import { HttpException } from '@nestjs/common'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate } from '../prisma/row-lock'; import { addDays, berlinToday } from '../common/dates'; import { HiveCatalogService } from './hive-catalog.service'; import { HiveService } from './hive.service'; import { loadFixtureCatalog } from './hive-test-utils'; import type { HiveOp, HiveState } from './hive-types'; jest.mock('../prisma/row-lock'); const SYSTEM_ID = 'hive-system-zander-liebig-10'; const COLONY = 'colony-1'; const USER = 'user-1'; 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 baseState = { schemaVersion: 1, stack: [ { id: 'p-floor', partTypeId: FLOOR, state: {} }, { id: 'p-box', partTypeId: BOX, state: {}, frames }, { id: 'p-roof', partTypeId: ROOF, state: {} }, ], } as unknown as HiveState; const TODAY = berlinToday(); const INIT_DAY = addDays(TODAY, -10); const setLabel = (label: string): HiveOp => ({ op: 'setLabel', id: 'p-box', label, }); function dayDate(day: string): Date { return new Date(`${day}T00:00:00.000Z`); } function argOf(mock: jest.Mock, call: number, arg: number): T { return (mock.mock.calls as unknown[][])[call][arg] as T; } interface WrittenChange { data: { stateAfter: { stack: { label?: string }[] }; occurredOn: Date; inspectionId: string | null; }; } const lockedTables = (mock: jest.Mock): unknown[] => (mock.mock.calls as unknown[][]).map((call) => call[1]); async function statusAndBody(promise: Promise) { try { await promise; } catch (error) { if (error instanceof HttpException) { return { status: error.getStatus(), body: error.getResponse() }; } throw error; } throw new Error('expected the call to reject'); } describe('HiveService', () => { let service: HiveService; let tx: { colony: { findUnique: jest.Mock }; hiveSystem: { findUnique: jest.Mock }; hiveSetup: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock; }; hiveChange: { findUnique: jest.Mock; findFirst: jest.Mock; findMany: jest.Mock; create: jest.Mock; }; }; let prisma: { $transaction: jest.Mock } & typeof tx; let events: { record: jest.Mock }; let catalogs: { loadCatalog: jest.Mock }; const lock = lockRowForUpdate as jest.Mock; const setup = (version = 1) => ({ colonyId: COLONY, systemId: SYSTEM_ID, version, state: baseState, updatedAt: new Date('2026-10-01T10:00:00.000Z'), }); beforeEach(() => { tx = { colony: { findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }), }, hiveSystem: { findUnique: jest .fn() .mockResolvedValue({ id: SYSTEM_ID, active: true }), }, hiveSetup: { findUnique: jest.fn().mockResolvedValue(setup()), create: jest.fn().mockImplementation(({ data }: { data: object }) => ({ ...data, updatedAt: new Date('2026-10-02T10:00:00.000Z'), })), update: jest.fn().mockImplementation(({ data }: { data: object }) => ({ colonyId: COLONY, systemId: SYSTEM_ID, updatedAt: new Date('2026-10-03T10:00:00.000Z'), ...data, })), }, hiveChange: { findUnique: jest.fn().mockResolvedValue({ occurredOn: dayDate(INIT_DAY), }), findFirst: jest.fn(), findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({}), }, }; prisma = { ...tx, $transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)), }; events = { record: jest.fn().mockResolvedValue(undefined) }; catalogs = { loadCatalog: jest.fn().mockResolvedValue(loadFixtureCatalog()), }; lock.mockReset().mockResolvedValue(true); service = new HiveService( prisma as unknown as PrismaService, events as unknown as DomainEventsService, catalogs as unknown as HiveCatalogService, ); }); describe('get', () => { it('returns the setup with derived facts', async () => { const view = await service.get(COLONY); expect(view).toMatchObject({ colonyId: COLONY, systemId: SYSTEM_ID, version: 1, updatedAt: '2026-10-01T10:00:00.000Z', }); expect(view.state).toEqual(baseState); expect(view.facts.boxCount).toBe(1); }); it('answers 404 HIVE_NOT_FOUND without a setup', async () => { tx.hiveSetup.findUnique.mockResolvedValue(null); const { status, body } = await statusAndBody(service.get(COLONY)); expect(status).toBe(404); expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' }); }); }); describe('create', () => { const input = (overrides = {}) => ({ systemId: SYSTEM_ID, state: baseState, occurredOn: TODAY, ...overrides, }); beforeEach(() => { tx.hiveSetup.findUnique.mockResolvedValue(null); }); it('writes version 1, the INIT change and the event in one transaction', async () => { const view = await service.create(COLONY, input(), USER); expect(view.version).toBe(1); expect(tx.hiveSetup.create).toHaveBeenCalledWith({ data: { colonyId: COLONY, systemId: SYSTEM_ID, version: 1, state: baseState, }, }); expect(tx.hiveChange.create).toHaveBeenCalledWith({ data: expect.objectContaining({ colonyId: COLONY, version: 1, source: 'INIT', userId: USER, occurredOn: dayDate(TODAY), ops: [], stateAfter: baseState, }) as unknown, }); expect(events.record).toHaveBeenCalledWith(tx, { type: 'HiveChanged', aggregateType: 'Colony', aggregateId: COLONY, actorUserId: USER, payload: { colonyId: COLONY, version: 1, source: 'INIT', inspectionId: null, }, }); }); it('locks the colony before the setup', async () => { await service.create(COLONY, input(), USER); expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']); }); it('answers 409 HIVE_EXISTS for a second setup, decided under the lock', async () => { tx.hiveSetup.findUnique.mockResolvedValue(setup()); const { status, body } = await statusAndBody( service.create(COLONY, input(), USER), ); expect(status).toBe(409); expect(body).toMatchObject({ code: 'HIVE_EXISTS' }); expect(tx.hiveSetup.create).not.toHaveBeenCalled(); expect(events.record).not.toHaveBeenCalled(); }); it('answers 409 for a colony that no longer lives', async () => { tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' }); const { status } = await statusAndBody( service.create(COLONY, input(), USER), ); expect(status).toBe(409); }); it('answers 404 for an unknown colony', async () => { lock.mockResolvedValueOnce(false); const { status } = await statusAndBody( service.create(COLONY, input(), USER), ); expect(status).toBe(404); }); it('answers 400 with messages for a state the catalog refuses', async () => { const state = { schemaVersion: 1, stack: [] }; state.stack.push({ id: 'x', partTypeId: 'unknown-part', state: {}, } as never); const { status, body } = await statusAndBody( service.create(COLONY, input({ state }), USER), ); expect(status).toBe(400); expect(body).toMatchObject({ messages: expect.any(Array) as unknown }); }); it('answers 400 for an inactive system and 404 for an unknown one', async () => { tx.hiveSystem.findUnique.mockResolvedValueOnce({ id: SYSTEM_ID, active: false, }); expect( (await statusAndBody(service.create(COLONY, input(), USER))).status, ).toBe(400); tx.hiveSystem.findUnique.mockResolvedValueOnce(null); expect( (await statusAndBody(service.create(COLONY, input(), USER))).status, ).toBe(404); }); it('answers 400 for a day in the future (Berlin)', async () => { const { status } = await statusAndBody( service.create(COLONY, input({ occurredOn: addDays(TODAY, 1) }), USER), ); expect(status).toBe(400); }); it('answers 400 for an impossible day', async () => { const { status } = await statusAndBody( service.create(COLONY, input({ occurredOn: '2026-02-30' }), USER), ); expect(status).toBe(400); }); }); describe('applyChange', () => { const change = (overrides = {}) => ({ occurredOn: TODAY, source: 'CARD' as const, ops: [setLabel('Brutraum')], ...overrides, }); it('increments the version, stores the change and records the event', async () => { const view = await service.applyChange( tx as never, COLONY, change({ note: 'Test' }), USER, ); expect(view.version).toBe(2); const written = argOf(tx.hiveChange.create, 0, 0).data; expect(written).toMatchObject({ colonyId: COLONY, version: 2, source: 'CARD', userId: USER, note: 'Test', occurredOn: dayDate(TODAY), inspectionId: null, }); expect(written.stateAfter.stack[1].label).toBe('Brutraum'); expect(tx.hiveSetup.update).toHaveBeenCalledWith({ where: { colonyId: COLONY }, data: { version: 2, state: written.stateAfter }, }); expect(events.record).toHaveBeenCalledWith(tx, { type: 'HiveChanged', aggregateType: 'Colony', aggregateId: COLONY, actorUserId: USER, payload: { colonyId: COLONY, version: 2, source: 'CARD', inspectionId: null, }, }); }); it('passes the inspection id on to the change and the event', async () => { await service.applyChange( tx as never, COLONY, change({ source: 'INSPECTION', inspectionId: 'insp-1' }), USER, ); expect( argOf(tx.hiveChange.create, 0, 0).data.inspectionId, ).toBe('insp-1'); expect( argOf<{ payload: object }>(events.record, 0, 1).payload, ).toMatchObject({ source: 'INSPECTION', inspectionId: 'insp-1', }); }); it('stores and applies only the fields the op name defines', async () => { const ops = [ { ...setLabel('Sauber'), junk: 1, id: 'p-box' }, ] as unknown as HiveOp[]; await service.applyChange(tx as never, COLONY, change({ ops }), USER); expect( argOf<{ data: { ops: unknown } }>(tx.hiveChange.create, 0, 0).data.ops, ).toEqual([{ op: 'setLabel', id: 'p-box', label: 'Sauber' }]); }); it('answers 400 for INIT and AI_SUGGESTION as source', async () => { for (const source of ['INIT', 'AI_SUGGESTION']) { const { status } = await statusAndBody( service.applyChange(tx as never, COLONY, change({ source }), USER), ); expect(status).toBe(400); } expect(lock).not.toHaveBeenCalled(); }); it('requires inspectionId exactly for source INSPECTION', async () => { for (const input of [ change({ source: 'INSPECTION' }), change({ source: 'CARD', inspectionId: 'insp-1' }), ]) { const { status } = await statusAndBody( service.applyChange(tx as never, COLONY, input, USER), ); expect(status).toBe(400); } }); it('locks the colony, then the setup', async () => { await service.applyChange(tx as never, COLONY, change(), USER); expect(lockedTables(lock)).toEqual(['Colony', 'HiveSetup']); }); it('answers 409 for a colony that no longer lives', async () => { tx.colony.findUnique.mockResolvedValue({ status: 'SOLD' }); const { status } = await statusAndBody( service.applyChange(tx as never, COLONY, change(), USER), ); expect(status).toBe(409); expect(tx.hiveChange.create).not.toHaveBeenCalled(); }); it('answers 404 HIVE_NOT_FOUND without a setup', async () => { lock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); const { status, body } = await statusAndBody( service.applyChange(tx as never, COLONY, change(), USER), ); expect(status).toBe(404); expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' }); }); it('answers 409 HIVE_CONFLICT with the current state for a missing id', async () => { tx.hiveSetup.findUnique.mockResolvedValue(setup(4)); const ops: HiveOp[] = [setLabel('ok'), { op: 'removePart', id: 'gone' }]; const { status, body } = await statusAndBody( service.applyChange(tx as never, COLONY, change({ ops }), USER), ); expect(status).toBe(409); expect(body).toMatchObject({ code: 'HIVE_CONFLICT', opIndex: 1, reason: expect.stringMatching(/^MISSING_ID/) as unknown, current: { version: 4, state: baseState }, }); expect(tx.hiveChange.create).not.toHaveBeenCalled(); expect(events.record).not.toHaveBeenCalled(); }); it('answers 400 for tomorrow (Berlin)', async () => { const { status } = await statusAndBody( service.applyChange( tx as never, COLONY, change({ occurredOn: addDays(TODAY, 1) }), USER, ), ); expect(status).toBe(400); }); it('answers 400 for a day before the INIT change', async () => { const { status } = await statusAndBody( service.applyChange( tx as never, COLONY, change({ occurredOn: addDays(INIT_DAY, -1) }), USER, ), ); expect(status).toBe(400); }); it('accepts the INIT day itself and a backdated day', async () => { await service.applyChange( tx as never, COLONY, change({ occurredOn: INIT_DAY }), USER, ); expect( argOf(tx.hiveChange.create, 0, 0).data.occurredOn, ).toEqual(dayDate(INIT_DAY)); }); it('leaves no event behind when the change cannot be written', async () => { tx.hiveChange.create.mockRejectedValue(new Error('db down')); await expect( service.applyChange(tx as never, COLONY, change(), USER), ).rejects.toThrow('db down'); expect(events.record).not.toHaveBeenCalled(); }); it('writes the event with the caller transaction client only', async () => { await service.applyChange(tx as never, COLONY, change(), USER); expect(argOf(events.record, 0, 0)).toBe(tx); expect(prisma.$transaction).not.toHaveBeenCalled(); }); }); describe('history', () => { const row = (version: number, day: string) => ({ id: `c${version}`, version, occurredOn: dayDate(day), source: 'CARD', inspectionId: null, userId: USER, user: { name: 'Nick' }, ops: [], note: null, createdAt: new Date('2026-10-05T08:00:00.000Z'), }); it('lists newest first without stateAfter and maps the user name', async () => { tx.hiveChange.findMany.mockResolvedValue([row(3, '2026-10-05')]); const result = await service.history(COLONY); expect(tx.hiveChange.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { colonyId: COLONY }, orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }], take: 21, }), ); expect( argOf<{ select: Record }>(tx.hiveChange.findMany, 0, 0) .select.stateAfter, ).toBe(undefined); expect(result.items[0]).toEqual({ id: 'c3', version: 3, occurredOn: '2026-10-05', source: 'CARD', inspectionId: null, userId: USER, userName: 'Nick', ops: [], note: null, createdAt: '2026-10-05T08:00:00.000Z', }); expect(result.nextCursor).toBeNull(); }); it('returns a cursor when more rows exist and continues after it', async () => { tx.hiveChange.findMany.mockResolvedValue([ row(4, '2026-10-05'), row(2, '2026-10-04'), row(3, '2026-10-03'), ]); const page = await service.history(COLONY, undefined, 2); expect(page.items.map((item) => item.version)).toEqual([4, 2]); expect(page.nextCursor).not.toBeNull(); expect( JSON.parse( Buffer.from(page.nextCursor as string, 'base64url').toString(), ), ).toEqual({ occurredOn: '2026-10-04', version: 2 }); tx.hiveChange.findMany.mockResolvedValue([]); await service.history(COLONY, page.nextCursor as string, 2); expect( argOf<{ where: unknown }>(tx.hiveChange.findMany, 1, 0).where, ).toEqual({ colonyId: COLONY, OR: [ { occurredOn: { lt: dayDate('2026-10-04') } }, { occurredOn: dayDate('2026-10-04'), version: { lt: 2 } }, ], }); }); it('answers 400 for a malformed cursor', async () => { const { status } = await statusAndBody( service.history(COLONY, 'not-a-cursor'), ); expect(status).toBe(400); }); it('answers 404 HIVE_NOT_FOUND without a setup', async () => { tx.hiveSetup.findUnique.mockResolvedValue(null); const { status } = await statusAndBody(service.history(COLONY)); expect(status).toBe(404); }); }); describe('at', () => { it('returns the highest version with occurredOn <= day', async () => { tx.hiveChange.findFirst.mockResolvedValue({ version: 3, occurredOn: dayDate('2026-10-04'), stateAfter: baseState, }); const result = await service.at(COLONY, '2026-10-05'); expect(tx.hiveChange.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { colonyId: COLONY, occurredOn: { lte: dayDate('2026-10-05') }, }, orderBy: { version: 'desc' }, }), ); expect(result).toMatchObject({ version: 3, occurredOn: '2026-10-04', state: baseState, }); expect(result.facts.boxCount).toBe(1); }); it('answers 404 before the INIT day', async () => { tx.hiveChange.findFirst.mockResolvedValue(null); const { status, body } = await statusAndBody( service.at(COLONY, '2020-01-01'), ); expect(status).toBe(404); expect(body).toMatchObject({ code: 'HIVE_NOT_FOUND' }); }); it('answers 400 for a malformed day', async () => { const { status } = await statusAndBody(service.at(COLONY, '05.10.2026')); expect(status).toBe(400); }); }); });