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 = {}) => ({ 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; data: Record; }; 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) => { 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 }], ]; 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) => { 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 } }, }); }); }); });