Files
ApiCore/ApiCore-Backend/test/throttler.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee 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]>
2026-10-14 11:36:38 +02:00

224 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('never reports more seconds than the ttl or the block duration (sub-ms rounding)', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
// The column keeps milliseconds while now() has microseconds; rounding
// up by < 1 ms used to turn 60.0004 s into 61. About half of all
// timestamps round up, so 60 fresh keys catch it reliably.
for (let i = 0; i < 60; i += 1) {
const record = await storage.increment(`ms-${i}`, 60000, 0, 60000, 'd');
expect(record.timeToExpire).toBeGreaterThanOrEqual(59);
expect(record.timeToExpire).toBeLessThanOrEqual(60);
expect(record.isBlocked).toBe(true);
expect(record.timeToBlockExpire).toBeGreaterThanOrEqual(59);
expect(record.timeToBlockExpire).toBeLessThanOrEqual(60);
}
});
it('keeps the same bounds when a window restarts or a block starts on an existing row', async () => {
const ns = randomUUID();
await newApp(ns);
const storage = apps[apps.length - 1].get(PrismaThrottlerStorage);
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 60; i += 1) {
// Restart branch: the 1 ms window is over, the row starts again at 1.
await storage.increment(`re-${i}`, 1, 10, 60000, 'd');
await sleep(5);
const restarted = await storage.increment(
`re-${i}`,
60000,
10,
60000,
'd',
);
expect(restarted.totalHits).toBe(1);
expect(restarted.timeToExpire).toBeGreaterThanOrEqual(59);
expect(restarted.timeToExpire).toBeLessThanOrEqual(60);
// Block branch: hits + 1 > limit on an open window sets blockedUntil.
await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
const blocked = await storage.increment(`bl-${i}`, 60000, 1, 60000, 'd');
expect(blocked.isBlocked).toBe(true);
expect(blocked.timeToBlockExpire).toBeGreaterThanOrEqual(59);
expect(blocked.timeToBlockExpire).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);
});
});