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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user