From 34f1b9729b3ad348bc4a81ef5cb996364f4d9e70 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 26 Aug 2026 09:38:26 +0200 Subject: [PATCH] Fixed and added tests --- .../services/__tests__/agent-service.test.ts | 110 ++++++- .../__tests__/appointment-service.test.ts | 64 +++++ .../__tests__/channel-service.test.ts | 105 ++++++- .../__tests__/schedule-service.test.ts | 271 +++++++++++++++++- src/lib/server/services/schedule-service.ts | 43 ++- .../calendar/(components)/Appointment.svelte | 2 +- 6 files changed, 575 insertions(+), 20 deletions(-) diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 447dae6..34115d2 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -29,6 +29,27 @@ vi.mock("$lib/logger", () => ({ }, })); +const { mockCleanAndRegenerateCache, mockGetAllChannels } = vi.hoisted(() => ({ + mockCleanAndRegenerateCache: vi.fn().mockResolvedValue(undefined), + mockGetAllChannels: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../schedule-service", () => ({ + ScheduleService: { + forTenant: vi.fn().mockResolvedValue({ + cleanAndRegenerateCache: mockCleanAndRegenerateCache, + }), + }, +})); + +vi.mock("../channel-service", () => ({ + ChannelService: { + forTenant: vi.fn().mockResolvedValue({ + getAllChannels: mockGetAllChannels, + }), + }, +})); + // Import after mocking import { AgentService, @@ -83,7 +104,9 @@ const mockDb = { })), })), delete: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([]), + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), })), }; return await callback(mockTx); @@ -104,6 +127,8 @@ describe("AgentService", () => { beforeEach(async () => { vi.clearAllMocks(); vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + mockCleanAndRegenerateCache.mockResolvedValue(undefined); + mockGetAllChannels.mockResolvedValue([]); // Import and get the mocked centralDb const dbModule = await import("../../db"); @@ -484,10 +509,39 @@ describe("AgentService", () => { }; mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockDb.transaction = vi.fn(async (callback) => { + const mockTx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([mockAgent]), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([ + { + agentId: "agent-123", + channelId: "channel-123", + }, + ]), + })), + })), + }; + return await callback(mockTx); + }); + const result = await service.deleteAgent("agent-123"); expect(result).toBe(true); expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should return false when agent not found", async () => { @@ -502,7 +556,9 @@ describe("AgentService", () => { })), })), delete: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([]), + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), })), }; return await callback(mockTx); @@ -615,6 +671,12 @@ describe("AgentService", () => { agentId: "agent-123", channelId: "channel-123", }); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should handle database error", async () => { @@ -648,6 +710,12 @@ describe("AgentService", () => { await service.removeAgentFromChannel("agent-123", "channel-123"); expect(mockDb.delete).toHaveBeenCalled(); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should handle database error", async () => { @@ -730,6 +798,12 @@ describe("AgentService", () => { })), }; mockDb.insert.mockReturnValue(insertChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: request.agentId }], + }, + ] as any); const result = await service.createAbsence(request); @@ -745,6 +819,14 @@ describe("AgentService", () => { from: null, to: null, }); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + startDate: new Date(request.startDate), + endDate: new Date(request.endDate), + }), + ); }); it("should create recurring absence successfully", async () => { @@ -1054,11 +1136,23 @@ describe("AgentService", () => { })), }; mockDb.update.mockReturnValue(updateChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: "agent-123" }], + }, + ] as any); const result = await service.updateAbsence("absence-123", updateData); expect(result.absenceType).toBe(updateData.absenceType); expect(result.description).toBe(updateData.description); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should update absence successfully", async () => { @@ -1196,10 +1290,22 @@ describe("AgentService", () => { })), }; mockDb.delete.mockReturnValue(deleteChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: "agent-123" }], + }, + ] as any); const result = await service.deleteAbsence("absence-123"); expect(result).toBe(true); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should return false if absence not found", async () => { diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 610d9d6..b858581 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -35,6 +35,18 @@ vi.mock("../notification-service", () => ({ }, })); +const { mockCleanAndRegenerateCache } = vi.hoisted(() => ({ + mockCleanAndRegenerateCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../schedule-service", () => ({ + ScheduleService: { + forTenant: vi.fn().mockResolvedValue({ + cleanAndRegenerateCache: mockCleanAndRegenerateCache, + }), + }, +})); + const mockAppointment = { id: "appointment-123", tunnelId: "tunnel-123", @@ -86,6 +98,7 @@ const mockClientTunnelData = { describe("AppointmentService", () => { beforeEach(() => { vi.clearAllMocks(); + mockCleanAndRegenerateCache.mockResolvedValue(undefined); }); describe("forTenant", () => { @@ -289,6 +302,13 @@ describe("AppointmentService", () => { expect.objectContaining({ agentId: "agent-456" }), "Test Channel", ); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -329,6 +349,7 @@ describe("AppointmentService", () => { returning: vi.fn().mockResolvedValue([ { id: "appointment-123", + channelId: "channel-123", appointmentDate: new Date("2024-01-15T10:00:00Z"), status: "NEW", }, @@ -363,6 +384,13 @@ describe("AppointmentService", () => { expect(result.id).toBe("appointment-123"); expect(result.status).toBe("NEW"); expect(result.appointmentDate).toBe("2024-01-15T10:00:00.000Z"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should block creation when no authorized users exist", async () => { @@ -512,6 +540,7 @@ describe("AppointmentService", () => { returning: vi.fn().mockResolvedValue([ { id: "apt-123", + channelId: "channel-123", appointmentDate: new Date("2024-01-01T10:00:00Z"), status: "CONFIRMED", }, @@ -545,6 +574,13 @@ describe("AppointmentService", () => { expect(result.status).toBe("CONFIRMED"); expect(mockTransaction).toHaveBeenCalled(); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -611,6 +647,13 @@ describe("AppointmentService", () => { const result = await service.deleteAppointment("appointment-123"); expect(result).toBe(true); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should return false when appointment not found", async () => { @@ -688,6 +731,13 @@ describe("AppointmentService", () => { expect(mockDb.delete).toHaveBeenCalled(); expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should throw NotFoundError when appointment does not exist", async () => { @@ -764,6 +814,13 @@ describe("AppointmentService", () => { await deletePromise; expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -948,6 +1005,13 @@ describe("AppointmentService", () => { expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123"); expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should throw NotFoundError when challenge is not found", async () => { diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index 707eba1..833bf51 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -32,6 +32,18 @@ vi.mock("../../db", () => ({ })), insert: vi.fn(), update: vi.fn(), + }, + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + groupBy: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })), delete: vi.fn(), }, })); @@ -67,6 +79,7 @@ vi.mock("../../db/tenant-config", () => ({ // Import after mocking import { ChannelService } from "../channel-service"; import { getTenantDb } from "../../db"; +import { ScheduleService } from "../schedule-service"; // Mock data with valid UUIDs const mockChannel = { @@ -102,7 +115,9 @@ const mockDb = { insert: vi.fn(), select: vi.fn(), update: vi.fn(), - delete: vi.fn(), + delete: vi.fn(() => ({ + where: vi.fn(() => ({})), + })), }; describe("ChannelService", () => { @@ -128,9 +143,13 @@ describe("ChannelService", () => { describe("createChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should create channel successfully", async () => { @@ -145,12 +164,21 @@ describe("ChannelService", () => { // Use minimal valid request that matches schema exactly const request = { names: { en: "Test Channel" }, + slotTemplates: [ + { + weekdays: 1, + from: "09:00:00", + to: "17:00:00", + duration: 30, + }, + ], }; const result = await service.createChannel(request as any); expect(result).toEqual(expectedResult); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); }); it("should create channel with slot templates and agents", async () => { @@ -245,16 +273,52 @@ describe("ChannelService", () => { describe("updateChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should update channel successfully", async () => { const expectedResult = { ...mockChannel, names: { en: "Updated Channel" }, - agents: [], + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }; + + mockDb.transaction.mockResolvedValue(expectedResult); + + const updateData = { + names: { en: "Updated Channel" }, + slotTemplates: [ + { + weekdays: 1, + from: "09:00:00", + to: "17:00:00", + duration: 30, + }, + ], + }; + + const result = await service.updateChannel( + "550e8400-e29b-41d4-a716-446655440000", + updateData, + ); + + expect(result).toEqual(expectedResult); + expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); + }); + + it("should not rebuild schedule cache if not needed", async () => { + const expectedResult = { + ...mockChannel, + names: { en: "Updated Channel" }, + agents: [mockAgent], slotTemplates: [], }; @@ -271,6 +335,7 @@ describe("ChannelService", () => { expect(result).toEqual(expectedResult); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).not.toHaveBeenCalled(); }); it("should handle validation error for invalid name", async () => { @@ -391,18 +456,52 @@ describe("ChannelService", () => { describe("deleteChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should delete channel successfully", async () => { - mockDb.transaction.mockResolvedValue(true); + mockDb.transaction.mockImplementation(async (callback: any) => { + let selectCall = 0; + const tx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => { + selectCall++; + if (selectCall === 1) { + return [{ slotTemplateId: "550e8400-e29b-41d4-a716-446655440002" }]; + } + return { + limit: vi.fn().mockResolvedValue([]), + }; + }), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: mockChannel.id }]), + })), + })), + })), + }; + + return callback(tx); + }); const result = await service.deleteChannel("550e8400-e29b-41d4-a716-446655440000"); expect(result).toBe(true); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); }); it("should return false when channel not found", async () => { diff --git a/src/lib/server/services/__tests__/schedule-service.test.ts b/src/lib/server/services/__tests__/schedule-service.test.ts index 4e2ac97..a7786bc 100644 --- a/src/lib/server/services/__tests__/schedule-service.test.ts +++ b/src/lib/server/services/__tests__/schedule-service.test.ts @@ -27,6 +27,14 @@ import { getTenantDb } from "../../db"; // Mock database operations with proper query chain handling const mockDb = { select: vi.fn(), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => Promise.resolve()), + })), + })), }; // Helper to setup database query mocks for the exact ScheduleService query pattern @@ -36,8 +44,11 @@ function setupDbMocks(responses: { appointments: any[]; absences: any[]; channelAgents: any[]; + scheduleCache?: any[]; + keyShares?: any[]; }) { let queryCallIndex = 0; + const hasKeyShareQuery = typeof responses.keyShares !== "undefined"; (mockDb.select as any).mockImplementation(() => { queryCallIndex++; @@ -69,8 +80,18 @@ function setupDbMocks(responses: { }; } + // Query 3a (optional): staff key shares - select with where + if (hasKeyShareQuery && queryCallIndex === 4) { + return { + from: vi.fn(() => ({ + where: vi.fn(() => responses.keyShares ?? []), + })), + }; + } + // Query 4: Absences - select with where (complex date conditions) - if (queryCallIndex === 4) { + const absencesQueryIndex = hasKeyShareQuery ? 5 : 4; + if (queryCallIndex === absencesQueryIndex) { return { from: vi.fn(() => ({ where: vi.fn(() => responses.absences), @@ -79,7 +100,8 @@ function setupDbMocks(responses: { } // Query 5: Channel Agents - select with innerJoin - if (queryCallIndex === 5) { + const channelAgentsQueryIndex = hasKeyShareQuery ? 6 : 5; + if (queryCallIndex === channelAgentsQueryIndex) { return { from: vi.fn(() => ({ innerJoin: vi.fn(() => responses.channelAgents), @@ -87,10 +109,24 @@ function setupDbMocks(responses: { }; } + // Query 6+: Schedule cache lookup - select with where().orderBy() + const firstCacheQueryIndex = hasKeyShareQuery ? 7 : 6; + if (queryCallIndex >= firstCacheQueryIndex) { + return { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => responses.scheduleCache ?? []), + })), + })), + }; + } + // Default fallback return { from: vi.fn(() => ({ - where: vi.fn(() => []), + where: vi.fn(() => ({ + orderBy: vi.fn(() => []), + })), innerJoin: vi.fn(() => []), })), }; @@ -999,4 +1035,233 @@ describe("ScheduleService", () => { ]); }); }); + + describe("cache management", () => { + let service: ScheduleService; + + beforeEach(async () => { + service = await ScheduleService.forTenant(mockTenantId); + }); + + it("should clean cache and rebuild synchronously when awaitRebuild is true", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }, { timezone: "Europe/Berlin" }]), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).toHaveBeenCalledTimes(2); + expect(getScheduleSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + tenantId: mockTenantId, + channelId: "channel-123", + timeZone: "UTC", + }), + ); + expect(getScheduleSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + tenantId: mockTenantId, + channelId: "channel-123", + timeZone: "Europe/Berlin", + }), + ); + }); + + it("should cap rebuild end date to max allowed horizon", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const veryFarEndDate = new Date("3000-01-01T00:00:00.000Z"); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }]), + })), + }); + + (mockDb.delete as any).mockReturnValue({ + where: vi.fn(() => Promise.resolve()), + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: veryFarEndDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate: veryFarEndDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + const now = new Date(); + const maxEndDate = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 14, 0, 23, 59, 59, 999), + ); + + expect(getScheduleSpy).toHaveBeenCalledTimes(1); + expect(new Date(getScheduleSpy.mock.calls[0][0].endDate).toISOString()).toBe( + maxEndDate.toISOString(), + ); + }); + + it("should clean cache and trigger background rebuild when awaitRebuild is false", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }]), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + }); + + // Background rebuild starts immediately even though it is not awaited. + await Promise.resolve(); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).toHaveBeenCalledTimes(1); + }); + + it("should handle usedTimeZones errors and still clear cache", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => { + throw new Error("groupBy failed"); + }), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).not.toHaveBeenCalled(); + }); + + it("should delete past cache entries", async () => { + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + await service.cleanPastCache(); + + expect(mockDb.delete).toHaveBeenCalledTimes(1); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("should generate cache ahead for all channels and used timezones", async () => { + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }, + schedule: [], + }); + + let selectCall = 0; + (mockDb.select as any).mockImplementation(() => { + selectCall++; + + // usedTimeZones() + if (selectCall === 1) { + return { + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }, { timezone: "Europe/Berlin" }]), + })), + }; + } + + // generateCacheAhead() channels query + return { + from: vi.fn(() => [{ id: "channel-1" }, { id: "channel-2" }]), + }; + }); + + await service.generateCacheAhead(); + await Promise.resolve(); + + expect(getScheduleSpy).toHaveBeenCalledTimes(4); + const observedCombinations = getScheduleSpy.mock.calls.map((call) => { + const req = call[0]; + return `${req.channelId}:${req.timeZone}`; + }); + + expect(observedCombinations).toEqual( + expect.arrayContaining([ + "channel-1:UTC", + "channel-1:Europe/Berlin", + "channel-2:UTC", + "channel-2:Europe/Berlin", + ]), + ); + }); + }); }); diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index bb6f82b..b061835 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -645,13 +645,22 @@ export class ScheduleService { } private async usedTimeZones(): Promise { - // TODO: Once we have a tenant timezone setting, we can get that timezone instead of all timezones in the cache - const db = await this.getDb(); - const timeZones = await db - .select({ timezone: scheduleCache.timezone }) - .from(scheduleCache) - .groupBy(scheduleCache.timezone); - return timeZones.map((tz) => tz.timezone); + try { + // TODO: Once we have a tenant timezone setting, we can get that timezone instead of all timezones in the cache + const db = await this.getDb(); + const timeZones = await db + .select({ timezone: scheduleCache.timezone }) + .from(scheduleCache) + .groupBy(scheduleCache.timezone); + return timeZones.map((tz) => tz.timezone); + } catch (error) { + const log = logger.setContext("ScheduleService"); + log.error("Failed to get used time zones", { + tenantId: this.tenantId, + error: String(error), + }); + return []; + } } /** @@ -670,10 +679,13 @@ export class ScheduleService { channelId: string; awaitRebuild?: boolean; }): Promise { - console.log("🚨🚨🚨🚨🚨🚨🚨🚨 cleanAndRegenerateCache()"); - console.log("channelId:", channelId); - console.log("startDate:", startDate); - console.log("endDate:", endDate); + const log = logger.setContext("ScheduleService"); + log.debug("Cleaning and regenerating cache", { + tenantId: this.tenantId, + channelId, + startDate: startDate.toISOString().split("T")[0], + endDate: endDate.toISOString().split("T")[0], + }); // Get all time zones for the channel in the cache const timeZones = await this.usedTimeZones(); @@ -718,11 +730,20 @@ export class ScheduleService { */ async generateCacheAhead(): Promise { const timeZones = await this.usedTimeZones(); + const log = logger.setContext("ScheduleService"); // Generate cache for next months const db = await this.getDb(); const channels = await db.select().from(tenantSchema.channel); for (const channel of channels) { + log.debug("Generating schedule cache ahead", { + tenantId: this.tenantId, + channelId: channel.id, + startDate: new Date().toISOString().split("T")[0], + endDate: new Date(new Date().setMonth(new Date().getMonth() + 14)) + .toISOString() + .split("T")[0], + }); this.generateCache({ startDate: new Date(), endDate: new Date(new Date().setMonth(new Date().getMonth() + 14)), diff --git a/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte index c1df9ad..1d43e3e 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte @@ -195,7 +195,7 @@ ]} /> {#if item.appointment.status === "reserved"} -
+