feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { INestApplication, Logger } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
@@ -6,14 +6,18 @@ import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
||||
import { GraphClientService } from '../src/calendar-sync/graph-client.service';
|
||||
import { withTimeout } from '../src/common/with-timeout';
|
||||
import { WAKE_CHANNELS } from '../src/common/worker-wakeup';
|
||||
import { PgListener } from '../src/worker/pg-listener';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
/**
|
||||
* The M365 outbox against the real database, with Microsoft Graph replaced
|
||||
* by a recorder: several workers (pods, the cron and "Jetzt
|
||||
* synchronisieren") must never write the same entry twice, and an edit
|
||||
* that arrives while an entry is being synced must not get lost.
|
||||
* by a recorder: several workers (worker pods, each ticking and woken by
|
||||
* "Jetzt synchronisieren") must never write the same entry twice, and an
|
||||
* edit that arrives while an entry is being synced must not get lost. The
|
||||
* API itself only queues a pass; it has no worker loop.
|
||||
*/
|
||||
describe('Calendar sync outbox (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
@@ -134,11 +138,11 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
tasks.push(await queueTask(title));
|
||||
}
|
||||
|
||||
// Two pods' cron ticks plus a click on "Jetzt synchronisieren".
|
||||
// Three worker pods, ticking or woken by "Jetzt synchronisieren".
|
||||
await Promise.all([
|
||||
calendarSync.processOutbox(),
|
||||
calendarSync.processOutbox(),
|
||||
calendarSync.runNow(),
|
||||
calendarSync.processOutbox(),
|
||||
]);
|
||||
|
||||
for (const title of titles) {
|
||||
@@ -211,7 +215,7 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => {
|
||||
it('retries a FAILED entry after "Jetzt synchronisieren", not on a regular tick', async () => {
|
||||
const task = await queueTask('Failed before');
|
||||
await prisma.calendarSyncOutboxEntry.update({
|
||||
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } },
|
||||
@@ -222,7 +226,9 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
expect(createsFor('Failed before')).toHaveLength(0);
|
||||
expect((await entryFor(task.id)).status).toBe('FAILED');
|
||||
|
||||
await calendarSync.runNow();
|
||||
// The click, then the tick of the worker it woke.
|
||||
await calendarSync.requestRun();
|
||||
await calendarSync.processOutbox();
|
||||
|
||||
expect(createsFor('Failed before')).toHaveLength(1);
|
||||
const entry = await entryFor(task.id);
|
||||
@@ -234,9 +240,51 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
describe('API', () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => {
|
||||
const task = await queueTask('Started via API');
|
||||
const title = `Started via API ${suffix}`;
|
||||
it('answers "Jetzt synchronisieren" with 202, resets FAILED and wakes the worker - no Graph call from the API', async () => {
|
||||
const task = await queueTask('Requested via API');
|
||||
await prisma.calendarSyncOutboxEntry.update({
|
||||
where: {
|
||||
sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id },
|
||||
},
|
||||
data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' },
|
||||
});
|
||||
let wake: () => void = () => undefined;
|
||||
const woken = new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
const worker = new PgListener({
|
||||
connectionString: process.env.DATABASE_URL ?? '',
|
||||
channels: [WAKE_CHANNELS.calendarSync],
|
||||
onNotify: () => wake(),
|
||||
logger: new Logger('CalendarSyncE2E'),
|
||||
});
|
||||
await worker.start();
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/calendar/sync/run-now')
|
||||
.set(auth())
|
||||
.expect(202);
|
||||
|
||||
// A pass is due: the client follows it through GET status.
|
||||
expect((response.body as { running: boolean }).running).toBe(true);
|
||||
await withTimeout(woken, 5_000, 'no calendar_sync wake-up');
|
||||
} finally {
|
||||
await worker.stop();
|
||||
}
|
||||
expect(await entryFor(task.id)).toMatchObject({
|
||||
status: 'PENDING',
|
||||
attempts: 0,
|
||||
lastError: null,
|
||||
claimedAt: null,
|
||||
});
|
||||
expect(createdEvents).toEqual([]);
|
||||
expect(updateEvent).not.toHaveBeenCalled();
|
||||
}, 15_000);
|
||||
|
||||
it('reports running in GET status while a worker holds an entry', async () => {
|
||||
const task = await queueTask('Held by a worker');
|
||||
const title = `Held by a worker ${suffix}`;
|
||||
let releaseCreate: () => void = () => undefined;
|
||||
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
|
||||
createdEvents.push(input.title);
|
||||
@@ -248,24 +296,23 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
eventCounter += 1;
|
||||
return `event-${suffix}-${eventCounter}`;
|
||||
});
|
||||
const status = async () =>
|
||||
(
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar/sync/status')
|
||||
.set(auth())
|
||||
.expect(200)
|
||||
).body as { running: boolean };
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/calendar/sync/run-now')
|
||||
.set(auth())
|
||||
.expect(202);
|
||||
|
||||
expect((response.body as { running: boolean }).running).toBe(true);
|
||||
await eventually(() => createsFor('Started via API').length === 1);
|
||||
expect((await entryFor(task.id)).status).toBe('PENDING');
|
||||
const whileRunning = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar/sync/status')
|
||||
.set(auth())
|
||||
.expect(200);
|
||||
expect((whileRunning.body as { running: boolean }).running).toBe(true);
|
||||
// A worker's tick, held inside the Graph call for this entry.
|
||||
const pass = calendarSync.processOutbox();
|
||||
await eventually(() => createsFor('Held by a worker').length === 1);
|
||||
expect((await status()).running).toBe(true);
|
||||
|
||||
releaseCreate();
|
||||
await eventually(async () => !(await calendarSync.getStatus()).running);
|
||||
await pass;
|
||||
|
||||
expect((await status()).running).toBe(false);
|
||||
expect((await entryFor(task.id)).status).toBe('SYNCED');
|
||||
}, 15_000);
|
||||
|
||||
|
||||
Reference in new issue
Block a user