Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
816 lines
27 KiB
TypeScript
816 lines
27 KiB
TypeScript
import { Logger } from '@nestjs/common';
|
|
import { CalendarSyncService } from './calendar-sync.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
|
|
import { GraphClientService, GraphRequestError } from './graph-client.service';
|
|
import { Prisma } from '../../generated/prisma';
|
|
|
|
describe('CalendarSyncService', () => {
|
|
let service: CalendarSyncService;
|
|
let prisma: {
|
|
calendarSyncOutboxEntry: {
|
|
upsert: jest.Mock;
|
|
findUnique: jest.Mock;
|
|
update: jest.Mock;
|
|
updateMany: jest.Mock;
|
|
findMany: jest.Mock;
|
|
count: jest.Mock;
|
|
};
|
|
task: { findUnique: jest.Mock };
|
|
inspection: { findUnique: jest.Mock };
|
|
treatment: { findUnique: jest.Mock };
|
|
feeding: { findUnique: jest.Mock };
|
|
harvest: { findUnique: jest.Mock };
|
|
queenColonyHistory: { findUnique: jest.Mock };
|
|
$executeRaw: jest.Mock;
|
|
};
|
|
let settingsService: { get: jest.Mock };
|
|
let graphClient: {
|
|
isConfigured: jest.Mock;
|
|
createEvent: jest.Mock;
|
|
updateEvent: jest.Mock;
|
|
deleteEvent: jest.Mock;
|
|
};
|
|
|
|
const enabledSettings = {
|
|
id: 'singleton',
|
|
enabled: true,
|
|
groupId: 'group-1',
|
|
syncTask: true,
|
|
syncInspection: true,
|
|
syncTreatment: true,
|
|
syncFeeding: true,
|
|
syncHarvest: true,
|
|
syncQueenAssignment: true,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const pendingEntry = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 'entry-1',
|
|
sourceType: 'TASK',
|
|
sourceId: 'task-1',
|
|
status: 'PENDING',
|
|
graphEventId: null,
|
|
attempts: 0,
|
|
revision: 3,
|
|
claimedAt: null,
|
|
...overrides,
|
|
});
|
|
|
|
/** Derived from the outbox entry, shaped like a GUID. */
|
|
const TRANSACTION_ID = expect.stringMatching(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
) as string;
|
|
|
|
/** Free, or held by a worker that gave up long ago (cut-off time). */
|
|
const CLAIMABLE = {
|
|
OR: [{ claimedAt: null }, { claimedAt: { lt: expect.any(Date) as Date } }],
|
|
};
|
|
|
|
type UpdateManyArgs = {
|
|
where: Record<string, unknown>;
|
|
data: Record<string, unknown>;
|
|
};
|
|
const updateManyCalls = () =>
|
|
prisma.calendarSyncOutboxEntry.updateMany.mock.calls.map(
|
|
([args]) => args as UpdateManyArgs,
|
|
);
|
|
/** The claim taken by this pass - every later write is conditional on it. */
|
|
const claimedAt = () => updateManyCalls()[0].data.claimedAt as Date;
|
|
|
|
beforeEach(() => {
|
|
prisma = {
|
|
calendarSyncOutboxEntry: {
|
|
upsert: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
update: jest.fn(),
|
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
count: jest.fn().mockResolvedValue(0),
|
|
},
|
|
task: { findUnique: jest.fn() },
|
|
inspection: { findUnique: jest.fn() },
|
|
treatment: { findUnique: jest.fn() },
|
|
feeding: { findUnique: jest.fn() },
|
|
harvest: { findUnique: jest.fn() },
|
|
queenColonyHistory: { findUnique: jest.fn() },
|
|
$executeRaw: jest.fn().mockResolvedValue(1),
|
|
};
|
|
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
|
|
graphClient = {
|
|
isConfigured: jest.fn().mockReturnValue(true),
|
|
createEvent: jest.fn(),
|
|
updateEvent: jest.fn(),
|
|
deleteEvent: jest.fn(),
|
|
};
|
|
service = new CalendarSyncService(
|
|
prisma as unknown as PrismaService,
|
|
settingsService as unknown as CalendarSyncSettingsService,
|
|
graphClient as unknown as GraphClientService,
|
|
);
|
|
});
|
|
|
|
describe('enqueue', () => {
|
|
it('does nothing when sync is disabled overall', async () => {
|
|
settingsService.get.mockResolvedValue({
|
|
...enabledSettings,
|
|
enabled: false,
|
|
});
|
|
|
|
await service.enqueue('TASK', 'task-1');
|
|
|
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing when the source type toggle is off', async () => {
|
|
settingsService.get.mockResolvedValue({
|
|
...enabledSettings,
|
|
syncTask: false,
|
|
});
|
|
|
|
await service.enqueue('TASK', 'task-1');
|
|
|
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => {
|
|
await service.enqueue('TASK', 'task-1');
|
|
|
|
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
|
|
where: {
|
|
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
|
},
|
|
create: { sourceType: 'TASK', sourceId: 'task-1' },
|
|
update: {
|
|
status: 'PENDING',
|
|
attempts: 0,
|
|
lastError: null,
|
|
revision: { increment: 1 },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// enqueue() runs after the source record is committed: a failure must not
|
|
// fail that request (the client would retry and store it twice).
|
|
describe('post-commit calls', () => {
|
|
let errorSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
errorSpy = jest
|
|
.spyOn(Logger.prototype, 'error')
|
|
.mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('enqueue never throws and logs the failure with the record', async () => {
|
|
settingsService.get.mockRejectedValue(new Error('db down'));
|
|
|
|
await expect(
|
|
service.enqueue('INSPECTION', 'inspection-1'),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
const [message] = errorSpy.mock.calls[0] as [string];
|
|
expect(message).toContain('INSPECTION inspection-1');
|
|
expect(message).toContain('db down');
|
|
});
|
|
});
|
|
|
|
describe('enqueueIn', () => {
|
|
it('writes through the given client and lets errors propagate (handler retry)', async () => {
|
|
const txUpsert = jest.fn().mockResolvedValue(undefined);
|
|
const tx = {
|
|
calendarSyncOutboxEntry: { upsert: txUpsert },
|
|
} as unknown as Prisma.TransactionClient;
|
|
|
|
await service.enqueueIn(tx, 'TASK', 'task-1');
|
|
|
|
expect(txUpsert).toHaveBeenCalledWith({
|
|
where: {
|
|
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
|
|
},
|
|
create: { sourceType: 'TASK', sourceId: 'task-1' },
|
|
update: {
|
|
status: 'PENDING',
|
|
attempts: 0,
|
|
lastError: null,
|
|
revision: { increment: 1 },
|
|
},
|
|
});
|
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
|
|
|
txUpsert.mockRejectedValue(new Error('tx failed'));
|
|
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
|
|
'tx failed',
|
|
);
|
|
});
|
|
|
|
it('does nothing when sync is disabled', async () => {
|
|
settingsService.get.mockResolvedValue({
|
|
...enabledSettings,
|
|
enabled: false,
|
|
});
|
|
const txUpsert = jest.fn();
|
|
const tx = {
|
|
calendarSyncOutboxEntry: { upsert: txUpsert },
|
|
} as unknown as Prisma.TransactionClient;
|
|
|
|
await service.enqueueIn(tx, 'TASK', 'task-1');
|
|
|
|
expect(txUpsert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('processOutbox', () => {
|
|
const dueTask = {
|
|
id: 'task-1',
|
|
title: 'Beute prüfen',
|
|
dueDate: new Date('2026-03-07'),
|
|
status: 'OPEN',
|
|
};
|
|
|
|
/** The outcome write: only lands if the row is unchanged since the claim. */
|
|
const expectSettled = (data: Record<string, unknown>) => {
|
|
expect(updateManyCalls()[1]).toEqual({
|
|
where: {
|
|
id: 'entry-1',
|
|
status: 'PENDING',
|
|
revision: 3,
|
|
claimedAt: claimedAt(),
|
|
},
|
|
data: { ...data, claimedAt: null },
|
|
});
|
|
};
|
|
|
|
it('does nothing when sync is disabled, unconfigured, or no groupId', async () => {
|
|
settingsService.get.mockResolvedValue({
|
|
...enabledSettings,
|
|
enabled: false,
|
|
});
|
|
await service.processOutbox();
|
|
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only picks PENDING rows that nobody holds, or whose holder gave up long ago', async () => {
|
|
await service.processOutbox();
|
|
const after = Date.now();
|
|
|
|
const [[query]] = prisma.calendarSyncOutboxEntry.findMany.mock.calls as [
|
|
[{ where: Record<string, unknown> }],
|
|
];
|
|
expect(query.where).toEqual({
|
|
status: 'PENDING',
|
|
...CLAIMABLE,
|
|
});
|
|
const staleBefore = (
|
|
query.where.OR as [unknown, { claimedAt: { lt: Date } }]
|
|
)[1].claimedAt.lt;
|
|
// A claim counts as abandoned only after several minutes - far longer
|
|
// than one entry can take with bounded Graph requests.
|
|
expect(after - staleBefore.getTime()).toBeGreaterThanOrEqual(
|
|
5 * 60 * 1000,
|
|
);
|
|
});
|
|
|
|
// Worker shutdown: the entry in progress is finished and settled (a cut
|
|
// off one is only taken over after the stale-claim window), but no
|
|
// further entry is claimed.
|
|
it('stops between entries once the signal aborts', async () => {
|
|
const stop = new AbortController();
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
pendingEntry({ id: 'entry-2', sourceId: 'task-2' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.createEvent.mockImplementation(() => {
|
|
stop.abort();
|
|
return Promise.resolve('graph-event-1');
|
|
});
|
|
|
|
await service.processOutbox(stop.signal);
|
|
|
|
expect(graphClient.createEvent).toHaveBeenCalledTimes(1);
|
|
expectSettled({
|
|
status: 'SYNCED',
|
|
graphEventId: 'graph-event-1',
|
|
lastError: null,
|
|
});
|
|
expect(updateManyCalls().map((call) => call.where.id)).not.toContain(
|
|
'entry-2',
|
|
);
|
|
});
|
|
|
|
it('reads nothing once the signal has aborted', async () => {
|
|
const stop = new AbortController();
|
|
stop.abort();
|
|
|
|
await service.processOutbox(stop.signal);
|
|
|
|
expect(settingsService.get).not.toHaveBeenCalled();
|
|
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('claims a row with a conditional update before working on it', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(updateManyCalls()[0]).toEqual({
|
|
where: {
|
|
id: 'entry-1',
|
|
status: 'PENDING',
|
|
revision: 3,
|
|
...CLAIMABLE,
|
|
},
|
|
data: { claimedAt: expect.any(Date) as Date },
|
|
});
|
|
const [claimOrder] =
|
|
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
|
const [createOrder] = graphClient.createEvent.mock.invocationCallOrder;
|
|
expect(claimOrder).toBeLessThan(createOrder);
|
|
});
|
|
|
|
it('leaves a row alone when another pod or run claimed it first', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.calendarSyncOutboxEntry.updateMany.mockResolvedValueOnce({
|
|
count: 0,
|
|
});
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(prisma.task.findUnique).not.toHaveBeenCalled();
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledTimes(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
|
'group-1',
|
|
{ title: 'Beute prüfen', date: new Date('2026-03-07') },
|
|
TRANSACTION_ID,
|
|
);
|
|
expectSettled({
|
|
status: 'SYNCED',
|
|
graphEventId: 'graph-event-1',
|
|
lastError: null,
|
|
});
|
|
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the new event id but stays PENDING when the source changed during the sync', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
|
prisma.calendarSyncOutboxEntry.updateMany
|
|
.mockResolvedValueOnce({ count: 1 }) // claim
|
|
.mockResolvedValueOnce({ count: 0 }); // re-enqueued meanwhile
|
|
|
|
await service.processOutbox();
|
|
|
|
// Not SYNCED: the newer state still has to reach M365, and the next
|
|
// pass must update this event rather than create a second one.
|
|
expect(updateManyCalls()[2]).toEqual({
|
|
where: { id: 'entry-1', status: 'PENDING', claimedAt: claimedAt() },
|
|
data: { graphEventId: 'graph-event-1', claimedAt: null },
|
|
});
|
|
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes the event again when the source left the calendar during the sync', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
|
prisma.calendarSyncOutboxEntry.updateMany
|
|
.mockResolvedValueOnce({ count: 1 }) // claim
|
|
.mockResolvedValueOnce({ count: 0 }) // not unchanged
|
|
.mockResolvedValueOnce({ count: 0 }) // not PENDING: cancelled
|
|
.mockResolvedValueOnce({ count: 1 }); // claim released
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(updateManyCalls()[3]).toEqual({
|
|
where: { id: 'entry-1', claimedAt: claimedAt() },
|
|
data: { claimedAt: null },
|
|
});
|
|
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
|
'group-1',
|
|
'graph-event-1',
|
|
);
|
|
});
|
|
|
|
it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
|
|
expectSettled({ status: 'SKIPPED', graphEventId: null });
|
|
});
|
|
|
|
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
|
'group-1',
|
|
'graph-event-1',
|
|
);
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expect(graphClient.updateEvent).not.toHaveBeenCalled();
|
|
expectSettled({ status: 'SKIPPED', graphEventId: null });
|
|
});
|
|
|
|
it('treats a CANCELLED task like a DONE one', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({
|
|
...dueTask,
|
|
status: 'CANCELLED',
|
|
});
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.deleteEvent).toHaveBeenCalledTimes(1);
|
|
expectSettled({ status: 'SKIPPED', graphEventId: null });
|
|
});
|
|
|
|
it.each([404, 410])(
|
|
'treats %i on delete as already deleted',
|
|
async (status) => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({
|
|
...dueTask,
|
|
status: 'DONE',
|
|
});
|
|
graphClient.deleteEvent.mockRejectedValue(
|
|
new GraphRequestError('deleteEvent', status, 'gone'),
|
|
);
|
|
|
|
await service.processOutbox();
|
|
|
|
expectSettled({ status: 'SKIPPED', graphEventId: null });
|
|
},
|
|
);
|
|
|
|
it('counts any other delete error as a failed attempt', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
|
|
graphClient.deleteEvent.mockRejectedValue(
|
|
new GraphRequestError('deleteEvent', 503, 'busy'),
|
|
);
|
|
|
|
await service.processOutbox();
|
|
|
|
expectSettled({
|
|
status: 'PENDING',
|
|
attempts: 2,
|
|
lastError: expect.any(String) as string,
|
|
});
|
|
});
|
|
|
|
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({ ...dueTask, dueDate: null });
|
|
prisma.calendarSyncOutboxEntry.updateMany
|
|
.mockResolvedValueOnce({ count: 1 })
|
|
.mockResolvedValueOnce({ count: 0 });
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(updateManyCalls()[2]).toEqual({
|
|
where: { id: 'entry-1', claimedAt: claimedAt() },
|
|
data: { claimedAt: null },
|
|
});
|
|
});
|
|
|
|
it('updates an existing Graph event when graphEventId is already set', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue({
|
|
...dueTask,
|
|
title: 'Beute prüfen (geändert)',
|
|
dueDate: new Date('2026-03-10'),
|
|
});
|
|
graphClient.updateEvent.mockResolvedValue(undefined);
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.updateEvent).toHaveBeenCalledWith(
|
|
'group-1',
|
|
'graph-event-1',
|
|
{ title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') },
|
|
);
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expectSettled({
|
|
status: 'SYNCED',
|
|
graphEventId: 'graph-event-1',
|
|
lastError: null,
|
|
});
|
|
});
|
|
|
|
it.each([404, 410])(
|
|
'falls back to creating a fresh event when the stored event is gone (%i)',
|
|
async (status) => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'deleted-on-m365' }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.updateEvent.mockRejectedValue(
|
|
new GraphRequestError('updateEvent', status, 'gone'),
|
|
);
|
|
graphClient.createEvent.mockResolvedValue('graph-event-new');
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.createEvent).toHaveBeenCalled();
|
|
expectSettled({
|
|
status: 'SYNCED',
|
|
graphEventId: 'graph-event-new',
|
|
lastError: null,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
['a Graph server error', new GraphRequestError('updateEvent', 503, '')],
|
|
['a throttled request', new GraphRequestError('updateEvent', 429, '')],
|
|
['a network failure', new Error('fetch failed')],
|
|
])(
|
|
'does not create a second event after %s, but retries the update later',
|
|
async (_case, failure) => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
|
|
]);
|
|
prisma.task.findUnique.mockResolvedValue(dueTask);
|
|
graphClient.updateEvent.mockRejectedValue(failure);
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expectSettled({
|
|
status: 'PENDING',
|
|
attempts: 2,
|
|
lastError: failure.message,
|
|
});
|
|
},
|
|
);
|
|
|
|
it('retries (stays PENDING) on failure below the max attempt count', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ attempts: 1 }),
|
|
]);
|
|
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
|
|
|
await service.processOutbox();
|
|
|
|
expectSettled({ status: 'PENDING', attempts: 2, lastError: 'db error' });
|
|
});
|
|
|
|
// lastError is stored and shown to admins - a Prisma message would carry
|
|
// query values (titles, names) into it.
|
|
it('stores a Prisma failure in lastError by name, code and model only', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ attempts: 1 }),
|
|
]);
|
|
prisma.task.findUnique.mockRejectedValue(
|
|
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
|
|
code: 'P2024',
|
|
clientVersion: '7.9.1',
|
|
meta: { modelName: 'Task' },
|
|
}),
|
|
);
|
|
|
|
await service.processOutbox();
|
|
|
|
expectSettled({
|
|
status: 'PENDING',
|
|
attempts: 2,
|
|
lastError: 'PrismaClientKnownRequestError P2024 (Task)',
|
|
});
|
|
});
|
|
|
|
it('marks FAILED once the max attempt count is reached', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ attempts: 4 }),
|
|
]);
|
|
prisma.task.findUnique.mockRejectedValue(new Error('db error'));
|
|
|
|
await service.processOutbox();
|
|
|
|
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
|
|
});
|
|
|
|
it('builds titles per source type matching the read-only calendar view', async () => {
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
|
]);
|
|
prisma.inspection.findUnique.mockResolvedValue({
|
|
id: 'i1',
|
|
date: new Date('2026-03-07'),
|
|
colony: { code: 'V-001' },
|
|
});
|
|
graphClient.createEvent.mockResolvedValue('g1');
|
|
|
|
await service.processOutbox();
|
|
|
|
expect(graphClient.createEvent).toHaveBeenCalledWith(
|
|
'group-1',
|
|
{ title: 'Durchsicht V-001', date: new Date('2026-03-07') },
|
|
TRANSACTION_ID,
|
|
);
|
|
});
|
|
});
|
|
|
|
// A create that timed out may still have reached Graph. The retry - by
|
|
// this or another pod - sends the same transactionId, so Graph can drop
|
|
// it instead of adding a second event.
|
|
describe('transactionId on create', () => {
|
|
/** Runs one pass over the entry and returns the transactionId sent. */
|
|
const transactionIdFor = async (entry: Record<string, unknown>) => {
|
|
graphClient.createEvent.mockClear();
|
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
|
pendingEntry(entry),
|
|
]);
|
|
await service.processOutbox();
|
|
const [[, , transactionId]] = graphClient.createEvent.mock.calls as [
|
|
[string, unknown, string],
|
|
];
|
|
return transactionId;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
prisma.task.findUnique.mockResolvedValue({
|
|
id: 'task-1',
|
|
title: 'Beute prüfen',
|
|
dueDate: new Date('2026-03-07'),
|
|
});
|
|
graphClient.createEvent.mockResolvedValue('graph-event-1');
|
|
});
|
|
|
|
it('is the same for every retry of the same create', async () => {
|
|
graphClient.createEvent.mockRejectedValueOnce(new Error('timeout'));
|
|
const first = await transactionIdFor({ attempts: 0 });
|
|
const retry = await transactionIdFor({ attempts: 1 });
|
|
|
|
expect(first).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
);
|
|
expect(retry).toBe(first);
|
|
});
|
|
|
|
it('differs per outbox entry', async () => {
|
|
const one = await transactionIdFor({ id: 'entry-1' });
|
|
const other = await transactionIdFor({ id: 'entry-2' });
|
|
|
|
expect(other).not.toBe(one);
|
|
});
|
|
|
|
// A cancelled source deleted its event; enqueued again it needs a new
|
|
// one, which an old transactionId might be matched against.
|
|
it('is new once the entry was enqueued again', async () => {
|
|
const before = await transactionIdFor({ revision: 3 });
|
|
const after = await transactionIdFor({ revision: 4 });
|
|
|
|
expect(after).not.toBe(before);
|
|
});
|
|
|
|
it('is new when replacing an event that was deleted in M365', async () => {
|
|
const initial = await transactionIdFor({ graphEventId: null });
|
|
graphClient.updateEvent.mockRejectedValue(
|
|
new GraphRequestError('updateEvent', 404, 'gone'),
|
|
);
|
|
const replacement = await transactionIdFor({
|
|
graphEventId: 'deleted-on-m365',
|
|
});
|
|
|
|
expect(replacement).not.toBe(initial);
|
|
});
|
|
});
|
|
|
|
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
|
|
// API request neither talks to Graph nor waits for up to 20 entries.
|
|
describe('requestRun', () => {
|
|
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
|
|
await service.requestRun();
|
|
|
|
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
|
|
where: { status: 'FAILED' },
|
|
data: { status: 'PENDING', attempts: 0, lastError: null },
|
|
});
|
|
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
|
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
|
expect(graphClient.updateEvent).not.toHaveBeenCalled();
|
|
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => {
|
|
await service.requestRun();
|
|
|
|
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
|
|
[TemplateStringsArray, ...unknown[]],
|
|
];
|
|
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
|
|
expect(values).toEqual(['calendar_sync']);
|
|
});
|
|
|
|
it('resets before waking, so the woken worker finds the entries PENDING', async () => {
|
|
await service.requestRun();
|
|
|
|
const [resetOrder] =
|
|
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
|
|
const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder;
|
|
expect(resetOrder).toBeLessThan(wakeOrder);
|
|
});
|
|
|
|
it('leaves FAILED entries alone on a regular worker tick', async () => {
|
|
await service.processOutbox();
|
|
|
|
expect(
|
|
prisma.calendarSyncOutboxEntry.updateMany,
|
|
).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { status: 'FAILED' } }),
|
|
);
|
|
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getStatus', () => {
|
|
it('reports configured/enabled/groupId plus outbox counts', async () => {
|
|
prisma.calendarSyncOutboxEntry.count
|
|
.mockResolvedValueOnce(2) // PENDING
|
|
.mockResolvedValueOnce(1) // FAILED
|
|
.mockResolvedValueOnce(5) // SYNCED
|
|
.mockResolvedValueOnce(0); // held by a worker right now
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status).toEqual({
|
|
configured: true,
|
|
enabled: true,
|
|
groupId: 'group-1',
|
|
pendingCount: 2,
|
|
failedCount: 1,
|
|
syncedCount: 5,
|
|
running: false,
|
|
});
|
|
});
|
|
|
|
// The pass runs in a worker process, never in the API that answers -
|
|
// only the worker's live claims can show it, across replicas.
|
|
it('reports running while any worker holds an entry', async () => {
|
|
prisma.calendarSyncOutboxEntry.count
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(0)
|
|
.mockResolvedValueOnce(1);
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.running).toBe(true);
|
|
expect(prisma.calendarSyncOutboxEntry.count).toHaveBeenLastCalledWith({
|
|
where: { claimedAt: { gte: expect.any(Date) as Date } },
|
|
});
|
|
});
|
|
});
|
|
});
|