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; slotWidth: number | null; stateSchema: unknown[]; active: boolean; } interface TemplateBody { schemaVersion: number; stack: { id: string; partTypeId?: 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; 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 { 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, slotWidth: row?.slotWidth, 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([]); }); it('accepts an id-less template (as the hive editor sends it) and serves fresh ids afterwards', async () => { const current = ( await api() .get(`/api/v1/hive-systems/${SYSTEM_ID}/template`) .set(auth(tokens.reader)) .expect(200) ).body as TemplateBody; const stripIds = ( part: Record, ): Record => { const rest = { ...part }; delete rest.id; const inserts = rest.inserts; delete rest.inserts; return Array.isArray(inserts) ? { ...rest, inserts: inserts.map(stripIds) } : rest; }; const idLess = { schemaVersion: 1, stack: current.stack.map((p) => stripIds(p as never)), }; await api() .patch(`/api/v1/hive-systems/${SYSTEM_ID}`) .set(auth(tokens.manager)) .send({ template: idLess }) .expect(200); const after = ( await api() .get(`/api/v1/hive-systems/${SYSTEM_ID}/template`) .set(auth(tokens.reader)) .expect(200) ).body as TemplateBody; expect(after.stack.map((p) => p.partTypeId)).toEqual( current.stack.map((p) => p.partTypeId), ); expect( after.stack.every((p) => typeof p.id === 'string' && p.id !== ''), ).toBe(true); }); });