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]>
304 lines
9.9 KiB
TypeScript
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([]);
|
|
});
|
|
});
|