Files
ApiCore/ApiCore-Backend/test/system.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

238 lines
7.2 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { DomainEventDispatcher } from '../src/domain-events/domain-event-dispatcher';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface FailedEventBody {
id: string;
failedHandlers: unknown[];
[key: string]: unknown;
}
interface FailedDeliveryBody {
id: string;
targetHost: string;
[key: string]: unknown;
}
interface OverviewBody {
jobs: unknown[];
domainEvents: { failed: number; oldestPendingAgeSeconds: number | null };
webhookDeliveries: { failed: number };
}
describe('System API (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let staffToken: string;
const server = () => app.getHttpServer();
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
for (const [key, role] of [
['admin', 'ADMIN'],
['staff', 'STAFF'],
] as const) {
await prisma.user.create({
data: {
email: `system-${key}-${suffix}@example.com`,
passwordHash,
name: key,
role: { connect: { key: role } },
},
});
}
adminToken = (
await login(app, `system-admin-${suffix}@example.com`, password)
).accessToken;
staffToken = (
await login(app, `system-staff-${suffix}@example.com`, password)
).accessToken;
});
afterAll(async () => {
await app.close();
});
it('staff without system:read gets 403; API keys never reach /system', async () => {
await request(server())
.get('/api/v1/system/overview')
.set(auth(staffToken))
.expect(403);
await request(server())
.post('/api/v1/system/domain-events/x/retry')
.set(auth(staffToken))
.expect(403);
const created = await request(server())
.post('/api/v1/api-keys')
.set(auth(adminToken))
.send({ name: `System ${suffix}`, scopes: ['shop-sync:read'] })
.expect(201);
const { token } = created.body as { token: string };
await request(server())
.get('/api/v1/system/overview')
.set('Authorization', `Bearer ${token}`)
.expect(403);
await request(server())
.get('/api/v1/system/job-runs')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
it('shows the overview and validates the list limit', async () => {
const response = await request(server())
.get('/api/v1/system/overview')
.set(auth(adminToken))
.expect(200);
const body = response.body as OverviewBody;
expect(body.jobs).toHaveLength(5);
expect(typeof body.domainEvents.failed).toBe('number');
await request(server())
.get('/api/v1/system/job-runs?limit=0')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?limit=101')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?job=nope')
.set(auth(adminToken))
.expect(400);
await request(server())
.get('/api/v1/system/job-runs?job=cleanup&limit=5')
.set(auth(adminToken))
.expect(200);
});
it('admin retries a FAILED event and the next drain completes it', async () => {
const correlationId = `system-${suffix}`;
const event = await prisma.domainEvent.create({
data: {
type: 'test.system-retry',
aggregateType: 'SystemTest',
aggregateId: `agg-${suffix}`,
payload: { secret: 'must-not-leak' },
correlationId,
status: 'FAILED',
attempts: 8,
lastError: 'boom',
availableAt: new Date(Date.now() + 86_400_000),
},
});
const failed = await request(server())
.get('/api/v1/system/domain-events/failed?limit=100')
.set(auth(adminToken))
.expect(200);
const listed = (failed.body as FailedEventBody[]).find(
(e) => e.id === event.id,
);
expect(listed).toBeDefined();
expect(JSON.stringify(failed.body)).not.toContain('must-not-leak');
expect(listed).not.toHaveProperty('payload');
await request(server())
.post(`/api/v1/system/domain-events/${event.id}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.domainEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
});
expect(reset.availableAt.getTime()).toBeLessThanOrEqual(Date.now() + 5_000);
await expect(
prisma.auditLog.count({
where: { entityType: 'DomainEvent', entityId: event.id },
}),
).resolves.toBe(1);
await app
.get(DomainEventDispatcher)
.drain({ correlationIds: [correlationId] });
const done = await prisma.domainEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(done.status).toBe('DONE');
// DONE is not FAILED any more: a second retry is a conflict.
await request(server())
.post(`/api/v1/system/domain-events/${event.id}/retry`)
.set(auth(adminToken))
.expect(409);
await request(server())
.post('/api/v1/system/domain-events/does-not-exist/retry')
.set(auth(adminToken))
.expect(404);
});
it('lists a failed webhook delivery with host only and retries it', async () => {
const subscription = await prisma.webhookSubscription.create({
data: {
url: 'https://hooks.example.com/very/secret/path?token=abc',
events: ['harvest.created'],
secret: 'enc-secret',
},
});
const delivery = await prisma.webhookDelivery.create({
data: {
subscriptionId: subscription.id,
event: 'harvest.created',
body: '{"private":"record"}',
status: 'FAILED',
attempts: 8,
lastStatusCode: 500,
lastError: 'HTTP 500',
},
});
const response = await request(server())
.get('/api/v1/system/webhook-deliveries/failed?limit=100')
.set(auth(adminToken))
.expect(200);
const listed = (response.body as FailedDeliveryBody[]).find(
(d) => d.id === delivery.id,
);
expect(listed?.targetHost).toBe('hooks.example.com');
const raw = JSON.stringify(listed);
expect(raw).not.toMatch(/secret|token|private/);
await request(server())
.post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`)
.set(auth(adminToken))
.expect(204);
const reset = await prisma.webhookDelivery.findUniqueOrThrow({
where: { id: delivery.id },
});
expect(reset).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
lastStatusCode: null,
});
await request(server())
.post(`/api/v1/system/webhook-deliveries/${delivery.id}/retry`)
.set(auth(adminToken))
.expect(409);
await prisma.webhookSubscription.delete({ where: { id: subscription.id } });
});
});