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

304 lines
9.9 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { HiveCatalogService } from '../src/hive/hive-catalog.service';
import { validateHiveState } from '../src/hive/hive-validate';
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';
interface FixturePartType {
id: string;
kind: string;
name: string;
lengthMm: number;
widthMm: number;
heightMm: number;
frameSlots: number | null;
stateSchema: unknown[];
active: boolean;
}
interface TemplateBody {
schemaVersion: number;
stack: { id: string; inserts?: { id: string }[] }[];
}
interface SystemBody {
id: string;
partTypes: { id: string }[];
}
const fixture = JSON.parse(
readFileSync(join(__dirname, 'fixtures', 'hive-ops', 'catalog.json'), 'utf8'),
) as { systemId: string; partTypes: FixturePartType[] };
const FLOOR_SCHEMA: unknown[] =
fixture.partTypes.find((p) => p.kind === 'FLOOR')?.stateSchema ?? [];
/** Hive catalog against the real database: seed, permissions, row lock SQL. */
describe('Hive catalog (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const roleIds: string[] = [];
const userIds: string[] = [];
const systemIds: string[] = [];
const tokens = { manager: '', reader: '' };
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const systemBody = (name: string) => ({
name,
frameWidthMm: 480,
frameHeightMm: 220,
footprintLMm: 520,
footprintWMm: 430,
});
const floorTypeBody = (stateSchema: unknown[]) => ({
kind: 'FLOOR',
name: `E2E Boden ${suffix}`,
lengthMm: 525,
widthMm: 430,
heightMm: 120,
stateSchema,
});
async function createUser(label: string, permissions: string[]) {
const role = await prisma.role.create({
data: {
key: `HIVE_CAT_${label.toUpperCase()}_${suffix}`,
name: `Hive catalog ${label}`,
permissions: {
create: permissions.map((p) => ({
permission: { connect: { key: p } },
})),
},
},
});
roleIds.push(role.id);
const email = `hive-cat-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Hive catalog ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
async function createSystem(name: string): Promise<string> {
const res = await api()
.post('/api/v1/hive-systems')
.set(auth(tokens.manager))
.send(systemBody(name))
.expect(201);
const id = (res.body as SystemBody).id;
systemIds.push(id);
return id;
}
async function createFloorType(systemId: string, stateSchema: unknown[]) {
const res = await api()
.post(`/api/v1/hive-systems/${systemId}/part-types`)
.set(auth(tokens.manager))
.send(floorTypeBody(stateSchema))
.expect(201);
return (res.body as { id: string }).id;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
tokens.manager = await createUser('manager', [
'colonies:read',
'hive-systems:manage',
]);
tokens.reader = await createUser('reader', ['colonies:read']);
});
afterAll(async () => {
await prisma.hivePartType.deleteMany({
where: { systemId: { in: systemIds } },
});
await prisma.hiveSystem.deleteMany({ where: { id: { in: systemIds } } });
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.refreshToken.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('seeds a catalog equal to test/fixtures/hive-ops/catalog.json', async () => {
const rows = await prisma.hivePartType.findMany({
where: { systemId: fixture.systemId },
orderBy: { id: 'asc' },
});
expect(rows.map((r) => r.id)).toEqual(
fixture.partTypes.map((p) => p.id).sort(),
);
for (const expected of fixture.partTypes) {
const row = rows.find((r) => r.id === expected.id);
expect({
id: row?.id,
kind: row?.kind,
name: row?.name,
lengthMm: row?.lengthMm,
widthMm: row?.widthMm,
heightMm: row?.heightMm,
frameSlots: row?.frameSlots,
stateSchema: row?.stateSchema,
active: row?.active,
}).toEqual(expected);
}
});
it('lists the systems with their part types for colonies:read', async () => {
const res = await api()
.get('/api/v1/hive-systems')
.set(auth(tokens.reader))
.expect(200);
const seeded = (res.body as SystemBody[]).find((s) => s.id === SYSTEM_ID);
expect(seeded?.partTypes).toHaveLength(fixture.partTypes.length);
});
it('refuses catalog writes without hive-systems:manage', async () => {
const attempts = [
() => api().post('/api/v1/hive-systems').send(systemBody('x')),
() =>
api().patch(`/api/v1/hive-systems/${SYSTEM_ID}`).send({ active: true }),
() =>
api()
.post(`/api/v1/hive-systems/${SYSTEM_ID}/part-types`)
.send(floorTypeBody([])),
() =>
api()
.patch(`/api/v1/hive-part-types/${fixture.partTypes[0].id}`)
.send({ name: 'x' }),
];
for (const attempt of attempts) {
await attempt().set(auth(tokens.reader)).expect(403);
}
});
it('creates and updates a system and a part type with the permission', async () => {
const systemId = await createSystem(`E2E System ${suffix}`);
await api()
.patch(`/api/v1/hive-systems/${systemId}`)
.set(auth(tokens.manager))
.send({ footprintLMm: 530 })
.expect(200);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const patched = await api()
.patch(`/api/v1/hive-part-types/${partTypeId}`)
.set(auth(tokens.manager))
.send({ heightMm: 125 })
.expect(200);
expect(patched.body).toMatchObject({ id: partTypeId, heightMm: 125 });
});
it('answers 400 SCHEMA_NOT_ADDITIVE when a state field is removed', async () => {
const systemId = await createSystem(`E2E Schema ${suffix}`);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const res = await api()
.patch(`/api/v1/hive-part-types/${partTypeId}`)
.set(auth(tokens.manager))
.send({ stateSchema: FLOOR_SCHEMA.slice(0, 1) })
.expect(400);
expect(res.body).toMatchObject({ code: 'SCHEMA_NOT_ADDITIVE' });
});
it('serialises two concurrent schema updates of one part type (row lock)', async () => {
const systemId = await createSystem(`E2E Lock ${suffix}`);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const url = `/api/v1/hive-part-types/${partTypeId}`;
const extra = (key: string) => ({
key,
label: key,
kind: 'choice',
options: ['A', 'B'],
optionLabels: ['a', 'b'],
});
const patch = (key: string) =>
api()
.patch(url)
.set(auth(tokens.manager))
.send({ stateSchema: [...FLOOR_SCHEMA, extra(key)] })
.then((res) => res);
const results = await Promise.all([patch('extraA'), patch('extraB')]);
// Without the lock both read the old schema and both would answer 200.
// Serialised, the loser sees the winner's field and is not additive.
expect(results.map((r) => r.status).sort()).toEqual([200, 400]);
const loser = results.find((r) => r.status === 400);
expect(loser?.body).toMatchObject({ code: 'SCHEMA_NOT_ADDITIVE' });
const row = await prisma.hivePartType.findUniqueOrThrow({
where: { id: partTypeId },
});
const keys = (row.stateSchema as unknown as { key: string }[]).map(
(f) => f.key,
);
expect(keys).toHaveLength(FLOOR_SCHEMA.length + 1);
});
it('answers 409 TEMPLATE_NOT_SET for a system without a template', async () => {
const systemId = await createSystem(`E2E Vorlage ${suffix}`);
const res = await api()
.get(`/api/v1/hive-systems/${systemId}/template`)
.set(auth(tokens.reader))
.expect(409);
expect(res.body).toMatchObject({ code: 'TEMPLATE_NOT_SET' });
});
it('answers 400 TEMPLATE_INVALID for a template with an empty stack', async () => {
const systemId = await createSystem(`E2E Leer ${suffix}`);
const res = await api()
.patch(`/api/v1/hive-systems/${systemId}`)
.set(auth(tokens.manager))
.send({ template: { schemaVersion: 1, stack: [] } })
.expect(400);
expect(res.body).toMatchObject({ code: 'TEMPLATE_INVALID' });
});
it('returns a valid template with fresh ids on every call', async () => {
const get = async () =>
(
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
const first = await get();
const second = await get();
const catalog = await app.get(HiveCatalogService).loadCatalog(SYSTEM_ID);
expect(validateHiveState(first, catalog)).toEqual([]);
const firstIds = first.stack.map((p) => p.id);
expect(firstIds.every((id) => typeof id === 'string' && id !== '')).toBe(
true,
);
expect(new Set(firstIds).size).toBe(firstIds.length);
const overlap = second.stack.filter((p) => firstIds.includes(p.id));
expect(overlap).toEqual([]);
});
});