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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaThrottlerStorage } from '../src/common/throttler/prisma-throttler.storage';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
// Public batches allow 30 requests per minute and client network.
const PUBLIC_BATCH_LIMIT = 30;
const PARALLEL_REQUESTS = 40;
describe('Database-backed throttler (e2e)', () => {
const suffix = randomUUID();
const path = `/api/v1/public/batches/NONE-${suffix}`;
const namespaces: string[] = [];
const apps: INestApplication<App>[] = [];
let prisma: PrismaService;
const newApp = async (
throttlerNamespace: string,
): Promise<INestApplication<App>> => {
namespaces.push(throttlerNamespace);
const app = await createTestApp({ throttlerNamespace });
apps.push(app);
prisma = app.get(PrismaService);
return app;
};
const status = async (app: INestApplication<App>): Promise<number> =>
(await request(app.getHttpServer()).get(path)).status;
afterAll(async () => {
for (const ns of namespaces) {
await prisma.throttlerHit.deleteMany({
where: { key: { startsWith: `${ns}:` } },
});
}
for (const app of apps) await app.close();
});
it('counts across two app instances sharing one namespace', async () => {
const ns = randomUUID();
const [a, b] = [await newApp(ns), await newApp(ns)];
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
await request((i % 2 ? a : b).getHttpServer())
.get(path)
.expect(404);
}
await request(a.getHttpServer()).get(path).expect(429);
await request(b.getHttpServer()).get(path).expect(429);
});
it('40 parallel requests over two instances yield exactly 10 × 429', async () => {
const ns = randomUUID();
const [a, b] = [await newApp(ns), await newApp(ns)];
const statuses = await Promise.all(
Array.from({ length: PARALLEL_REQUESTS }, (_, i) =>
status(i % 2 ? a : b),
),
);
expect(statuses.filter((s) => s === 429)).toHaveLength(
PARALLEL_REQUESTS - PUBLIC_BATCH_LIMIT,
);
expect(statuses.filter((s) => s === 404)).toHaveLength(PUBLIC_BATCH_LIMIT);
});
it('separate namespaces never share counters', async () => {
const [a, b] = [await newApp(randomUUID()), await newApp(randomUUID())];
for (let i = 0; i < PUBLIC_BATCH_LIMIT; i += 1) {
await request(a.getHttpServer()).get(path).expect(404);
}
await request(a.getHttpServer()).get(path).expect(429);
await request(b.getHttpServer()).get(path).expect(404);
});
it('an expired window starts again at 1', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
for (let i = 0; i < 3; i += 1) {
await storage.increment('window', 60000, 10, 60000, 'default');
}
// The window ends in the past, decided by the database clock.
await prisma.$executeRaw`
UPDATE "ThrottlerHit"
SET "expiresAt" = now() - interval '1 second'
WHERE "key" = ${`${ns}:window`}`;
const record = await storage.increment(
'window',
60000,
10,
60000,
'default',
);
expect(record.totalHits).toBe(1);
expect(record.isBlocked).toBe(false);
expect(record.timeToExpire).toBeGreaterThan(0);
expect(record.timeToExpire).toBeLessThanOrEqual(60);
});
it('keeps hits while blocked and releases the block after it expired', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
await storage.increment('block', 60000, 1, 60000, 'default');
const blocked = await storage.increment(
'block',
60000,
1,
60000,
'default',
);
expect(blocked.isBlocked).toBe(true);
expect(blocked.totalHits).toBe(2);
expect(blocked.timeToBlockExpire).toBeGreaterThan(0);
const stillBlocked = await storage.increment(
'block',
60000,
1,
60000,
'default',
);
expect(stillBlocked.isBlocked).toBe(true);
expect(stillBlocked.totalHits).toBe(2);
await prisma.$executeRaw`
UPDATE "ThrottlerHit"
SET "blockedUntil" = now() - interval '1 second'
WHERE "key" = ${`${ns}:block`}`;
const released = await storage.increment(
'block',
60000,
1,
60000,
'default',
);
expect(released.isBlocked).toBe(false);
expect(released.totalHits).toBe(1);
expect(released.timeToBlockExpire).toBe(0);
});
it('blocks the first hit when the limit is 0', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
const first = await storage.increment('zero', 60000, 0, 60000, 'default');
expect(first.isBlocked).toBe(true);
expect(first.totalHits).toBe(1);
// Same after a window or block has expired.
await prisma.$executeRaw`
UPDATE "ThrottlerHit"
SET "expiresAt" = now() - interval '1 second',
"blockedUntil" = now() - interval '1 second'
WHERE "key" = ${`${ns}:zero`}`;
const again = await storage.increment('zero', 60000, 0, 60000, 'default');
expect(again.isBlocked).toBe(true);
expect(again.totalHits).toBe(1);
});
});