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]>
This commit is contained in:
1 parent
8982d0796a
commit
b48fb157c8
141 files changed
+15211
-17
No files matched your search
@@ -0,0 +1,609 @@
|
||||
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<T>(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<unknown>) {
|
||||
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<WrittenChange>(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<WrittenChange>(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<WrittenChange>(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<unknown>(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<string, unknown> }>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user