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]>
175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
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);
|
||
});
|
||
});
|