Files
appointment-booking-software/src/lib/server/services/__tests__/appointment-service.test.ts
T

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");
});
});
});