Files
ApiCore/ApiCore-Backend/test/throttler.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 c01a38835a 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]>
2026-10-13 17:57:54 +02:00

175 lines
5.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('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);
});
});