feat: platform backbone and field capture complete (checkpoint)
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]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -151,8 +151,8 @@ describe('CalendarSyncService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// enqueue()/cancel() run after the source record is committed: a failure
|
||||
// must not fail that request (the client would retry and store it twice).
|
||||
// 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;
|
||||
|
||||
@@ -178,61 +178,50 @@ describe('CalendarSyncService', () => {
|
||||
expect(message).toContain('INSPECTION inspection-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
|
||||
it('cancel never throws and logs the failure with the record', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
|
||||
new Error('db down'),
|
||||
);
|
||||
|
||||
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const [message] = errorSpy.mock.calls[0] as [string];
|
||||
expect(message).toContain('TASK task-1');
|
||||
expect(message).toContain('db down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('does nothing when no outbox row exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
|
||||
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.cancel('TASK', 'task-1');
|
||||
await service.enqueueIn(tx, 'TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
|
||||
id: 'entry-1',
|
||||
graphEventId: 'graph-event-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();
|
||||
|
||||
await service.cancel('TASK', 'task-1');
|
||||
|
||||
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
'graph-event-1',
|
||||
txUpsert.mockRejectedValue(new Error('tx failed'));
|
||||
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
|
||||
'tx failed',
|
||||
);
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('still clears the row when the Graph delete fails', async () => {
|
||||
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
|
||||
id: 'entry-1',
|
||||
graphEventId: 'graph-event-1',
|
||||
it('does nothing when sync is disabled', async () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
enabled: false,
|
||||
});
|
||||
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
|
||||
const txUpsert = jest.fn();
|
||||
const tx = {
|
||||
calendarSyncOutboxEntry: { upsert: txUpsert },
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
|
||||
await service.cancel('TASK', 'task-1');
|
||||
await service.enqueueIn(tx, 'TASK', 'task-1');
|
||||
|
||||
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'entry-1' },
|
||||
data: { status: 'SKIPPED', graphEventId: null },
|
||||
});
|
||||
expect(txUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +230,7 @@ describe('CalendarSyncService', () => {
|
||||
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. */
|
||||
@@ -441,7 +431,78 @@ describe('CalendarSyncService', () => {
|
||||
await service.processOutbox();
|
||||
|
||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||
expectSettled({ status: 'SKIPPED' });
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user