mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-30 12:44:53 +02:00
Fixed and added tests
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,13 +645,22 @@ export class ScheduleService {
|
||||
}
|
||||
|
||||
private async usedTimeZones(): Promise<string[]> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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)),
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
]}
|
||||
/>
|
||||
{#if item.appointment.status === "reserved"}
|
||||
<div class="mt-5 flex flex-col gap-2">
|
||||
<div class="mt-5 flex w-full flex-col gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
disabled={isConfirming || isDenying}
|
||||
|
||||
Reference in New Issue
Block a user