Files
ApiCore/ApiCore-Backend/test/hive.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 b48fb157c8
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s
feat: hive setup data model and API (Beute L1)
Each colony can now hold its hive setup (Ist-Zustand) as validated
JSON with an append-only change log:

- catalog: hive systems and part types with dimensions, placement
  rules and state fields; Liebig-Zander 10 seeded with fixed ids
  (owner's equipment incl. Bausperre, Futterzarge, Nassenheider)
- pure domain module (operations, validator, derived facts) with
  shared JSON fixtures the frontend runs too
- API: catalog (hive-systems:manage), setup create/change/history/
  state at a day (colonies:read/update), 409 HIVE_CONFLICT with the
  current state, idempotent writes, HiveChanged event
- inspections can carry hive ops saved in the same transaction
- backup export/import of the new tables (backup wins per colony)
- docs: ARCHITECTURE section 13, Annahmen 95-100

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 21:23:23 +02:00

495 lines
16 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { addDays, berlinToday } from '../src/common/dates';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
const SYSTEM_ID = 'hive-system-zander-liebig-10';
const FLOOR = 'hive-part-zl10-floor';
const BOX = 'hive-part-zl10-box-10';
const ROOF = 'hive-part-zl10-roof';
const frames = Array.from({ length: 10 }, (_, i) => ({
slot: i + 1,
type: 'FOUNDATION',
}));
const state = {
schemaVersion: 1,
stack: [
{ id: 'e2e-floor', partTypeId: FLOOR, state: {} },
{ id: 'e2e-box', partTypeId: BOX, state: {}, frames },
{ id: 'e2e-roof', partTypeId: ROOF, state: {} },
],
};
interface ViewBody {
version: number;
state: { schemaVersion: number; stack: { label?: string }[] };
facts: { boxCount: number };
}
interface HistoryBody {
items: { version: number; userName: string; stateAfter?: unknown }[];
nextCursor: string | null;
}
interface ConflictBody {
current: { version: number; state: { schemaVersion: number } };
}
const versionsOf = (body: HistoryBody) => body.items.map((i) => i.version);
/** Hive setup API against the real database: locks, unique keys, outbox. */
describe('Hive setup (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const today = berlinToday();
const initDay = addDays(today, -5);
let apiaryId: string;
let colonyId: string;
const roleIds: string[] = [];
const userIds: string[] = [];
const tokens = { writer: '', reader: '' };
let counter = 0;
const key = () => `hive-e2e-${suffix}-${(counter += 1)}-key`;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const url = (path = '') => `/api/v1/colonies/${colonyId}/hive${path}`;
async function createUser(label: string, permissions: string[]) {
const role = await prisma.role.create({
data: {
key: `HIVE_E2E_${label.toUpperCase()}_${suffix}`,
name: `Hive e2e ${label}`,
permissions: {
create: permissions.map((p) => ({
permission: { connect: { key: p } },
})),
},
},
});
roleIds.push(role.id);
const email = `hive-e2e-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Hive ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
const change = (overrides: object = {}) =>
api()
.post(url('/changes'))
.set(auth(tokens.writer))
.set('Idempotency-Key', key())
.send({
occurredOn: today,
source: 'CARD',
ops: [{ op: 'setLabel', id: 'e2e-box', label: 'Brutraum' }],
...overrides,
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Hive e2e ${suffix}` } })
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `HV-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
tokens.writer = await createUser('writer', [
'colonies:read',
'colonies:update',
]);
tokens.reader = await createUser('reader', ['colonies:read']);
});
afterAll(async () => {
await prisma.hiveChange.deleteMany({ where: { colonyId } });
await prisma.hiveSetup.deleteMany({ where: { colonyId } });
await prisma.domainEvent.deleteMany({ where: { aggregateId: colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('answers 404 HIVE_NOT_FOUND before a setup exists', async () => {
const res = await api().get(url()).set(auth(tokens.reader)).expect(404);
expect(res.body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
});
it('refuses a writer without colonies:update', async () => {
await api()
.post(url())
.set(auth(tokens.reader))
.set('Idempotency-Key', key())
.send({ systemId: SYSTEM_ID, state, occurredOn: initDay })
.expect(403);
});
it('creates the setup with version 1, an INIT change and an event', async () => {
const res = await api()
.post(url())
.set(auth(tokens.writer))
.set('Idempotency-Key', key())
.send({ systemId: SYSTEM_ID, state, occurredOn: initDay })
.expect(201);
expect(res.body).toMatchObject({ colonyId, version: 1 });
expect((res.body as ViewBody).facts.boxCount).toBe(1);
const init = await prisma.hiveChange.findUniqueOrThrow({
where: { colonyId_version: { colonyId, version: 1 } },
});
expect(init.source).toBe('INIT');
expect(init.occurredOn.toISOString().slice(0, 10)).toBe(initDay);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId, type: 'HiveChanged' },
});
expect(events).toHaveLength(1);
expect(events[0].payload).toEqual({
colonyId,
version: 1,
source: 'INIT',
inspectionId: null,
});
});
it('answers 409 HIVE_EXISTS for a second setup', async () => {
const res = await api()
.post(url())
.set(auth(tokens.writer))
.set('Idempotency-Key', key())
.send({ systemId: SYSTEM_ID, state, occurredOn: initDay })
.expect(409);
expect(res.body).toMatchObject({ code: 'HIVE_EXISTS' });
});
it('applies a change, then a backdated one, and lists them by day', async () => {
const first = await change().expect(201);
const firstView = first.body as ViewBody;
expect(firstView.version).toBe(2);
expect(firstView.state.stack[1].label).toBe('Brutraum');
const backdated = await change({
occurredOn: addDays(initDay, 2),
ops: [{ op: 'setLabel', id: 'e2e-box', label: 'Nachtrag' }],
}).expect(201);
expect((backdated.body as ViewBody).version).toBe(3);
const history = await api()
.get(url('/changes'))
.set(auth(tokens.reader))
.expect(200);
const list = history.body as HistoryBody;
expect(versionsOf(list)).toEqual([2, 3, 1]);
expect(list.items[0].userName).toBe('Hive writer');
expect(list.items[0].stateAfter).toBeUndefined();
const page = await api()
.get(url('/changes?limit=1'))
.set(auth(tokens.reader))
.expect(200);
const firstPage = page.body as HistoryBody;
expect(firstPage.items).toHaveLength(1);
expect(firstPage.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/);
const next = await api()
.get(
url(
`/changes?limit=5&cursor=${encodeURIComponent(firstPage.nextCursor ?? '')}`,
),
)
.set(auth(tokens.reader))
.expect(200);
expect(versionsOf(next.body as HistoryBody)).toEqual([3, 1]);
});
it('returns the state at a day and 404 before INIT', async () => {
const at = await api()
.get(url(`/at?day=${addDays(initDay, 2)}`))
.set(auth(tokens.reader))
.expect(200);
expect((at.body as ViewBody).version).toBe(3);
await api()
.get(url(`/at?day=${addDays(initDay, -1)}`))
.set(auth(tokens.reader))
.expect(404);
});
it('answers 409 HIVE_CONFLICT with the current state', async () => {
const res = await change({
ops: [{ op: 'removePart', id: 'gone' }],
}).expect(409);
expect(res.body).toMatchObject({
code: 'HIVE_CONFLICT',
opIndex: 0,
current: { version: 3 },
});
expect((res.body as ConflictBody).current.state.schemaVersion).toBe(1);
});
it('answers 400 for shape errors, AI_SUGGESTION and wrong days', async () => {
await change({ ops: [{ op: 'explode' }] }).expect(400);
await change({ source: 'AI_SUGGESTION' }).expect(400);
await change({ ops: [] }).expect(400);
await change({ occurredOn: addDays(today, 1) }).expect(400);
await change({ occurredOn: addDays(initDay, -1) }).expect(400);
});
it('replays an idempotent retry without a second change', async () => {
const retryKey = key();
const send = () =>
api()
.post(url('/changes'))
.set(auth(tokens.writer))
.set('Idempotency-Key', retryKey)
.send({
occurredOn: today,
source: 'EDITOR',
ops: [{ op: 'setLabel', id: 'e2e-box', label: 'Retry' }],
});
const first = await send().expect(201);
const second = await send().expect(201);
const firstVersion = (first.body as ViewBody).version;
expect((second.body as ViewBody).version).toBe(firstVersion);
expect(await prisma.hiveChange.count({ where: { colonyId } })).toBe(
firstVersion,
);
});
it('makes a colony that no longer lives read-only', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'SOLD' },
});
await change().expect(409);
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'ACTIVE' },
});
});
});
/** An inspection carries hive operations that are saved with it atomically. */
describe('Inspection with hiveChange (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const today = berlinToday();
let apiaryId: string;
let colonyId: string;
let bareColonyId: string;
const roleIds: string[] = [];
const userIds: string[] = [];
const tokens = { full: '', inspectorOnly: '' };
let counter = 0;
const key = () => `hive-insp-${suffix}-${(counter += 1)}-key`;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
async function createUser(label: string, permissions: string[]) {
const role = await prisma.role.create({
data: {
key: `HIVE_INSP_${label.toUpperCase()}_${suffix}`,
name: `Hive inspection ${label}`,
permissions: {
create: permissions.map((p) => ({
permission: { connect: { key: p } },
})),
},
},
});
roleIds.push(role.id);
const email = `hive-insp-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Hive inspection ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
const inspect = (token: string, body: object, targetColony = colonyId) =>
api()
.post('/api/v1/inspections')
.set(auth(token))
.set('Idempotency-Key', key())
.send({
colonyId: targetColony,
date: new Date().toISOString(),
...body,
});
const labelOp = (label: string, id = 'e2e-box') => ({
op: 'setLabel',
id,
label,
});
const inspectionCount = (id = colonyId) =>
prisma.inspection.count({ where: { colonyId: id } });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Hive insp ${suffix}` } })
).id;
const make = async (code: string) =>
(
await prisma.colony.create({
data: {
code: `${code}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
colonyId = await make('HI');
bareColonyId = await make('HB');
tokens.full = await createUser('full', [
'colonies:read',
'colonies:update',
'inspections:create',
]);
tokens.inspectorOnly = await createUser('inspector', [
'colonies:read',
'inspections:create',
]);
await api()
.post(`/api/v1/colonies/${colonyId}/hive`)
.set(auth(tokens.full))
.set('Idempotency-Key', key())
.send({
systemId: SYSTEM_ID,
state,
occurredOn: addDays(today, -5),
})
.expect(201);
});
afterAll(async () => {
const colonyIds = [colonyId, bareColonyId];
await prisma.hiveChange.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.hiveSetup.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('saves the inspection and its hive change together', async () => {
const res = await inspect(tokens.full, {
hiveChange: { ops: [labelOp('Brutraum')] },
}).expect(201);
const body = res.body as { id: string; hive: ViewBody };
expect(body.hive.version).toBe(2);
const change = await prisma.hiveChange.findUniqueOrThrow({
where: { colonyId_version: { colonyId, version: 2 } },
});
expect(change).toMatchObject({
source: 'INSPECTION',
inspectionId: body.id,
});
expect(change.occurredOn.toISOString().slice(0, 10)).toBe(today);
expect(await inspectionCount()).toBe(1);
});
it('leaves no inspection row when an operation conflicts', async () => {
const before = await inspectionCount();
const res = await inspect(tokens.full, {
hiveChange: { ops: [labelOp('x', 'does-not-exist')] },
}).expect(409);
expect(res.body).toMatchObject({ code: 'HIVE_CONFLICT', opIndex: 0 });
expect(await inspectionCount()).toBe(before);
expect(await prisma.hiveChange.count({ where: { colonyId } })).toBe(2);
});
it('leaves no inspection row for a colony without a hive setup', async () => {
const res = await inspect(
tokens.full,
{ hiveChange: { ops: [labelOp('x')] } },
bareColonyId,
).expect(404);
expect(res.body).toMatchObject({ code: 'HIVE_NOT_FOUND' });
expect(await inspectionCount(bareColonyId)).toBe(0);
});
it('answers 403 HIVE_FORBIDDEN without colonies:update and writes nothing', async () => {
const before = await inspectionCount();
const res = await inspect(tokens.inspectorOnly, {
hiveChange: { ops: [labelOp('Nein')] },
}).expect(403);
expect(res.body).toMatchObject({ code: 'HIVE_FORBIDDEN' });
expect(await inspectionCount()).toBe(before);
expect(await prisma.hiveChange.count({ where: { colonyId } })).toBe(2);
});
it('answers 400 for a future inspection date and one before the INIT day', async () => {
const before = await inspectionCount();
const future = new Date(Date.now() + 3 * 86_400_000).toISOString();
const early = new Date(Date.now() - 30 * 86_400_000).toISOString();
for (const date of [future, early]) {
await inspect(tokens.full, {
date,
hiveChange: { ops: [labelOp('Datum')] },
}).expect(400);
}
expect(await inspectionCount()).toBe(before);
});
it('answers 400 for an empty ops list and still saves a plain inspection', async () => {
await inspect(tokens.full, { hiveChange: { ops: [] } }).expect(400);
const plain = await inspect(tokens.inspectorOnly, {}).expect(201);
expect(plain.body).not.toHaveProperty('hive');
});
});