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]>
495 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|