Files
ApiCore/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 b48fb157c8
Backend Image / build-and-push (push) Successful in 5m5s
Frontend Image / build-and-push (push) Successful in 11m2s
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]>
2026-10-15 21:23:23 +02:00

177 lines
5.1 KiB
TypeScript

import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
import { PermissionCatalogSyncService } from '../src/auth/permissions/permission-catalog-sync.service';
import { PrismaService } from '../src/prisma/prisma.service';
const SYSTEM_ID = 'hive-system-zander-liebig-10';
const PART_PREFIX = 'hive-part-zl10-';
const PART_SLUGS = [
'floor',
'floor-insert',
'box-10',
'box-empty',
'divider-board',
'queen-excluder',
'feeder',
'cover-abdeckfix',
'cover-lotus',
'propolis-screen',
'inner-lid',
'insulation',
'roof',
'evaporator',
] as const;
interface StateField {
key: string;
kind: string;
feedType?: string;
[extra: string]: unknown;
}
/**
* The Liebig-Zander start catalog arrives with the migration (fixed ids) and
* `hive-systems:manage` joins ADMIN through the boot-time catalog sync.
* Checked against the migrated test database.
*/
describe('Hive catalog seed (e2e)', () => {
let prisma: PrismaService;
beforeAll(() => {
prisma = new PrismaService();
});
afterAll(async () => {
await prisma.$disconnect();
});
async function partType(slug: string) {
return prisma.hivePartType.findUniqueOrThrow({
where: { id: `${PART_PREFIX}${slug}` },
});
}
it('seeds the Zander-Liebig system with its geometry and template', async () => {
const system = await prisma.hiveSystem.findUniqueOrThrow({
where: { id: SYSTEM_ID },
});
expect(system).toMatchObject({
name: 'Zander nach Dr. Liebig, 10 Rähmchen',
frameWidthMm: 477,
frameHeightMm: 220,
footprintLMm: 525,
footprintWMm: 430,
active: true,
});
});
it('seeds 14 active part types for the system under fixed ids', async () => {
const types = await prisma.hivePartType.findMany({
where: { systemId: SYSTEM_ID, active: true },
orderBy: { id: 'asc' },
});
expect(types.map((t) => t.id)).toEqual(
PART_SLUGS.map((slug) => `${PART_PREFIX}${slug}`).sort(),
);
});
it('describes the 10-frame box with 10 slots and 230 mm height', async () => {
const box = await partType('box-10');
expect(box).toMatchObject({
kind: 'BOX',
boxDepth: 'FULL',
frameSlots: 10,
heightMm: 230,
lengthMm: 525,
widthMm: 430,
});
expect((await partType('box-empty')).frameSlots).toBe(0);
});
it('gives the feeder two level fields with a feed type each', async () => {
const feeder = await partType('feeder');
const fields = feeder.stateSchema as unknown as StateField[];
expect(fields).toHaveLength(2);
expect(fields.every((f) => f.kind === 'level')).toBe(true);
expect(fields.map((f) => [f.key, f.feedType])).toEqual([
['syrupLiters', 'SUGAR_WATER'],
['fondantKg', 'FEED_PASTE'],
]);
});
it('gives the other stateful parts their choice and level fields', async () => {
const keysOf = async (slug: string) =>
((await partType(slug)).stateSchema as unknown as StateField[]).map(
(f) => f.key,
);
expect(await keysOf('floor')).toEqual(['entrance', 'varroaTray']);
expect(await keysOf('divider-board')).toEqual(['bungHoles']);
expect(await keysOf('inner-lid')).toEqual(['feedHole']);
expect(await keysOf('evaporator')).toEqual(['acidMl']);
expect(await keysOf('roof')).toEqual([]);
});
it('stores the system template without ids, with 10 foundation frames', async () => {
const system = await prisma.hiveSystem.findUniqueOrThrow({
where: { id: SYSTEM_ID },
});
const template = system.template as unknown as {
schemaVersion: number;
stack: {
id?: string;
partTypeId: string;
state: Record<string, unknown>;
frames?: { slot: number; type: string }[];
}[];
};
expect(template.schemaVersion).toBe(1);
expect(template.stack.map((p) => p.partTypeId)).toEqual([
`${PART_PREFIX}floor`,
`${PART_PREFIX}box-10`,
`${PART_PREFIX}cover-abdeckfix`,
`${PART_PREFIX}inner-lid`,
`${PART_PREFIX}insulation`,
`${PART_PREFIX}roof`,
]);
for (const part of template.stack) {
expect(part.id).toBeUndefined();
expect(part.state).toEqual({});
}
const frames = template.stack[1].frames ?? [];
expect(frames).toHaveLength(10);
expect(frames.map((f) => f.slot)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(frames.every((f) => f.type === 'FOUNDATION')).toBe(true);
});
it('lists hive-systems:manage in the permission catalog', () => {
const entry = PERMISSION_CATALOG.find(
(e) => e.key === 'hive-systems:manage',
);
expect(entry).toMatchObject({
resource: 'hive-systems',
action: 'manage',
labelDe: 'Pflegen',
groupDe: 'Beutensysteme',
});
});
it('grants hive-systems:manage to ADMIN after the catalog sync', async () => {
await new PermissionCatalogSyncService(prisma).onModuleInit();
const grant = await prisma.rolePermission.findFirst({
where: {
role: { key: 'ADMIN' },
permission: { key: 'hive-systems:manage' },
},
});
expect(grant).not.toBeNull();
});
});