mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-27 11:14:49 +02:00
986 lines
33 KiB
TypeScript
986 lines
33 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { AppointmentService } from "../appointment-service";
|
|
import { NotFoundError, ConflictError } from "../../utils/errors";
|
|
|
|
// Mock dependencies
|
|
vi.mock("../../db", () => ({
|
|
getTenantDb: vi.fn(),
|
|
centralDb: {
|
|
select: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../challenge-store", () => ({
|
|
challengeStore: {
|
|
consume: vi.fn(),
|
|
store: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../challenge-throttle", () => ({
|
|
challengeThrottleService: {
|
|
recordFailedAttempt: vi.fn(),
|
|
clearThrottle: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../notification-service", () => ({
|
|
NotificationService: {
|
|
forTenant: vi.fn().mockResolvedValue({
|
|
sendAppointmentConfirmationEmail: vi.fn().mockResolvedValue(undefined),
|
|
sendAppointmentCancellationEmail: vi.fn().mockResolvedValue(undefined),
|
|
createNotification: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
const mockAppointment = {
|
|
id: "appointment-123",
|
|
tunnelId: "tunnel-123",
|
|
channelId: "channel-123",
|
|
appointmentDate: new Date("2024-01-15T10:00:00Z"),
|
|
duration: 10,
|
|
status: "NEW" as const,
|
|
encryptedPayload: "encrypted-data",
|
|
iv: "iv-data",
|
|
authTag: "auth-tag",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockClientTunnel = {
|
|
id: "tunnel-123",
|
|
emailHash: "email-hash-123",
|
|
clientPublicKey: "client-public-key",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const mockClientTunnelData = {
|
|
tunnelId: "tunnel-123",
|
|
channelId: "channel-123",
|
|
agentId: "agent-123",
|
|
appointmentDate: "2024-01-15T10:00:00Z",
|
|
appointmentTimeZone: "Europe/Berlin",
|
|
duration: 10,
|
|
emailHash: "email-hash-123",
|
|
clientEmail: "test@example.com",
|
|
clientLanguage: "de",
|
|
clientPublicKey: "client-public-key",
|
|
privateKeyShare: "private-key-share",
|
|
encryptedAppointment: {
|
|
encryptedPayload: "encrypted-data",
|
|
iv: "iv-data",
|
|
authTag: "auth-tag",
|
|
},
|
|
staffKeyShares: [
|
|
{
|
|
userId: "staff-123",
|
|
encryptedTunnelKey: "encrypted-tunnel-key",
|
|
},
|
|
],
|
|
clientEncryptedTunnelKey: "client-encrypted-tunnel-key",
|
|
};
|
|
|
|
describe("AppointmentService", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("forTenant", () => {
|
|
it("should create service for valid tenant", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = { select: vi.fn() };
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
expect(service.tenantId).toBe("tenant-123");
|
|
expect(getTenantDb).toHaveBeenCalledWith("tenant-123");
|
|
});
|
|
|
|
it("should handle database connection errors", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
vi.mocked(getTenantDb).mockRejectedValue(new Error("Database connection failed"));
|
|
|
|
await expect(AppointmentService.forTenant("tenant-123")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getClientTunnels", () => {
|
|
it("should return client tunnels", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([mockClientTunnel]),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getClientTunnels();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe("tunnel-123");
|
|
expect(result[0].emailHash).toBe("email-hash-123");
|
|
expect(result[0].clientPublicKey).toBe("client-public-key");
|
|
expect(result[0].createdAt).toBeDefined();
|
|
});
|
|
|
|
it("should return empty array when no tunnels exist", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getClientTunnels();
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should include current staff encrypted tunnel key when staffUserId is provided", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
const mockSelect = vi
|
|
.fn()
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([mockClientTunnel]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue([
|
|
{
|
|
tunnelId: "tunnel-123",
|
|
encryptedTunnelKey: "staff-encrypted-tunnel-key",
|
|
},
|
|
]),
|
|
}),
|
|
});
|
|
|
|
const mockDb = {
|
|
select: mockSelect,
|
|
};
|
|
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getClientTunnels("staff-123");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].currentStaffEncryptedTunnelKey).toBe("staff-encrypted-tunnel-key");
|
|
expect(result[0]).not.toHaveProperty("clientEncryptedTunnelKey");
|
|
});
|
|
|
|
it("should set current staff encrypted tunnel key to undefined when no share exists", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
const mockSelect = vi
|
|
.fn()
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([mockClientTunnel]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
});
|
|
|
|
const mockDb = {
|
|
select: mockSelect,
|
|
};
|
|
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getClientTunnels("staff-123");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].currentStaffEncryptedTunnelKey).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("createNewClientWithAppointment", () => {
|
|
it("should create client tunnel and appointment successfully", async () => {
|
|
const { getTenantDb, centralDb } = await import("../../db");
|
|
|
|
// Mock authorization check - users exist
|
|
const mockAuthBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
|
|
};
|
|
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
|
|
|
|
// Mock existing tunnel check (client doesn't exist yet)
|
|
const mockExistingTunnelBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([]), // No existing tunnel
|
|
};
|
|
|
|
// Mock tenant database transaction
|
|
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
|
|
const tx = {
|
|
insert: vi
|
|
.fn()
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockResolvedValue(undefined),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "appointment-123",
|
|
appointmentDate: new Date("2024-01-15T10:00:00Z"),
|
|
status: "NEW",
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([{ requiresConfirmation: true }]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
return await callback(tx);
|
|
});
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any),
|
|
transaction: mockTransaction,
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.createNewClientWithAppointment(mockClientTunnelData);
|
|
|
|
expect(result.id).toBe("appointment-123");
|
|
expect(result.status).toBe("NEW");
|
|
expect(result.appointmentDate).toBe("2024-01-15T10:00:00.000Z");
|
|
});
|
|
|
|
it("should block creation when no authorized users exist", async () => {
|
|
const { centralDb } = await import("../../db");
|
|
|
|
// Mock authorization check - no users exist
|
|
const mockAuthBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([]),
|
|
};
|
|
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
|
|
ConflictError,
|
|
);
|
|
});
|
|
|
|
it("should throw ConflictError when client already exists", async () => {
|
|
const { getTenantDb, centralDb } = await import("../../db");
|
|
|
|
// Mock authorization check - users exist
|
|
const mockAuthBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
|
|
};
|
|
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
|
|
|
|
// Mock existing tunnel check (client already exists)
|
|
const mockExistingTunnelBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([{ id: "existing-tunnel-123" }]), // Existing tunnel found
|
|
};
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
|
|
ConflictError,
|
|
);
|
|
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
|
|
"This email address is already registered",
|
|
);
|
|
});
|
|
|
|
it("should throw NotFoundError when channel not found", async () => {
|
|
const { getTenantDb, centralDb } = await import("../../db");
|
|
|
|
// Mock authorization check - users exist
|
|
const mockAuthBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
|
|
};
|
|
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
|
|
|
|
// Mock existing tunnel check (client doesn't exist yet)
|
|
const mockExistingTunnelBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([]), // No existing tunnel
|
|
};
|
|
|
|
// Mock tenant database transaction with channel not found
|
|
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
|
|
const tx = {
|
|
insert: vi
|
|
.fn()
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([]), // Channel not found
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
return await callback(tx);
|
|
});
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any),
|
|
transaction: mockTransaction,
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
|
|
NotFoundError,
|
|
);
|
|
});
|
|
|
|
it("should create appointment with CONFIRMED status when staff user creates it", async () => {
|
|
const { getTenantDb, centralDb } = await import("../../db");
|
|
|
|
// Mock authorization check - users exist
|
|
const mockAuthBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
|
|
};
|
|
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
|
|
|
|
// Mock existing tunnel check (client doesn't exist yet)
|
|
const mockExistingTunnelBuilder = {
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockResolvedValue([]), // No existing tunnel
|
|
};
|
|
|
|
// Mock tenant database transaction with successful creation
|
|
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
|
|
const tx = {
|
|
insert: vi
|
|
.fn()
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockResolvedValue(undefined),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "apt-123",
|
|
appointmentDate: new Date("2024-01-01T10:00:00Z"),
|
|
status: "CONFIRMED",
|
|
},
|
|
]),
|
|
}),
|
|
}),
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([{ id: "channel-456" }]), // Channel found
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
return await callback(tx);
|
|
});
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any),
|
|
transaction: mockTransaction,
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.createNewClientWithAppointment(mockClientTunnelData);
|
|
|
|
expect(result.status).toBe("CONFIRMED");
|
|
expect(mockTransaction).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("getAppointmentsByTimeRange", () => {
|
|
it("should return appointments within time range", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([mockAppointment]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const startDate = new Date("2024-01-01T00:00:00Z");
|
|
const endDate = new Date("2024-01-31T23:59:59Z");
|
|
|
|
const result = await service.getAppointmentsByTimeRange(startDate, endDate);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe("appointment-123");
|
|
});
|
|
|
|
it("should return empty array when no appointments in range", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const startDate = new Date("2024-01-01T00:00:00Z");
|
|
const endDate = new Date("2024-01-31T23:59:59Z");
|
|
|
|
const result = await service.getAppointmentsByTimeRange(startDate, endDate);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("deleteAppointment", () => {
|
|
it("should delete appointment successfully", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
delete: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([mockAppointment]),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.deleteAppointment("appointment-123");
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false when appointment not found", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
delete: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.deleteAppointment("appointment-123");
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("deleteAppointmentByStaff", () => {
|
|
it("should delete appointment and send email/notifications", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
// Mock email service
|
|
const emailModule = await import("../../email/email-service");
|
|
const mockSendEmail = vi.fn().mockResolvedValue(undefined);
|
|
const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel");
|
|
vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail);
|
|
vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle);
|
|
|
|
// Mock TenantAdminService
|
|
const tenantModule = await import("../tenant-admin-service");
|
|
const mockTenant = {
|
|
id: "tenant-123",
|
|
shortName: "test-clinic",
|
|
longName: "Test Clinic",
|
|
languages: ["de", "en"],
|
|
};
|
|
vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({
|
|
tenantData: mockTenant,
|
|
} as any);
|
|
|
|
// Mock NotificationService
|
|
const notificationModule = await import("../notification-service");
|
|
const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]);
|
|
vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({
|
|
createNotification: mockCreateNotification,
|
|
} as any);
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([mockAppointment]),
|
|
}),
|
|
}),
|
|
}),
|
|
delete: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
// Use a promise to track async operations
|
|
const deletePromise = service.deleteAppointmentByStaff(
|
|
"appointment-123",
|
|
"client@example.com",
|
|
"de",
|
|
);
|
|
|
|
await deletePromise;
|
|
|
|
expect(mockDb.delete).toHaveBeenCalled();
|
|
expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de");
|
|
});
|
|
|
|
it("should throw NotFoundError when appointment does not exist", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(
|
|
service.deleteAppointmentByStaff("appointment-123", "client@example.com", "de"),
|
|
).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it("should use default language when not provided", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
// Mock email service
|
|
const emailModule = await import("../../email/email-service");
|
|
const mockSendEmail = vi.fn().mockResolvedValue(undefined);
|
|
const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel");
|
|
vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail);
|
|
vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle);
|
|
|
|
// Mock TenantAdminService
|
|
const tenantModule = await import("../tenant-admin-service");
|
|
const mockTenant = {
|
|
id: "tenant-123",
|
|
shortName: "test-clinic",
|
|
longName: "Test Clinic",
|
|
languages: ["de", "en"],
|
|
};
|
|
vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({
|
|
tenantData: mockTenant,
|
|
} as any);
|
|
|
|
// Mock NotificationService
|
|
const notificationModule = await import("../notification-service");
|
|
const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]);
|
|
vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({
|
|
createNotification: mockCreateNotification,
|
|
} as any);
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([mockAppointment]),
|
|
}),
|
|
}),
|
|
}),
|
|
delete: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
// Use a promise to track async operations
|
|
const deletePromise = service.deleteAppointmentByStaff(
|
|
"appointment-123",
|
|
"client@example.com",
|
|
);
|
|
|
|
await deletePromise;
|
|
|
|
expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de");
|
|
});
|
|
});
|
|
|
|
describe("getFutureAppointmentsByTunnelId", () => {
|
|
it("should return future appointments for a client tunnel", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
const futureDate1 = new Date("2025-02-15T10:00:00Z");
|
|
const futureDate2 = new Date("2025-03-20T14:30:00Z");
|
|
|
|
const mockFutureAppointments = [
|
|
{
|
|
id: "appointment-1",
|
|
appointmentDate: futureDate1,
|
|
status: "CONFIRMED",
|
|
channelId: "channel-1",
|
|
encryptedPayload: "encrypted-1",
|
|
iv: "iv-1",
|
|
authTag: "auth-tag-1",
|
|
},
|
|
{
|
|
id: "appointment-2",
|
|
appointmentDate: futureDate2,
|
|
status: "NEW",
|
|
channelId: "channel-2",
|
|
encryptedPayload: "encrypted-2",
|
|
iv: "iv-2",
|
|
authTag: "auth-tag-2",
|
|
},
|
|
];
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue(mockFutureAppointments),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({
|
|
id: "appointment-1",
|
|
appointmentDate: futureDate1.toISOString(),
|
|
status: "CONFIRMED",
|
|
channelId: "channel-1",
|
|
encryptedPayload: "encrypted-1",
|
|
iv: "iv-1",
|
|
authTag: "auth-tag-1",
|
|
});
|
|
expect(result[1]).toEqual({
|
|
id: "appointment-2",
|
|
appointmentDate: futureDate2.toISOString(),
|
|
status: "NEW",
|
|
channelId: "channel-2",
|
|
encryptedPayload: "encrypted-2",
|
|
iv: "iv-2",
|
|
authTag: "auth-tag-2",
|
|
});
|
|
});
|
|
|
|
it("should return empty array when no future appointments exist", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should handle null encrypted fields gracefully", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
|
|
const futureDate = new Date("2025-02-15T10:00:00Z");
|
|
const mockAppointmentsWithNulls = [
|
|
{
|
|
id: "appointment-1",
|
|
appointmentDate: futureDate,
|
|
status: "NEW",
|
|
channelId: "channel-1",
|
|
encryptedPayload: null,
|
|
iv: null,
|
|
authTag: null,
|
|
},
|
|
];
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue(mockAppointmentsWithNulls),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
const result = await service.getFutureAppointmentsByTunnelId("tunnel-123");
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].encryptedPayload).toBe("");
|
|
expect(result[0].iv).toBe("");
|
|
expect(result[0].authTag).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("deleteAppointmentByClient", () => {
|
|
it("should delete appointment after verifying challenge and ownership", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const { challengeStore } = await import("../challenge-store");
|
|
const { challengeThrottleService } = await import("../challenge-throttle");
|
|
|
|
const mockAppointmentDate = new Date("2025-02-15T10:00:00Z");
|
|
|
|
// Mock challenge verification
|
|
vi.mocked(challengeStore.consume).mockResolvedValue({
|
|
challenge: Buffer.from("test-challenge").toString("base64"),
|
|
emailHash: "email-hash-123",
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
});
|
|
|
|
vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue();
|
|
|
|
// Mock database queries - use mockReturnValueOnce for sequential calls
|
|
const mockLimit = vi
|
|
.fn()
|
|
// First call: appointment query
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: "appointment-123",
|
|
tunnelId: "tunnel-123",
|
|
appointmentDate: mockAppointmentDate,
|
|
channelId: "channel-123",
|
|
},
|
|
])
|
|
// Second call: tunnel query
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: "tunnel-123",
|
|
emailHash: "email-hash-123",
|
|
},
|
|
]);
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: mockLimit,
|
|
}),
|
|
delete: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockResolvedValue(undefined),
|
|
}),
|
|
};
|
|
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
await service.deleteAppointmentByClient(
|
|
"appointment-123",
|
|
"email-hash-123",
|
|
"challenge-123",
|
|
Buffer.from("test-challenge").toString("base64"),
|
|
);
|
|
|
|
expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123");
|
|
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin");
|
|
});
|
|
|
|
it("should throw NotFoundError when challenge is not found", async () => {
|
|
const { challengeStore } = await import("../challenge-store");
|
|
|
|
vi.mocked(challengeStore.consume).mockResolvedValue(null);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(
|
|
service.deleteAppointmentByClient(
|
|
"appointment-123",
|
|
"email-hash-123",
|
|
"invalid-challenge",
|
|
"challenge-response",
|
|
),
|
|
).rejects.toThrow(NotFoundError);
|
|
});
|
|
|
|
it("should throw ValidationError when challenge doesn't belong to client", async () => {
|
|
const { challengeStore } = await import("../challenge-store");
|
|
|
|
vi.mocked(challengeStore.consume).mockResolvedValue({
|
|
challenge: Buffer.from("test-challenge").toString("base64"),
|
|
emailHash: "different-email-hash",
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
});
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(
|
|
service.deleteAppointmentByClient(
|
|
"appointment-123",
|
|
"email-hash-123",
|
|
"challenge-123",
|
|
Buffer.from("test-challenge").toString("base64"),
|
|
),
|
|
).rejects.toThrow("Invalid authentication");
|
|
});
|
|
|
|
it("should throw ValidationError when challenge response is incorrect", async () => {
|
|
const { challengeStore } = await import("../challenge-store");
|
|
const { challengeThrottleService } = await import("../challenge-throttle");
|
|
|
|
vi.mocked(challengeStore.consume).mockResolvedValue({
|
|
challenge: Buffer.from("correct-challenge").toString("base64"),
|
|
emailHash: "email-hash-123",
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
});
|
|
|
|
vi.mocked(challengeThrottleService.recordFailedAttempt).mockResolvedValue();
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(
|
|
service.deleteAppointmentByClient(
|
|
"appointment-123",
|
|
"email-hash-123",
|
|
"challenge-123",
|
|
Buffer.from("wrong-challenge").toString("base64"),
|
|
),
|
|
).rejects.toThrow("Invalid challenge response");
|
|
|
|
expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith(
|
|
"email-hash-123",
|
|
"pin",
|
|
);
|
|
});
|
|
|
|
it("should throw ValidationError when appointment doesn't belong to client", async () => {
|
|
const { getTenantDb } = await import("../../db");
|
|
const { challengeStore } = await import("../challenge-store");
|
|
const { challengeThrottleService } = await import("../challenge-throttle");
|
|
|
|
const mockAppointmentDate = new Date("2025-02-15T10:00:00Z");
|
|
|
|
vi.mocked(challengeStore.consume).mockResolvedValue({
|
|
challenge: Buffer.from("test-challenge").toString("base64"),
|
|
emailHash: "email-hash-123",
|
|
createdAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
});
|
|
|
|
vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue();
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockImplementation(() => ({
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockImplementation(() => {
|
|
const callStack = new Error().stack || "";
|
|
if (callStack.includes("appointment")) {
|
|
return Promise.resolve([
|
|
{
|
|
id: "appointment-123",
|
|
tunnelId: "tunnel-123",
|
|
appointmentDate: mockAppointmentDate,
|
|
channelId: "channel-123",
|
|
},
|
|
]);
|
|
} else {
|
|
// Tunnel belongs to different email
|
|
return Promise.resolve([
|
|
{
|
|
id: "tunnel-123",
|
|
emailHash: "different-email-hash",
|
|
},
|
|
]);
|
|
}
|
|
}),
|
|
})),
|
|
};
|
|
|
|
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
|
|
|
|
const service = await AppointmentService.forTenant("tenant-123");
|
|
|
|
await expect(
|
|
service.deleteAppointmentByClient(
|
|
"appointment-123",
|
|
"email-hash-123",
|
|
"challenge-123",
|
|
Buffer.from("test-challenge").toString("base64"),
|
|
),
|
|
).rejects.toThrow("Appointment does not belong to this client");
|
|
});
|
|
});
|
|
});
|