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; 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; 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'); }); });