diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 59bbc1c..211f69c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -215,6 +215,12 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 92. **Beim Bearbeiten einer Behandlung wählt die Oberfläche nie selbst eine Packung vor**: Nur das Anlegen schlägt die Packung mit dem frühesten Verfall vor; im Bearbeiten-Dialog steht nach einem Mittelwechsel „Ohne Packung“, bis der Nutzer wählt. Kosten bei falscher Annahme: Wer eine Abbuchung erwartet, muss die Packung ausdrücklich wählen. Abschnitt 12.11. 93. **Die Glocke zeigt für `COMPLIANCE` das Kurz-Etikett „Behörden“**: Die Badges der Glocke sind Einwort-Etiketten („Aufgabe“, „Vorschlag“); „Behörden & Prüfung“ würde die Zeile umbrechen und steht darum nur in `lib/notifications/labels.ts` und der Telegram-Kategorienauswahl. Kosten bei falscher Annahme: Zwei Bezeichnungen für dieselbe Kategorie. Abschnitt 12.11. 94. **Die Dosis ist auf drei Nachkommastellen begrenzt**: Das Eingabefeld lehnt mehr als drei Nachkommastellen mit „Höchstens 3 Nachkommastellen.“ ab (Packungsmengen rechnen auf Tausendstel, `roundToThousandths`). Kosten bei falscher Annahme: Eine feinere Dosis lässt sich nicht erfassen. Abschnitt 12.11. +95. **Die Liebig-Zander-Zarge misst außen 525 × 430 mm**: Der Wert stammt aus dem Innenmaß des Edelstahldeckels und steht im Katalog (Migration `20261017100100_hive_catalog_zander_liebig`, `test/fixtures/hive-ops/catalog.json`). Kosten bei falscher Annahme: Die 3D-Maße stimmen nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert. Abschnitt 13. +96. **Die Styropor-Einlage misst 465 × 365 × 20 mm**: Der Inhaber misst nach. Kosten bei falscher Annahme: Die Dämmung sitzt im Modell zu groß oder zu klein; Katalogwert ändern, kein Code. Abschnitt 13. +97. **Der Nassenheider Professional liegt in einer Leerzarge auf den Rähmchen der Zarge darunter**: Im Modell ist er ein Einsatz der Leerzarge (`EVAPORATOR` in einer `BOX` mit 0 Plätzen). Kosten bei falscher Annahme: Die Lage im Aufbau weicht vom Stock ab; der Inhaber bestätigt mit Foto, die Regel steht in `hive-validate.ts`. Abschnitt 13. +98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13. +99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13. +100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13. ## 7. Kubernetes @@ -965,3 +971,42 @@ Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitsch - **Ernte:** nur die Warnung „Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.“; sie speichert immer. - **409.** `RESTRICTED_ZONE` und „Der Stand wurde gerade geändert. Bitte neu laden.“ erscheinen mit dem Servertext über dem Knopf, der Fokus springt auf die Meldung, die aktiven Sperren laden neu, und ein Knopf „Neu laden“ (Umsetzen, Vereinigen, Standkorrektur) lädt Volk und Stände neu und löscht die Meldung. Kein automatischer Neuversuch. - **Service Worker.** Die Sperrbezirk-Pfade sind seit Task 14 nie zwischengespeichert; offline fehlt die frühe Sperre, der Server entscheidet. + +## 13. Beute (3D-Aufbau) + +Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Lieferung L1 ist das Backend ohne Oberfläche. + +**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke. + +**Speicherform.** +- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide. +- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft. +- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig. + +**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`). + +**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`. + +**Sperrreihenfolge.** `Colony` FOR UPDATE, dann `HiveSetup` FOR UPDATE, dann `HivePartType` (`LOCKABLE_TABLES` in `src/prisma/row-lock.ts`: `HiveSetup` direkt nach `Colony`). Die Version steigt je Änderung um 1; zwei gleichzeitige Änderungen laufen nacheinander, die zweite sieht den Stand der ersten. + +**Rechte.** Lesen `colonies:read`, Aufbau anlegen und ändern `colonies:update`, Katalog ändern `hive-systems:manage` (Gruppe „Beutensysteme“). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung (`PermissionCatalogSyncService`) zur Rolle ADMIN, ohne Rechte-Migration. STAFF hat `colonies:update` nicht und kann die Beute nicht ändern (Annahme 99). + +**Ereignis.** `HiveChanged` (Outbox, in derselben Transaktion wie die Änderung; Nutzlast `colonyId`, `version`, `source`, `inspectionId`). Ein nicht lebendes Volk ist schreibgeschützt: 409 wie `assertActive`. + +**Durchsicht.** `POST /inspections` nimmt optional `hiveChange` (`ops`). Durchsicht und Beute-Änderung werden in einer Transaktion gespeichert und gemeinsam abgelehnt. Ohne `colonies:update` antwortet der Server 403 `HIVE_FORBIDDEN` und schreibt nichts. Der Tag der Änderung ist der Tag der Durchsicht (nicht in der Zukunft, nicht vor dem `INIT`-Tag); die Änderung hat `source` `INSPECTION` und verweist auf die Durchsicht. + +**Backup.** Export und Import enthalten `HiveSystem`, `HivePartType`, `HiveSetup` und `HiveChange`. Katalogzeilen werden nach Id eingespielt (auch in eine frische Installation mit Startdaten). Je Volk gewinnt die Sicherung: Aufbau und Protokoll eines wiederhergestellten Volks ersetzen die vorhandenen. Nicht versiegelte Altdateien ohne diese Tabellen werden gemeldet und übersprungen (Abschnitt 8). + +**Fehlercodes.** + +| Code | Status | Bedeutung | +|---|---|---| +| `HIVE_CONFLICT` | 409 | Operation passt nicht zum aktuellen Stand; mit `opIndex`, `reason`, `current` (Version und Stand) | +| `HIVE_EXISTS` | 409 | Das Volk hat schon einen Aufbau | +| `HIVE_NOT_FOUND` | 404 | Das Volk hat noch keinen Aufbau | +| `HIVE_FORBIDDEN` | 403 | Durchsicht mit `hiveChange` ohne `colonies:update` | +| `TEMPLATE_NOT_SET` | 409 | Das System hat noch keine Vorlage | +| `SCHEMA_NOT_ADDITIVE` | 400 | Zustandsfelder dürfen nur angehängt werden | +| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht | + +Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren. diff --git a/ApiCore-Backend/prisma/migrations/20261017100000_hive_setup/migration.sql b/ApiCore-Backend/prisma/migrations/20261017100000_hive_setup/migration.sql new file mode 100644 index 0000000..050c190 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261017100000_hive_setup/migration.sql @@ -0,0 +1,109 @@ +-- Beute in 3D: hive systems, part types, per-colony setup and its change +-- history (spec 2026-10-15, section 5.1). + +-- CreateEnum +CREATE TYPE "HivePartKind" AS ENUM ('FLOOR', 'BOX', 'DIVIDER_BOARD', 'QUEEN_EXCLUDER', 'FEEDER', 'COVER_SHEET', 'PROPOLIS_SCREEN', 'INNER_LID', 'INSULATION', 'ROOF', 'FLOOR_INSERT', 'EVAPORATOR'); + +-- CreateEnum +CREATE TYPE "HiveBoxDepth" AS ENUM ('FULL', 'SHALLOW'); + +-- CreateEnum +CREATE TYPE "HiveChangeSource" AS ENUM ('INIT', 'CARD', 'EDITOR', 'INSPECTION', 'AI_SUGGESTION'); + +-- CreateTable +CREATE TABLE "HiveSystem" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "frameWidthMm" INTEGER NOT NULL, + "frameHeightMm" INTEGER NOT NULL, + "footprintLMm" INTEGER NOT NULL, + "footprintWMm" INTEGER NOT NULL, + "template" JSONB NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HiveSystem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HivePartType" ( + "id" TEXT NOT NULL, + "systemId" TEXT NOT NULL, + "kind" "HivePartKind" NOT NULL, + "name" TEXT NOT NULL, + "articleRef" TEXT, + "lengthMm" INTEGER NOT NULL, + "widthMm" INTEGER NOT NULL, + "heightMm" INTEGER NOT NULL, + "boxDepth" "HiveBoxDepth", + "frameSlots" INTEGER, + "stateSchema" JSONB NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HivePartType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "HiveSetup" ( + "colonyId" TEXT NOT NULL, + "systemId" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "state" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "HiveSetup_pkey" PRIMARY KEY ("colonyId") +); + +-- CreateTable +CREATE TABLE "HiveChange" ( + "id" TEXT NOT NULL, + "colonyId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "occurredOn" TIMESTAMP(3) NOT NULL, + "source" "HiveChangeSource" NOT NULL, + "inspectionId" TEXT, + "userId" TEXT NOT NULL, + "ops" JSONB NOT NULL, + "stateAfter" JSONB NOT NULL, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "HiveChange_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "HiveSystem_name_key" ON "HiveSystem"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "HivePartType_systemId_name_key" ON "HivePartType"("systemId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "HiveChange_inspectionId_key" ON "HiveChange"("inspectionId"); + +-- CreateIndex +CREATE INDEX "HiveChange_colonyId_occurredOn_idx" ON "HiveChange"("colonyId", "occurredOn"); + +-- CreateIndex +CREATE UNIQUE INDEX "HiveChange_colonyId_version_key" ON "HiveChange"("colonyId", "version"); + +-- AddForeignKey +ALTER TABLE "HivePartType" ADD CONSTRAINT "HivePartType_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HiveSetup" ADD CONSTRAINT "HiveSetup_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "HiveSystem"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "HiveSetup"("colonyId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_inspectionId_fkey" FOREIGN KEY ("inspectionId") REFERENCES "Inspection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "HiveChange" ADD CONSTRAINT "HiveChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/ApiCore-Backend/prisma/migrations/20261017100100_hive_catalog_zander_liebig/migration.sql b/ApiCore-Backend/prisma/migrations/20261017100100_hive_catalog_zander_liebig/migration.sql new file mode 100644 index 0000000..d26b8f2 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261017100100_hive_catalog_zander_liebig/migration.sql @@ -0,0 +1,36 @@ +-- Beute in 3D: start catalog for the Liebig-Zander equipment (spec 2026-10-15, +-- sections 4.3 and 4.4) under fixed ids, so the system template has stable +-- references and a restore into a fresh install creates no duplicate names. +-- Dimensions are catalog data and editable in the settings. + +-- Hive system +INSERT INTO "HiveSystem" ("id", "name", "frameWidthMm", "frameHeightMm", "footprintLMm", "footprintWMm", "template", "active", "updatedAt") +VALUES ( + 'hive-system-zander-liebig-10', + 'Zander nach Dr. Liebig, 10 Rähmchen', + 477, + 220, + 525, + 430, + '{"schemaVersion":1,"stack":[{"partTypeId":"hive-part-zl10-floor","state":{}},{"partTypeId":"hive-part-zl10-box-10","state":{},"frames":[{"slot":1,"type":"FOUNDATION"},{"slot":2,"type":"FOUNDATION"},{"slot":3,"type":"FOUNDATION"},{"slot":4,"type":"FOUNDATION"},{"slot":5,"type":"FOUNDATION"},{"slot":6,"type":"FOUNDATION"},{"slot":7,"type":"FOUNDATION"},{"slot":8,"type":"FOUNDATION"},{"slot":9,"type":"FOUNDATION"},{"slot":10,"type":"FOUNDATION"}]},{"partTypeId":"hive-part-zl10-cover-abdeckfix","state":{}},{"partTypeId":"hive-part-zl10-inner-lid","state":{}},{"partTypeId":"hive-part-zl10-insulation","state":{}},{"partTypeId":"hive-part-zl10-roof","state":{}}]}'::jsonb, + true, + CURRENT_TIMESTAMP +); + +-- Part types +INSERT INTO "HivePartType" ("id", "systemId", "kind", "name", "lengthMm", "widthMm", "heightMm", "boxDepth", "frameSlots", "stateSchema", "active", "updatedAt") +VALUES + ('hive-part-zl10-floor', 'hive-system-zander-liebig-10', 'FLOOR', 'Liebig Wanderboden', 525, 430, 120, NULL, NULL, '[{"key":"entrance","label":"Flugloch","kind":"choice","options":["OPEN","NARROW","CLOSED"],"optionLabels":["offen","eng","zu"]},{"key":"varroaTray","label":"Varroa-Schublade","kind":"choice","options":["IN","OUT"],"optionLabels":["drin","draußen"]}]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-floor-insert', 'hive-system-zander-liebig-10', 'FLOOR_INSERT', 'Liebig Bausperre', 475, 375, 5, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-box-10', 'hive-system-zander-liebig-10', 'BOX', 'Liebig Zander Ganzzarge, 10 Rähmchen', 525, 430, 230, 'FULL', 10, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-box-empty', 'hive-system-zander-liebig-10', 'BOX', 'Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)', 525, 430, 230, 'FULL', 0, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-divider-board', 'hive-system-zander-liebig-10', 'DIVIDER_BOARD', 'Liebig Zwischenboden', 525, 430, 50, NULL, NULL, '[{"key":"bungHoles","label":"Spundlöcher","kind":"choice","options":["OPEN","CLOSED","BEE_ESCAPE"],"optionLabels":["offen","zu","Bienenflucht"]}]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-queen-excluder', 'hive-system-zander-liebig-10', 'QUEEN_EXCLUDER', 'Niro-Absperrgitter Zander Liebig', 478, 376, 2, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-feeder', 'hive-system-zander-liebig-10', 'FEEDER', 'Liebig Zweikammer-Futterzarge', 520, 420, 70, NULL, NULL, '[{"key":"syrupLiters","label":"Flüssigfutter","kind":"level","unit":"l","max":3,"step":0.5,"feedType":"SUGAR_WATER"},{"key":"fondantKg","label":"Futterteig","kind":"level","unit":"kg","max":2.5,"step":0.5,"feedType":"FEED_PASTE"}]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-cover-abdeckfix', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Abdeck-Fix (Folie, klar, 0,4 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-cover-lotus', 'hive-system-zander-liebig-10', 'COVER_SHEET', 'Imgut Lotus-Abdeckung (0,5 mm)', 478, 378, 1, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-propolis-screen', 'hive-system-zander-liebig-10', 'PROPOLIS_SCREEN', 'Imgut Propolisgitter', 478, 378, 3, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-inner-lid', 'hive-system-zander-liebig-10', 'INNER_LID', 'Liebig Holzdeckel', 525, 430, 35, NULL, NULL, '[{"key":"feedHole","label":"Futterloch","kind":"choice","options":["OPEN","CLOSED"],"optionLabels":["offen","zu"]}]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-insulation', 'hive-system-zander-liebig-10', 'INSULATION', 'Styropor-Einlage (selbst gebaut)', 465, 365, 20, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-roof', 'hive-system-zander-liebig-10', 'ROOF', 'Edelstahldeckel konisch Liebig', 525, 430, 60, NULL, NULL, '[]'::jsonb, true, CURRENT_TIMESTAMP), + ('hive-part-zl10-evaporator', 'hive-system-zander-liebig-10', 'EVAPORATOR', 'Nassenheider Verdunster Professional', 200, 140, 120, NULL, NULL, '[{"key":"acidMl","label":"Ameisensäure","kind":"level","unit":"ml","max":290,"step":10}]'::jsonb, true, CURRENT_TIMESTAMP); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index d211dc5..575614b 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -104,6 +104,7 @@ model User { refreshTokens RefreshToken[] inspections Inspection[] + hiveChanges HiveChange[] varroaMeasurements VarroaMeasurement[] swarmControlChecks SwarmControlCheck[] winterChecks WinterCheck[] @@ -331,6 +332,7 @@ model Colony { harvests HarvestColony[] queenEvaluations QueenEvaluation[] suggestions Suggestion[] + hiveSetup HiveSetup? @@index([apiaryId]) @@index([status]) @@ -501,7 +503,8 @@ model Inspection { notes String? createdAt DateTime @default(now()) - tasks Task[] + tasks Task[] + hiveChange HiveChange? @@index([colonyId, date]) } @@ -2182,3 +2185,101 @@ model ComplianceAcknowledgement { @@index([itemKey, subjectId]) } + +// --------------------------------------------------------------------------- +// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15) +// --------------------------------------------------------------------------- + +model HiveSystem { + id String @id @default(cuid()) + name String @unique + frameWidthMm Int // Oberträger + frameHeightMm Int + footprintLMm Int + footprintWMm Int + template Json // Aufbau-JSON ohne Ids, Schema 5.2 + active Boolean @default(true) + partTypes HivePartType[] + setups HiveSetup[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model HivePartType { + id String @id @default(cuid()) + systemId String + system HiveSystem @relation(fields: [systemId], references: [id]) + kind HivePartKind + name String + articleRef String? // z. B. Holtermann-Art.-Nr. + lengthMm Int + widthMm Int + heightMm Int + boxDepth HiveBoxDepth? + frameSlots Int? // nur BOX + stateSchema Json // Abschnitt 4.2 + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([systemId, name]) +} + +model HiveSetup { + colonyId String @id + colony Colony @relation(fields: [colonyId], references: [id]) + systemId String + system HiveSystem @relation(fields: [systemId], references: [id]) + version Int @default(1) + state Json // Abschnitt 5.2, immer vollständig + updatedAt DateTime @updatedAt + changes HiveChange[] +} + +model HiveChange { + id String @id @default(cuid()) + colonyId String + setup HiveSetup @relation(fields: [colonyId], references: [colonyId]) + version Int // Version nach der Änderung + occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe) + source HiveChangeSource + inspectionId String? @unique + inspection Inspection? @relation(fields: [inspectionId], references: [id]) + userId String + user User @relation(fields: [userId], references: [id]) + ops Json // angewandte Operationen, Abschnitt 6 + stateAfter Json + note String? + createdAt DateTime @default(now()) + + @@unique([colonyId, version]) + @@index([colonyId, occurredOn]) +} + +enum HivePartKind { + FLOOR + BOX + DIVIDER_BOARD + QUEEN_EXCLUDER + FEEDER + COVER_SHEET + PROPOLIS_SCREEN + INNER_LID + INSULATION + ROOF + FLOOR_INSERT + EVAPORATOR +} + +enum HiveBoxDepth { + FULL + SHALLOW +} + +enum HiveChangeSource { + INIT + CARD + EDITOR + INSPECTION + AI_SUGGESTION +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index dfd6769..b7f85ad 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module'; import { AutopilotModule } from './autopilot/autopilot.module'; import { SystemModule } from './system/system.module'; import { WithdrawalModule } from './withdrawal/withdrawal.module'; +import { HiveModule } from './hive/hive.module'; import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module'; import { ComplianceModule } from './compliance/compliance.module'; import { ReadinessModule } from './compliance/readiness/readiness.module'; @@ -136,6 +137,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce ApiKeysModule, SystemModule, VeterinaryMedicinesModule, + HiveModule, ComplianceModule, ReadinessModule, ], diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 6c3c125..10c0f04 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -412,6 +412,9 @@ export const PERMISSION_CATALOG = [ ], 3800, ), + // Beute in 3D: hive systems and their part-type catalog. Reading the + // catalog and writing a colony's setup use the colonies permissions. + ...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index f76d63c..6fa0ba5 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, Logger } from '@nestjs/common'; import * as path from 'node:path'; import { Prisma } from '../../generated/prisma'; import { + BackupBundle, BackupService, LEGACY_UNSEALED_TABLES, MODELS_EXCLUDED_FROM_BACKUP, @@ -113,7 +114,15 @@ describe('BackupService', () => { document: { findMany: jest.Mock; count: jest.Mock }; $transaction: jest.Mock; }; - let tx: Record & { + let tx: Record< + string, + { + upsert: jest.Mock; + update?: jest.Mock; + create?: jest.Mock; + deleteMany?: jest.Mock; + } + > & { permission: { findUnique: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock }; queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock }; @@ -139,6 +148,10 @@ describe('BackupService', () => { (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest .fn() .mockResolvedValue([]); + for (const key of ['hiveChange', 'hiveSetup']) { + tx[key].create = jest.fn().mockResolvedValue({}); + tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 }); + } tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.document.findUnique = jest.fn().mockResolvedValue(null); @@ -1948,6 +1961,80 @@ describe('BackupService', () => { }); }); + describe('importAll hive models', () => { + const hiveBundle = (): BackupBundle => ({ + version: 3, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { + hiveSystem: [{ id: 'hive-system-1', name: 'System' }], + hivePartType: [{ id: 'hive-part-1', systemId: 'hive-system-1' }], + colony: [{ id: 'colony-1' }, { id: 'colony-2' }], + hiveSetup: [ + { colonyId: 'colony-1', systemId: 'hive-system-1', version: 2 }, + { colonyId: 'colony-other', systemId: 'hive-system-1', version: 1 }, + ], + hiveChange: [ + { id: 'change-1', colonyId: 'colony-1', userId: 'known-user' }, + { id: 'change-2', colonyId: 'colony-1', userId: 'gone-user' }, + { id: 'change-3', colonyId: 'colony-other', userId: 'known-user' }, + ], + }, + }); + + it('upserts the catalog by id so seeded rows are neither duplicated nor rejected', async () => { + await service.importAll(hiveBundle(), 'admin-1', TRUSTED); + + expect(tx.hiveSystem.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'hive-system-1' } }), + ); + expect(tx.hivePartType.upsert).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'hive-part-1' } }), + ); + }); + + it('replaces setup and history of each restored colony, remapping the history user', async () => { + const summary = await service.importAll(hiveBundle(), 'admin-1', TRUSTED); + + expect(tx.hiveChange.deleteMany).toHaveBeenCalledWith({ + where: { colonyId: { in: ['colony-1'] } }, + }); + expect(tx.hiveSetup.deleteMany).toHaveBeenCalledWith({ + where: { colonyId: { in: ['colony-1'] } }, + }); + expect(tx.hiveSetup.create).toHaveBeenCalledTimes(1); + const created = tx.hiveChange.create!.mock.calls.map( + ([arg]: [{ data: { id: string; userId: string } }]) => arg.data, + ); + expect(created).toEqual([ + expect.objectContaining({ id: 'change-1', userId: 'known-user' }), + expect.objectContaining({ id: 'change-2', userId: 'admin-1' }), + ]); + expect(summary.imported).toMatchObject({ + hiveSystem: 1, + hivePartType: 1, + hiveSetup: 2, + hiveChange: 3, + }); + }); + + it('takes no hive table from an unsealed file and reports them', async () => { + const summary = await service.importAll(hiveBundle(), 'admin-1', { + trusted: false, + }); + + expect(tx.hiveSetup.create).not.toHaveBeenCalled(); + expect(tx.hiveSystem.upsert).not.toHaveBeenCalled(); + expect(summary.skipped).toEqual( + expect.arrayContaining([ + 'hiveSystem', + 'hivePartType', + 'hiveSetup', + 'hiveChange', + ]), + ); + }); + }); + // Drift guard: a model added to the schema has to be backed up or listed // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing. describe('model coverage', () => { diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index c783e2f..4b7e041 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -329,9 +329,13 @@ export class BackupService { data.colony = await this.prisma.colony.findMany(); data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany(); data.colonyLineage = await this.prisma.colonyLineage.findMany(); + data.hiveSystem = await this.prisma.hiveSystem.findMany(); + data.hivePartType = await this.prisma.hivePartType.findMany(); + data.hiveSetup = await this.prisma.hiveSetup.findMany(); data.queenEvaluation = await this.prisma.queenEvaluation.findMany(); data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany(); data.inspection = await this.prisma.inspection.findMany(); + data.hiveChange = await this.prisma.hiveChange.findMany(); data.taskTemplate = await this.prisma.taskTemplate.findMany(); data.suggestion = await this.prisma.suggestion.findMany(); data.task = await this.prisma.task.findMany(); @@ -608,6 +612,26 @@ export class BackupService { } imported.queen = rowsOf('queen').length; + // Catalog first: a fresh install already carries the seeded rows with + // the same fixed ids, so upsert by id neither fails nor duplicates. + for (const row of rowsOf('hiveSystem')) { + await tx.hiveSystem.upsert({ + where: { id: row.id as string }, + create: row as never, + update: updateWith(row) as never, + }); + } + imported.hiveSystem = rowsOf('hiveSystem').length; + + for (const row of rowsOf('hivePartType')) { + await tx.hivePartType.upsert({ + where: { id: row.id as string }, + create: row as never, + update: updateWith(row) as never, + }); + } + imported.hivePartType = rowsOf('hivePartType').length; + // Restore deliberately emits no ColonyCreated (no domain events). for (const row of rowsOf('colony')) { await tx.colony.upsert({ @@ -618,6 +642,33 @@ export class BackupService { } imported.colony = rowsOf('colony').length; + // A restore replaces, never merges: the backup's setup wins, so the + // existing setup and history of each restored colony go first (the + // history rows reference the setup). Their backup history follows + // after the inspections. + const hiveSetupRows = rowsOf('hiveSetup'); + const restoredColonyIds = new Set( + rowsOf('colony').map((row) => row.id as string), + ); + const setupsToReplace = hiveSetupRows.filter((row) => + restoredColonyIds.has(row.colonyId as string), + ); + const replacedColonyIds = setupsToReplace.map( + (row) => row.colonyId as string, + ); + if (replacedColonyIds.length > 0) { + await tx.hiveChange.deleteMany({ + where: { colonyId: { in: replacedColonyIds } }, + }); + await tx.hiveSetup.deleteMany({ + where: { colonyId: { in: replacedColonyIds } }, + }); + } + for (const row of setupsToReplace) { + await tx.hiveSetup.create({ data: row as never }); + } + imported.hiveSetup = hiveSetupRows.length; + for (const row of rowsOf('queen')) { if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) { await tx.queen.update({ @@ -692,6 +743,21 @@ export class BackupService { } imported.inspection = rowsOf('inspection').length; + const replacedColonies = new Set(replacedColonyIds); + const hiveChangeRows = rowsOf('hiveChange'); + for (const row of hiveChangeRows) { + if (!replacedColonies.has(row.colonyId as string)) { + continue; + } + await tx.hiveChange.create({ + data: { + ...row, + userId: resolveUserId(asString(row, 'userId')), + } as never, + }); + } + imported.hiveChange = hiveChangeRows.length; + for (const row of rowsOf('taskTemplate')) { await tx.taskTemplate.upsert({ where: { id: row.id as string }, diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index 58d38de..f85a06f 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -1,6 +1,7 @@ import { ColonyCondition, ColonyStatus, + HiveChangeSource, PhaseSource, PhenologyPhase, Prisma, @@ -144,6 +145,15 @@ export interface DomainEventPayloads { // reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to // webhooks. TskReferenceDayReached: { year: number; referenceDay: string }; + // Beute in 3D, aggregate Colony: the hive setup of a colony changed (INIT + // or a change). Ids and the source only, no content. Not forwarded to + // webhooks. + HiveChanged: { + colonyId: string; + version: number; + source: HiveChangeSource; + inspectionId: string | null; + }; } export type DomainEventType = keyof DomainEventPayloads; @@ -188,6 +198,7 @@ const CATALOG: Record = { ColonyMoved: true, ApiaryChanged: true, TskReferenceDayReached: true, + HiveChanged: true, }; export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts index ed83147..8d65165 100644 --- a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts +++ b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts @@ -183,7 +183,7 @@ describe('DomainEventsService', () => { describe('domain event catalog', () => { it('lists every event type exactly once', () => { - expect(DOMAIN_EVENT_TYPES).toHaveLength(38); + expect(DOMAIN_EVENT_TYPES).toHaveLength(39); expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length); expect(DOMAIN_EVENT_TYPES).toEqual( expect.arrayContaining([ @@ -206,6 +206,7 @@ describe('domain event catalog', () => { 'ColonyMoved', 'ApiaryChanged', 'TskReferenceDayReached', + 'HiveChanged', ]), ); }); diff --git a/ApiCore-Backend/src/hive/dto/create-hive-change.dto.spec.ts b/ApiCore-Backend/src/hive/dto/create-hive-change.dto.spec.ts new file mode 100644 index 0000000..15a4078 --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/create-hive-change.dto.spec.ts @@ -0,0 +1,82 @@ +import { ArgumentMetadata, ValidationPipe } from '@nestjs/common'; +import { CreateHiveChangeDto } from './create-hive-change.dto'; + +const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, +}); +const metadata: ArgumentMetadata = { + type: 'body', + metatype: CreateHiveChangeDto, +}; + +const body = (overrides: Record = {}) => ({ + occurredOn: '2026-10-15', + source: 'CARD', + ops: [{ op: 'setLabel', id: 'p1', label: 'Brutraum' }], + ...overrides, +}); + +const accepted = (value: unknown) => pipe.transform(value, metadata); +const rejected = async (value: unknown) => { + await expect(accepted(value)).rejects.toMatchObject({ status: 400 }); +}; + +describe('CreateHiveChangeDto', () => { + it('accepts every op shape', async () => { + const ops = [ + { op: 'addPart', part: { id: 'a', partTypeId: 't', state: {} }, at: 1 }, + { op: 'addInsert', hostId: 'h', part: { id: 'a' } }, + { op: 'removePart', id: 'a' }, + { op: 'movePart', id: 'a', to: 0 }, + { op: 'setState', id: 'a', key: 'k', value: 0.5 }, + { op: 'setState', id: 'a', key: 'k', value: 'OPEN' }, + { op: 'setFrame', boxId: 'b', slot: 1, type: 'HONEY', name: null }, + { op: 'setFrames', boxId: 'b', frames: [{ slot: 1, type: 'BROOD' }] }, + { op: 'setLabel', id: 'a', label: null }, + ]; + await expect(accepted(body({ ops }))).resolves.toBeDefined(); + }); + + it('refuses AI_SUGGESTION, INIT and INSPECTION as source', async () => { + for (const source of ['AI_SUGGESTION', 'INIT', 'INSPECTION']) { + await rejected(body({ source })); + } + }); + + it('refuses an unknown op name', async () => { + await rejected(body({ ops: [{ op: 'explode' }] })); + }); + + it('refuses wrong primitive types', async () => { + await rejected(body({ ops: [{ op: 'removePart', id: 5 }] })); + await rejected(body({ ops: [{ op: 'movePart', id: 'a', to: '1' }] })); + await rejected( + body({ ops: [{ op: 'setState', id: 'a', key: 'k', value: true }] }), + ); + await rejected(body({ ops: [{ op: 'addPart', part: 'x', at: 0 }] })); + }); + + it('refuses a missing required field of an op', async () => { + await rejected(body({ ops: [{ op: 'removePart' }] })); + }); + + it('refuses an empty ops list', async () => { + await rejected(body({ ops: [] })); + }); + + it('refuses more than 100 ops and a non-array', async () => { + const op = { op: 'setLabel', id: 'p1', label: 'x' }; + await expect( + accepted(body({ ops: Array.from({ length: 100 }, () => op) })), + ).resolves.toBeDefined(); + await rejected(body({ ops: Array.from({ length: 101 }, () => op) })); + await rejected(body({ ops: 'nope' })); + }); + + it('refuses a malformed day and unknown properties', async () => { + await rejected(body({ occurredOn: '15.10.2026' })); + await rejected(body({ extra: 1 })); + }); +}); diff --git a/ApiCore-Backend/src/hive/dto/create-hive-change.dto.ts b/ApiCore-Backend/src/hive/dto/create-hive-change.dto.ts new file mode 100644 index 0000000..7d30996 --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/create-hive-change.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { DAY_PATTERN } from './create-hive-setup.dto'; +import { HiveOpDto, MAX_OPS_PER_REQUEST } from './hive-op.dto'; + +// INIT and INSPECTION are set by the server; AI_SUGGESTION is not accepted yet. +export const CLIENT_CHANGE_SOURCES = ['CARD', 'EDITOR'] as const; +const MAX_NOTE_LENGTH = 500; + +export class CreateHiveChangeDto { + @ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' }) + @Matches(DAY_PATTERN) + occurredOn!: string; + + @ApiProperty({ enum: CLIENT_CHANGE_SOURCES }) + @IsIn(CLIENT_CHANGE_SOURCES) + source!: (typeof CLIENT_CHANGE_SOURCES)[number]; + + @ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(MAX_OPS_PER_REQUEST) + @ValidateNested({ each: true }) + @Type(() => HiveOpDto) + ops!: HiveOpDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(MAX_NOTE_LENGTH) + note?: string; +} diff --git a/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts b/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts new file mode 100644 index 0000000..40b9dc6 --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/create-hive-part-type.dto.ts @@ -0,0 +1,89 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export const HIVE_PART_KINDS = [ + 'FLOOR', + 'BOX', + 'DIVIDER_BOARD', + 'QUEEN_EXCLUDER', + 'FEEDER', + 'COVER_SHEET', + 'PROPOLIS_SCREEN', + 'INNER_LID', + 'INSULATION', + 'ROOF', + 'FLOOR_INSERT', + 'EVAPORATOR', +] as const; +export const HIVE_BOX_DEPTHS = ['FULL', 'SHALLOW'] as const; + +const MAX_MM = 5000; +const MAX_SCHEMA_FIELDS = 30; +const MAX_FRAME_SLOTS = 20; + +export class CreateHivePartTypeDto { + @ApiProperty({ enum: HIVE_PART_KINDS }) + @IsIn(HIVE_PART_KINDS) + kind!: (typeof HIVE_PART_KINDS)[number]; + + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(120) + name!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(80) + articleRef?: string; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + lengthMm!: number; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + widthMm!: number; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + heightMm!: number; + + @ApiPropertyOptional({ enum: HIVE_BOX_DEPTHS }) + @IsOptional() + @IsIn(HIVE_BOX_DEPTHS) + boxDepth?: (typeof HIVE_BOX_DEPTHS)[number]; + + @ApiPropertyOptional({ description: 'Required for kind BOX' }) + @IsOptional() + @IsInt() + @Min(0) + @Max(MAX_FRAME_SLOTS) + frameSlots?: number; + + @ApiPropertyOptional({ description: 'State fields, spec 4.2' }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MAX_SCHEMA_FIELDS) + @IsObject({ each: true }) + stateSchema?: Record[]; +} diff --git a/ApiCore-Backend/src/hive/dto/create-hive-setup.dto.ts b/ApiCore-Backend/src/hive/dto/create-hive-setup.dto.ts new file mode 100644 index 0000000..3b85044 --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/create-hive-setup.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsObject, IsString, Matches, MaxLength } from 'class-validator'; + +export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export class CreateHiveSetupDto { + @ApiProperty() + @IsString() + @MaxLength(100) + systemId!: string; + + @ApiProperty({ + description: 'Complete hive state (schemaVersion 1); validated server side', + }) + @IsObject() + state!: Record; + + @ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' }) + @Matches(DAY_PATTERN) + occurredOn!: string; +} diff --git a/ApiCore-Backend/src/hive/dto/create-hive-system.dto.ts b/ApiCore-Backend/src/hive/dto/create-hive-system.dto.ts new file mode 100644 index 0000000..6c99607 --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/create-hive-system.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsInt, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +const MAX_MM = 5000; + +export class CreateHiveSystemDto { + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(120) + name!: string; + + @ApiProperty({ description: 'Top bar width in mm' }) + @IsInt() + @Min(1) + @Max(MAX_MM) + frameWidthMm!: number; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + frameHeightMm!: number; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + footprintLMm!: number; + + @ApiProperty() + @IsInt() + @Min(1) + @Max(MAX_MM) + footprintWMm!: number; +} diff --git a/ApiCore-Backend/src/hive/dto/hive-op.dto.ts b/ApiCore-Backend/src/hive/dto/hive-op.dto.ts new file mode 100644 index 0000000..21d55cf --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/hive-op.dto.ts @@ -0,0 +1,144 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsInt, + IsObject, + IsOptional, + IsString, + MaxLength, + ValidateBy, + ValidateIf, + buildMessage, +} from 'class-validator'; +import { FRAME_TYPES, MAX_OPS } from '../hive-types'; + +export const HIVE_OP_NAMES = [ + 'addPart', + 'addInsert', + 'removePart', + 'movePart', + 'setState', + 'setFrame', + 'setFrames', + 'setLabel', +] as const; + +export const MAX_OPS_PER_REQUEST = MAX_OPS; + +const MAX_ID_LENGTH = 100; +const MAX_TEXT = 200; +const MAX_FRAMES = 100; + +type OpName = (typeof HIVE_OP_NAMES)[number]; + +/** The property applies only to the listed operations. */ +const onlyFor = + (...names: OpName[]) => + (dto: HiveOpDto) => + names.includes(dto.op); + +function IsStringOrFiniteNumber() { + return ValidateBy( + { + name: 'isStringOrFiniteNumber', + validator: { + validate: (value: unknown) => + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)), + defaultMessage: buildMessage( + (each) => `${each}$property must be a string or a finite number`, + ), + }, + }, + {}, + ); +} + +/** + * One hive operation. Only the shape is checked here (discriminator and + * primitive types); the deep check is `applyHiveOps` (spec 6.2). + */ +export class HiveOpDto { + @ApiProperty({ enum: HIVE_OP_NAMES }) + @IsIn(HIVE_OP_NAMES) + op!: OpName; + + @ApiPropertyOptional({ description: 'addPart, addInsert: the part' }) + @ValidateIf(onlyFor('addPart', 'addInsert')) + @IsObject() + part?: Record; + + @ApiPropertyOptional({ description: 'addPart: stack index' }) + @ValidateIf(onlyFor('addPart')) + @IsInt() + at?: number; + + @ApiPropertyOptional({ description: 'addInsert: id of the host part' }) + @ValidateIf(onlyFor('addInsert')) + @IsString() + @MaxLength(MAX_ID_LENGTH) + hostId?: string; + + @ApiPropertyOptional({ + description: 'removePart, movePart, setState, setLabel: part id', + }) + @ValidateIf(onlyFor('removePart', 'movePart', 'setState', 'setLabel')) + @IsString() + @MaxLength(MAX_ID_LENGTH) + id?: string; + + @ApiPropertyOptional({ description: 'movePart: target stack index' }) + @ValidateIf(onlyFor('movePart')) + @IsInt() + to?: number; + + @ApiPropertyOptional({ description: 'setState: state field key' }) + @ValidateIf(onlyFor('setState')) + @IsString() + @MaxLength(MAX_ID_LENGTH) + key?: string; + + @ApiPropertyOptional({ description: 'setState: number or string' }) + @ValidateIf(onlyFor('setState')) + @IsStringOrFiniteNumber() + value?: number | string; + + @ApiPropertyOptional({ description: 'setFrame, setFrames: box id' }) + @ValidateIf(onlyFor('setFrame', 'setFrames')) + @IsString() + @MaxLength(MAX_ID_LENGTH) + boxId?: string; + + @ApiPropertyOptional({ description: 'setFrame: slot number' }) + @ValidateIf(onlyFor('setFrame')) + @IsInt() + slot?: number; + + @ApiPropertyOptional({ description: 'setFrame: frame type' }) + @ValidateIf(onlyFor('setFrame')) + @IsIn(FRAME_TYPES) + type?: string; + + @ApiPropertyOptional({ description: 'setFrame: optional frame name' }) + @ValidateIf(onlyFor('setFrame')) + @IsOptional() + @IsString() + @MaxLength(MAX_TEXT) + name?: string | null; + + @ApiPropertyOptional({ description: 'setFrames: all frames of the box' }) + @ValidateIf(onlyFor('setFrames')) + @IsArray() + @ArrayMaxSize(MAX_FRAMES) + @IsObject({ each: true }) + frames?: Record[]; + + @ApiPropertyOptional({ description: 'setLabel: new label or null' }) + @ValidateIf(onlyFor('setLabel')) + @IsOptional() + @IsString() + @MaxLength(MAX_TEXT) + label?: string | null; +} diff --git a/ApiCore-Backend/src/hive/dto/hive-queries.dto.ts b/ApiCore-Backend/src/hive/dto/hive-queries.dto.ts new file mode 100644 index 0000000..7ad952a --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/hive-queries.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { DAY_PATTERN } from './create-hive-setup.dto'; + +export const MAX_HISTORY_LIMIT = 100; + +export class HiveHistoryQueryDto { + @ApiPropertyOptional({ description: 'Opaque cursor of the previous page' }) + @IsOptional() + @IsString() + @MaxLength(200) + cursor?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: MAX_HISTORY_LIMIT }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_HISTORY_LIMIT) + limit?: number; +} + +export class HiveAtQueryDto { + @ApiProperty({ description: 'Berlin calendar day, YYYY-MM-DD' }) + @Matches(DAY_PATTERN) + day!: string; +} diff --git a/ApiCore-Backend/src/hive/dto/update-hive-part-type.dto.ts b/ApiCore-Backend/src/hive/dto/update-hive-part-type.dto.ts new file mode 100644 index 0000000..9805e7b --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/update-hive-part-type.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { CreateHivePartTypeDto } from './create-hive-part-type.dto'; + +// `kind` cannot change after creation, so it is not part of the update DTO. +export class UpdateHivePartTypeDto extends PartialType( + OmitType(CreateHivePartTypeDto, ['kind'] as const), +) { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/ApiCore-Backend/src/hive/dto/update-hive-system.dto.ts b/ApiCore-Backend/src/hive/dto/update-hive-system.dto.ts new file mode 100644 index 0000000..d4dfa0f --- /dev/null +++ b/ApiCore-Backend/src/hive/dto/update-hive-system.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsBoolean, IsObject, IsOptional } from 'class-validator'; +import { CreateHiveSystemDto } from './create-hive-system.dto'; + +export class UpdateHiveSystemDto extends PartialType(CreateHiveSystemDto) { + @ApiPropertyOptional({ description: 'Hive state JSON without ids (5.2)' }) + @IsOptional() + @IsObject() + template?: Record; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/ApiCore-Backend/src/hive/hive-canonical.ts b/ApiCore-Backend/src/hive/hive-canonical.ts new file mode 100644 index 0000000..f1a5392 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-canonical.ts @@ -0,0 +1,35 @@ +import type { HiveOp } from './hive-types'; + +/** + * A copy of the op with only the fields its name defines, so stray client + * fields never reach the state logic or the stored history. + */ +export function canonicalOp(op: HiveOp): HiveOp { + switch (op.op) { + case 'addPart': + return { op: op.op, part: op.part, at: op.at }; + case 'addInsert': + return { op: op.op, hostId: op.hostId, part: op.part }; + case 'removePart': + return { op: op.op, id: op.id }; + case 'movePart': + return { op: op.op, id: op.id, to: op.to }; + case 'setState': + return { op: op.op, id: op.id, key: op.key, value: op.value }; + case 'setFrame': + return { + op: op.op, + boxId: op.boxId, + slot: op.slot, + type: op.type, + ...(op.name !== undefined ? { name: op.name } : {}), + }; + case 'setFrames': + return { op: op.op, boxId: op.boxId, frames: op.frames }; + case 'setLabel': + return { op: op.op, id: op.id, label: op.label }; + default: + // Unknown names stay as they are; applyHiveOps reports UNKNOWN_OP. + return { ...(op as object) } as HiveOp; + } +} diff --git a/ApiCore-Backend/src/hive/hive-catalog.controller.ts b/ApiCore-Backend/src/hive/hive-catalog.controller.ts new file mode 100644 index 0000000..57bcdfb --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-catalog.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto'; +import { CreateHiveSystemDto } from './dto/create-hive-system.dto'; +import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto'; +import { UpdateHiveSystemDto } from './dto/update-hive-system.dto'; +import { HiveCatalogService } from './hive-catalog.service'; + +@ApiTags('hive-catalog') +@ApiBearerAuth() +@Controller() +export class HiveCatalogController { + constructor(private readonly catalog: HiveCatalogService) {} + + @RequirePermission('colonies:read') + @Get('hive-systems') + listSystems() { + return this.catalog.listSystems(); + } + + @RequirePermission('colonies:read') + @Get('hive-systems/:id/template') + template(@Param('id') id: string) { + return this.catalog.template(id); + } + + @RequirePermission('hive-systems:manage') + @Post('hive-systems') + createSystem( + @Body() dto: CreateHiveSystemDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.catalog.createSystem(dto, actor.id); + } + + @RequirePermission('hive-systems:manage') + @Patch('hive-systems/:id') + updateSystem( + @Param('id') id: string, + @Body() dto: UpdateHiveSystemDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.catalog.updateSystem(id, dto, actor.id); + } + + @RequirePermission('hive-systems:manage') + @Post('hive-systems/:id/part-types') + createPartType( + @Param('id') id: string, + @Body() dto: CreateHivePartTypeDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.catalog.createPartType(id, dto, actor.id); + } + + @RequirePermission('hive-systems:manage') + @Patch('hive-part-types/:id') + updatePartType( + @Param('id') id: string, + @Body() dto: UpdateHivePartTypeDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.catalog.updatePartType(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts b/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts new file mode 100644 index 0000000..4c5c5af --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-catalog.service.spec.ts @@ -0,0 +1,458 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Prisma } from '../../generated/prisma'; +import { HiveCatalogService } from './hive-catalog.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { validateHiveState } from './hive-validate'; +import type { HiveState } from './hive-types'; +import { loadFixtureCatalog } from './hive-test-utils'; + +const SYSTEM_ID = 'hive-system-zander-liebig-10'; +const USER = 'user-1'; + +const fixtureRows = [...loadFixtureCatalog().partTypes.values()].map((t) => ({ + ...t, + systemId: SYSTEM_ID, +})); + +const FLOOR = 'hive-part-zl10-floor'; +const BOX = 'hive-part-zl10-box-10'; +const ROOF = 'hive-part-zl10-roof'; +const EVAPORATOR = 'hive-part-zl10-evaporator'; + +const frames = Array.from({ length: 10 }, (_, i) => ({ + slot: i + 1, + type: 'FOUNDATION', +})); + +// Stored templates carry no ids (spec 4.4); one insert covers the insert path. +const storedTemplate = { + schemaVersion: 1, + stack: [ + { partTypeId: FLOOR, state: {} }, + { + partTypeId: BOX, + state: {}, + frames, + inserts: [{ partTypeId: EVAPORATOR, state: {} }], + }, + { partTypeId: ROOF, state: {} }, + ], +}; + +const levelField = { + key: 'syrupLiters', + label: 'Fluessigfutter', + kind: 'level', + unit: 'l', + max: 3, + step: 0.5, +}; +const choiceField = { + key: 'entrance', + label: 'Flugloch', + kind: 'choice', + options: ['OPEN', 'CLOSED'], + optionLabels: ['offen', 'zu'], +}; + +describe('HiveCatalogService', () => { + let service: HiveCatalogService; + let tx: { + $queryRaw: jest.Mock; + hiveSystem: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + hivePartType: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let prisma: { $transaction: jest.Mock } & typeof tx; + let auditLog: { record: jest.Mock }; + + beforeEach(() => { + tx = { + $queryRaw: jest.fn().mockResolvedValue([{ id: 'x' }]), + hiveSystem: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ + id: SYSTEM_ID, + name: 'Zander', + template: storedTemplate, + active: true, + }), + create: jest.fn(), + update: jest.fn(), + }, + hivePartType: { + findMany: jest.fn().mockResolvedValue(fixtureRows), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + prisma = { + ...tx, + $transaction: jest.fn((cb: (t: typeof tx) => unknown) => cb(tx)), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new HiveCatalogService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + describe('listSystems', () => { + it('returns systems with all part types ordered by name', async () => { + await service.listSystems(); + expect(prisma.hiveSystem.findMany).toHaveBeenCalledWith({ + include: { partTypes: { orderBy: { name: 'asc' } } }, + orderBy: { name: 'asc' }, + }); + }); + }); + + describe('loadCatalog', () => { + it('maps rows including inactive part types', async () => { + tx.hivePartType.findMany.mockResolvedValue([ + { ...fixtureRows[0], active: false }, + ]); + const catalog = await service.loadCatalog(SYSTEM_ID); + expect(catalog.systemId).toBe(SYSTEM_ID); + expect(catalog.partTypes.get(fixtureRows[0].id)?.active).toBe(false); + }); + + it('uses the given transaction client', async () => { + const other = { + hiveSystem: { + findUnique: jest.fn().mockResolvedValue({ id: SYSTEM_ID }), + }, + hivePartType: { findMany: jest.fn().mockResolvedValue([]) }, + }; + await service.loadCatalog( + SYSTEM_ID, + other as unknown as Prisma.TransactionClient, + ); + expect(other.hivePartType.findMany).toHaveBeenCalled(); + expect(tx.hivePartType.findMany).not.toHaveBeenCalled(); + }); + + it('rejects a part type row with a malformed stateSchema', async () => { + tx.hivePartType.findMany.mockResolvedValue([ + { ...fixtureRows[0], stateSchema: { not: 'a list' } }, + ]); + await expect(service.loadCatalog(SYSTEM_ID)).rejects.toThrow(); + }); + + it('throws NotFound for an unknown system', async () => { + tx.hiveSystem.findUnique.mockResolvedValue(null); + await expect(service.loadCatalog('nope')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); + + describe('template', () => { + const ids = (state: HiveState): string[] => + state.stack.flatMap((p) => [p.id, ...(p.inserts ?? []).map((i) => i.id)]); + + it('gives fresh ids on every call and is a valid state', async () => { + const first = await service.template(SYSTEM_ID); + const second = await service.template(SYSTEM_ID); + const firstIds = ids(first); + expect(firstIds).toHaveLength(4); + expect(new Set(firstIds).size).toBe(4); + expect(ids(second).some((id) => firstIds.includes(id))).toBe(false); + expect(validateHiveState(first, loadFixtureCatalog())).toEqual([]); + }); + + it('throws 409 TEMPLATE_NOT_SET for an empty stored stack', async () => { + tx.hiveSystem.findUnique.mockResolvedValue({ + id: SYSTEM_ID, + template: { schemaVersion: 1, stack: [] }, + }); + await expect(service.template(SYSTEM_ID)).rejects.toMatchObject({ + status: 409, + response: expect.objectContaining({ + code: 'TEMPLATE_NOT_SET', + }) as unknown, + }); + }); + + it('does not mutate the stored template', async () => { + await service.template(SYSTEM_ID); + expect(storedTemplate.stack[0]).not.toHaveProperty('id'); + }); + }); + + describe('updateSystem', () => { + it('rejects a template that fails validation with the messages', async () => { + const bad = { schemaVersion: 1, stack: [{ partTypeId: BOX, state: {} }] }; + const error = await service + .updateSystem(SYSTEM_ID, { template: bad }, USER) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(BadRequestException); + const body = (error as BadRequestException).getResponse() as { + messages: string[]; + }; + expect(body.messages.length).toBeGreaterThan(0); + expect(tx.hiveSystem.update).not.toHaveBeenCalled(); + }); + + it('rejects an explicit empty-stack template with TEMPLATE_INVALID', async () => { + await expect( + service.updateSystem( + SYSTEM_ID, + { template: { schemaVersion: 1, stack: [] } }, + USER, + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'TEMPLATE_INVALID', + }) as unknown, + }); + }); + + it('stores a valid template and audits the update', async () => { + tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID }); + await service.updateSystem(SYSTEM_ID, { template: storedTemplate }, USER); + expect(tx.hiveSystem.update).toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'HiveSystem', + action: 'UPDATE', + entityId: SYSTEM_ID, + }), + tx, + ); + }); + + it('deactivates without deleting', async () => { + tx.hiveSystem.update.mockResolvedValue({ id: SYSTEM_ID, active: false }); + await service.updateSystem(SYSTEM_ID, { active: false }, USER); + expect(tx.hiveSystem.update).toHaveBeenCalledWith({ + where: { id: SYSTEM_ID }, + data: { active: false }, + }); + }); + }); + + describe('createSystem', () => { + it('creates with an empty stack and audits CREATE', async () => { + tx.hiveSystem.create.mockResolvedValue({ id: 'new' }); + await service.createSystem( + { + name: 'Neu', + frameWidthMm: 1, + frameHeightMm: 1, + footprintLMm: 1, + footprintWMm: 1, + }, + USER, + ); + expect(tx.hiveSystem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + template: { schemaVersion: 1, stack: [] }, + }) as unknown, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'HiveSystem', action: 'CREATE' }), + tx, + ); + }); + }); + + describe('part types', () => { + const existing = { + id: 'pt-1', + systemId: SYSTEM_ID, + kind: 'FEEDER', + name: 'Futter', + stateSchema: [levelField, choiceField], + active: true, + }; + + beforeEach(() => { + tx.hivePartType.findUnique.mockResolvedValue(existing); + tx.hivePartType.update.mockResolvedValue(existing); + }); + + it('rejects removing a state field with SCHEMA_NOT_ADDITIVE', async () => { + const error = await service + .updatePartType('pt-1', { stateSchema: [levelField] }, USER) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).getResponse()).toMatchObject({ + code: 'SCHEMA_NOT_ADDITIVE', + }); + expect(tx.hivePartType.update).not.toHaveBeenCalled(); + }); + + it('rejects changing an existing field definition', async () => { + const changed = { ...levelField, max: 5 }; + await expect( + service.updatePartType( + 'pt-1', + { stateSchema: [changed, choiceField] }, + USER, + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'SCHEMA_NOT_ADDITIVE', + }) as unknown, + }); + }); + + it('accepts appending a field and audits the update', async () => { + const extra = { ...levelField, key: 'extra' }; + await service.updatePartType( + 'pt-1', + { stateSchema: [levelField, choiceField, extra] }, + USER, + ); + expect(tx.hivePartType.update).toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'HivePartType', + action: 'UPDATE', + entityId: 'pt-1', + }), + tx, + ); + }); + + it('rejects a duplicate key in the appended schema', async () => { + await expect( + service.updatePartType( + 'pt-1', + { stateSchema: [levelField, choiceField, levelField] }, + USER, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('locks the part type row before reading it', async () => { + await service.updatePartType('pt-1', { active: false }, USER); + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.hivePartType.findUnique.mock.invocationCallOrder[0], + ); + }); + + it.each([ + ['unknown feedType', { ...levelField, key: 'f', feedType: 'NOPE' }], + [ + 'duplicate options', + { + ...choiceField, + key: 'c', + options: ['A', 'A'], + optionLabels: ['a', 'b'], + }, + ], + ['key over 40', { ...levelField, key: 'k'.repeat(41) }], + ['label over 60', { ...levelField, key: 'l', label: 'x'.repeat(61) }], + ['unit over 10', { ...levelField, key: 'u', unit: 'x'.repeat(11) }], + [ + 'option over 40', + { + ...choiceField, + key: 'o', + options: ['x'.repeat(41), 'B'], + optionLabels: ['a', 'b'], + }, + ], + [ + 'option label over 40', + { + ...choiceField, + key: 'p', + options: ['A', 'B'], + optionLabels: ['x'.repeat(41), 'b'], + }, + ], + ])('rejects a new field with %s', async (_name, field) => { + await expect( + service.updatePartType( + 'pt-1', + { stateSchema: [levelField, choiceField, field] }, + USER, + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'SCHEMA_INVALID', + }) as unknown, + }); + }); + + it('accepts a valid feedType', async () => { + const ok = { ...levelField, key: 'fed', feedType: 'FEED_PASTE' }; + await service.updatePartType( + 'pt-1', + { stateSchema: [levelField, choiceField, ok] }, + USER, + ); + expect(tx.hivePartType.update).toHaveBeenCalled(); + }); + + it('deactivates by setting active false and never deletes', async () => { + await service.updatePartType('pt-1', { active: false }, USER); + expect(tx.hivePartType.update).toHaveBeenCalledWith({ + where: { id: 'pt-1' }, + data: { active: false }, + }); + }); + + it('throws NotFound for an unknown part type', async () => { + tx.hivePartType.findUnique.mockResolvedValue(null); + await expect( + service.updatePartType('x', { active: false }, USER), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('creates a part type with audit CREATE', async () => { + tx.hivePartType.create.mockResolvedValue({ id: 'new' }); + await service.createPartType( + SYSTEM_ID, + { + kind: 'ROOF', + name: 'Dach', + lengthMm: 1, + widthMm: 1, + heightMm: 1, + stateSchema: [], + }, + USER, + ); + expect(tx.hivePartType.create).toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'HivePartType', + action: 'CREATE', + }), + tx, + ); + }); + + it('requires frameSlots for a BOX', async () => { + await expect( + service.createPartType( + SYSTEM_ID, + { + kind: 'BOX', + name: 'Zarge', + lengthMm: 1, + widthMm: 1, + heightMm: 1, + }, + USER, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/ApiCore-Backend/src/hive/hive-catalog.service.ts b/ApiCore-Backend/src/hive/hive-catalog.service.ts new file mode 100644 index 0000000..a147e17 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-catalog.service.ts @@ -0,0 +1,296 @@ +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { HivePartType, HiveSystem, Prisma } from '../../generated/prisma'; +import { AuditLogService } from '../audit/audit-log.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { CreateHivePartTypeDto } from './dto/create-hive-part-type.dto'; +import { CreateHiveSystemDto } from './dto/create-hive-system.dto'; +import { UpdateHivePartTypeDto } from './dto/update-hive-part-type.dto'; +import { UpdateHiveSystemDto } from './dto/update-hive-system.dto'; +import { + isAdditiveSchema, + parseStateSchema, + stateSchemaProblems, +} from './hive-state-schema'; +import { catalogFromRows } from './hive-types'; +import type { HiveCatalog, HiveState } from './hive-types'; +import { validateHiveState } from './hive-validate'; + +export type HiveSystemDto = HiveSystem & { partTypes: HivePartType[] }; + +const EMPTY_TEMPLATE = { schemaVersion: 1, stack: [] as never[] }; +const UNIQUE_VIOLATION = 'P2002'; + +type Obj = Record; + +const isObj = (value: unknown): value is Obj => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Copy of a stored part (no id) with a fresh id on it and on its inserts. */ +function stampPart(part: unknown): unknown { + if (!isObj(part)) return part; + const stamped: Obj = { ...part, id: randomUUID() }; + if (Array.isArray(part.inserts)) { + stamped.inserts = part.inserts.map(stampPart); + } + return stamped; +} + +function stampTemplate(template: unknown): unknown { + if (!isObj(template) || !Array.isArray(template.stack)) return template; + return { ...template, stack: template.stack.map(stampPart) }; +} + +function conflictOnDuplicate(error: unknown, what: string): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === UNIQUE_VIOLATION + ) { + throw new ConflictException(`${what} name already exists`); + } + throw error; +} + +@Injectable() +export class HiveCatalogService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async listSystems(): Promise { + return this.prisma.hiveSystem.findMany({ + include: { partTypes: { orderBy: { name: 'asc' } } }, + orderBy: { name: 'asc' }, + }); + } + + /** Part types of a system incl. inactive ones (`active: false`). */ + async loadCatalog( + systemId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const db = tx ?? this.prisma; + const system = await db.hiveSystem.findUnique({ + where: { id: systemId }, + select: { id: true }, + }); + if (!system) throw new NotFoundException('Hive system not found'); + const rows = await db.hivePartType.findMany({ where: { systemId } }); + return catalogFromRows( + rows.map((row) => ({ + id: row.id, + kind: row.kind, + name: row.name, + lengthMm: row.lengthMm, + widthMm: row.widthMm, + heightMm: row.heightMm, + frameSlots: row.frameSlots, + stateSchema: parseStateSchema(row.stateSchema), + active: row.active, + })), + systemId, + ); + } + + /** The system template as a hive state with fresh ids on every part. */ + async template(systemId: string): Promise { + const system = await this.prisma.hiveSystem.findUnique({ + where: { id: systemId }, + }); + if (!system) throw new NotFoundException('Hive system not found'); + const stored = system.template; + if ( + isObj(stored) && + Array.isArray(stored.stack) && + stored.stack.length === 0 + ) { + throw new ConflictException({ + code: 'TEMPLATE_NOT_SET', + message: 'The hive system has no template yet', + }); + } + return stampTemplate(stored) as HiveState; + } + + async createSystem( + dto: CreateHiveSystemDto, + actingUserId: string, + ): Promise { + return this.prisma + .$transaction(async (tx) => { + // A template needs part types of the system, so it starts empty and + // is set with PATCH once the catalog exists. + const system = await tx.hiveSystem.create({ + data: { ...dto, template: EMPTY_TEMPLATE }, + }); + await this.auditLog.record( + { + entityType: 'HiveSystem', + entityId: system.id, + action: 'CREATE', + userId: actingUserId, + afterData: system, + }, + tx, + ); + return system; + }) + .catch((error: unknown) => conflictOnDuplicate(error, 'System')); + } + + async updateSystem( + id: string, + dto: UpdateHiveSystemDto, + actingUserId: string, + ): Promise { + return this.prisma + .$transaction(async (tx) => { + const before = await tx.hiveSystem.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('Hive system not found'); + if (dto.template !== undefined) { + await this.assertValidTemplate(id, dto.template, tx); + } + const { template, ...rest } = dto; + const system = await tx.hiveSystem.update({ + where: { id }, + data: { + ...rest, + ...(template !== undefined + ? { template: template as Prisma.InputJsonValue } + : {}), + }, + }); + await this.auditLog.record( + { + entityType: 'HiveSystem', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: system, + }, + tx, + ); + return system; + }) + .catch((error: unknown) => conflictOnDuplicate(error, 'System')); + } + + async createPartType( + systemId: string, + dto: CreateHivePartTypeDto, + actingUserId: string, + ): Promise { + if (dto.kind === 'BOX' && dto.frameSlots === undefined) { + throw new BadRequestException('frameSlots is required for a BOX'); + } + const stateSchema = dto.stateSchema ?? []; + this.assertSchemaShape(stateSchema); + return this.prisma + .$transaction(async (tx) => { + const system = await tx.hiveSystem.findUnique({ + where: { id: systemId }, + select: { id: true }, + }); + if (!system) throw new NotFoundException('Hive system not found'); + const partType = await tx.hivePartType.create({ + data: { + ...dto, + systemId, + stateSchema: stateSchema as Prisma.InputJsonValue, + }, + }); + await this.auditLog.record( + { + entityType: 'HivePartType', + entityId: partType.id, + action: 'CREATE', + userId: actingUserId, + afterData: partType, + }, + tx, + ); + return partType; + }) + .catch((error: unknown) => conflictOnDuplicate(error, 'Part type')); + } + + async updatePartType( + id: string, + dto: UpdateHivePartTypeDto, + actingUserId: string, + ): Promise { + return this.prisma + .$transaction(async (tx) => { + await lockRowForUpdate(tx, 'HivePartType', id); + const before = await tx.hivePartType.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('Hive part type not found'); + const { stateSchema, ...rest } = dto; + if (stateSchema !== undefined) { + this.assertSchemaShape(stateSchema); + this.assertAdditive(before.stateSchema, stateSchema); + } + const partType = await tx.hivePartType.update({ + where: { id }, + data: { + ...rest, + ...(stateSchema !== undefined + ? { stateSchema: stateSchema as Prisma.InputJsonValue } + : {}), + }, + }); + await this.auditLog.record( + { + entityType: 'HivePartType', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: partType, + }, + tx, + ); + return partType; + }) + .catch((error: unknown) => conflictOnDuplicate(error, 'Part type')); + } + + private assertSchemaShape(stateSchema: unknown): void { + const problems = stateSchemaProblems(stateSchema); + if (problems.length > 0) { + throw new BadRequestException({ + code: 'SCHEMA_INVALID', + messages: problems, + }); + } + } + + private assertAdditive(current: unknown, next: unknown): void { + if (!isAdditiveSchema(parseStateSchema(current), parseStateSchema(next))) { + throw new BadRequestException({ + code: 'SCHEMA_NOT_ADDITIVE', + message: + 'The state schema may only append fields; existing fields stay unchanged', + }); + } + } + + private async assertValidTemplate( + systemId: string, + template: unknown, + tx: Prisma.TransactionClient, + ): Promise { + const catalog = await this.loadCatalog(systemId, tx); + const messages = validateHiveState(stampTemplate(template), catalog); + if (messages.length > 0) { + throw new BadRequestException({ code: 'TEMPLATE_INVALID', messages }); + } + } +} diff --git a/ApiCore-Backend/src/hive/hive-errors.ts b/ApiCore-Backend/src/hive/hive-errors.ts new file mode 100644 index 0000000..216cd6a --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-errors.ts @@ -0,0 +1,44 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import type { HiveState } from './hive-types'; + +/** 409: an operation failed against the current state (spec 6.2). */ +export class HiveConflictException extends ConflictException { + constructor( + opIndex: number, + reason: string, + current: { version: number; state: HiveState }, + ) { + super({ + statusCode: 409, + code: 'HIVE_CONFLICT', + message: 'Die Änderung passt nicht mehr zum aktuellen Aufbau.', + opIndex, + reason, + current, + }); + } +} + +export function hiveNotFound(): NotFoundException { + return new NotFoundException({ + statusCode: 404, + code: 'HIVE_NOT_FOUND', + message: 'Für dieses Volk gibt es noch keinen Aufbau.', + }); +} + +export function hiveExists(): ConflictException { + return new ConflictException({ + statusCode: 409, + code: 'HIVE_EXISTS', + message: 'Für dieses Volk gibt es schon einen Aufbau.', + }); +} + +export function invalidDay(field: string): BadRequestException { + return new BadRequestException(`${field} must be a valid YYYY-MM-DD day`); +} diff --git a/ApiCore-Backend/src/hive/hive-facts.spec.ts b/ApiCore-Backend/src/hive/hive-facts.spec.ts new file mode 100644 index 0000000..a6e1e5e --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-facts.spec.ts @@ -0,0 +1,118 @@ +import { deriveHiveFacts } from './hive-facts'; +import type { FrameType, HivePart, HiveState } from './hive-types'; +import { loadFixtureCatalog } from './hive-test-utils'; + +const T = (slug: string) => `hive-part-zl10-${slug}`; +const catalog = loadFixtureCatalog(); + +const makeBox = (types: FrameType[]): HivePart => ({ + id: 'b1', + partTypeId: T('box-10'), + state: {}, + frames: types.map((type, i) => ({ slot: i + 1, type })), +}); + +const stackOf = (...parts: HivePart[]): HiveState => ({ + schemaVersion: 1, + stack: [{ id: 'f1', partTypeId: T('floor'), state: {} }, ...parts], +}); + +describe('deriveHiveFacts', () => { + it('derives zero facts for the system template', () => { + const state = stackOf( + makeBox(Array(10).fill('FOUNDATION')), + { id: 'c1', partTypeId: T('cover-abdeckfix'), state: {} }, + { id: 'l1', partTypeId: T('inner-lid'), state: {} }, + { id: 'i1', partTypeId: T('insulation'), state: {} }, + { id: 'r1', partTypeId: T('roof'), state: {} }, + ); + const facts = deriveHiveFacts(state, catalog); + expect(facts.boxCount).toBe(1); + expect(facts.broodFrames).toBe(0); + expect(facts.honeyFrames).toBe(0); + expect(facts.builtFrames).toBe(0); + expect(facts.hasFloorInsert).toBe(false); + expect(facts.evaporatorAcidMl).toBeNull(); + }); + + it('counts brood, honey and built frames', () => { + const types: FrameType[] = [ + ...Array(6).fill('BROOD'), + 'HONEY', + 'HONEY', + 'DIVIDER', + 'NONE', + ]; + const facts = deriveHiveFacts(stackOf(makeBox(types)), catalog); + expect(facts.broodFrames).toBe(6); + expect(facts.honeyFrames).toBe(2); + expect(facts.builtFrames).toBe(8); + }); + + it('reports a feeder level with its feed type', () => { + const state = stackOf({ + id: 'fd1', + partTypeId: T('feeder'), + state: { syrupLiters: 2.5 }, + }); + const facts = deriveHiveFacts(state, catalog); + const syrup = facts.levels.filter((l) => l.key === 'syrupLiters'); + expect(syrup).toEqual([ + { + partId: 'fd1', + key: 'syrupLiters', + label: 'Flüssigfutter', + unit: 'l', + value: 2.5, + max: 3, + feedType: 'SUGAR_WATER', + }, + ]); + // Missing level fields count as 0. + expect(facts.levels.find((l) => l.key === 'fondantKg')?.value).toBe(0); + }); + + it('detects a floor insert and reads evaporator acid', () => { + const state: HiveState = { + schemaVersion: 1, + stack: [ + { + id: 'f1', + partTypeId: T('floor'), + state: {}, + inserts: [{ id: 'fi1', partTypeId: T('floor-insert'), state: {} }], + }, + { + id: 'e1', + partTypeId: T('box-empty'), + state: {}, + frames: [], + inserts: [ + { id: 'ev1', partTypeId: T('evaporator'), state: { acidMl: 250 } }, + ], + }, + ], + }; + const facts = deriveHiveFacts(state, catalog); + expect(facts.hasFloorInsert).toBe(true); + expect(facts.evaporatorAcidMl).toBe(250); + expect(facts.boxCount).toBe(1); + }); + + it('reports 0 ml for an evaporator without acid state', () => { + const state: HiveState = { + schemaVersion: 1, + stack: [ + { id: 'f1', partTypeId: T('floor'), state: {} }, + { + id: 'e1', + partTypeId: T('box-empty'), + state: {}, + frames: [], + inserts: [{ id: 'ev1', partTypeId: T('evaporator'), state: {} }], + }, + ], + }; + expect(deriveHiveFacts(state, catalog).evaporatorAcidMl).toBe(0); + }); +}); diff --git a/ApiCore-Backend/src/hive/hive-facts.ts b/ApiCore-Backend/src/hive/hive-facts.ts new file mode 100644 index 0000000..5f85659 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-facts.ts @@ -0,0 +1,60 @@ +import type { + HiveCatalog, + HiveFacts, + HiveLevelFact, + HivePart, + HiveState, +} from './hive-types'; + +const UNBUILT_FRAME_TYPES = new Set(['NONE', 'FOUNDATION', 'DIVIDER']); + +function allParts(state: HiveState): HivePart[] { + return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]); +} + +function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] { + const type = catalog.partTypes.get(part.partTypeId); + if (!type) return []; + return type.stateSchema.flatMap((field) => { + if (field.kind !== 'level') return []; + const raw = part.state[field.key]; + return [ + { + partId: part.id, + key: field.key, + label: field.label, + unit: field.unit, + value: typeof raw === 'number' ? raw : 0, + max: field.max, + ...(field.feedType ? { feedType: field.feedType } : {}), + }, + ]; + }); +} + +/** Pure derivation of display facts (spec 5.3). Never written back to a colony. */ +export function deriveHiveFacts( + state: HiveState, + catalog: HiveCatalog, +): HiveFacts { + const parts = allParts(state); + const kindOf = (part: HivePart) => + catalog.partTypes.get(part.partTypeId)?.kind; + const frames = state.stack + .filter((part) => kindOf(part) === 'BOX') + .flatMap((part) => part.frames ?? []); + const evaporators = parts.filter((part) => kindOf(part) === 'EVAPORATOR'); + const acid = evaporators.reduce((sum, part) => { + const value = part.state.acidMl; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + return { + boxCount: state.stack.filter((part) => kindOf(part) === 'BOX').length, + broodFrames: frames.filter((f) => f.type === 'BROOD').length, + honeyFrames: frames.filter((f) => f.type === 'HONEY').length, + builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length, + levels: parts.flatMap((part) => levelsOf(part, catalog)), + hasFloorInsert: parts.some((part) => kindOf(part) === 'FLOOR_INSERT'), + evaporatorAcidMl: evaporators.length > 0 ? acid : null, + }; +} diff --git a/ApiCore-Backend/src/hive/hive-ops.spec.ts b/ApiCore-Backend/src/hive/hive-ops.spec.ts new file mode 100644 index 0000000..5d88030 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-ops.spec.ts @@ -0,0 +1,114 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { applyHiveOps } from './hive-ops'; +import { validateHiveState } from './hive-validate'; +import type { HiveOp, HiveState } from './hive-types'; +import { FIXTURE_DIR, deepFreeze, loadFixtureCatalog } from './hive-test-utils'; + +interface FixtureCase { + name: string; + inactivePartTypeIds?: string[]; + state: HiveState; + ops: HiveOp[]; + expect: + | { ok: true; state: HiveState } + | { ok: false; opIndex: number; reasonCode: string }; +} + +const cases = readdirSync(FIXTURE_DIR) + .filter((file) => file.endsWith('.json') && file !== 'catalog.json') + .sort() + .map((file) => ({ + file, + data: JSON.parse( + readFileSync(join(FIXTURE_DIR, file), 'utf8'), + ) as FixtureCase, + })); + +function findCase(name: string): FixtureCase { + const found = cases.find((c) => c.data.name === name); + if (!found) throw new Error(`fixture not found: ${name}`); + return found.data; +} + +describe('applyHiveOps fixtures', () => { + it('has at least 20 cases', () => { + expect(cases.length).toBeGreaterThanOrEqual(20); + }); + + it.each(cases.map((c) => [c.file, c.data] as const))('%s', (_file, data) => { + const catalog = loadFixtureCatalog(data.inactivePartTypeIds); + const result = applyHiveOps( + deepFreeze(structuredClone(data.state)), + deepFreeze(structuredClone(data.ops)), + catalog, + ); + if (data.expect.ok) { + expect(result).toEqual({ ok: true, state: data.expect.state }); + // The expected end state must itself be valid. + expect(validateHiveState(data.expect.state, catalog)).toEqual([]); + } else { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.opIndex).toBe(data.expect.opIndex); + expect(result.reason.split(':')[0]).toBe(data.expect.reasonCode); + } + } + }); +}); + +describe('applyHiveOps purity', () => { + it('does not mutate a deep-frozen input state or ops', () => { + const catalog = loadFixtureCatalog(); + const sample = findCase('multiple ops apply in order'); + const state = deepFreeze(structuredClone(sample.state)); + const ops = deepFreeze(structuredClone(sample.ops)); + const before = JSON.stringify(state); + const result = applyHiveOps(state, ops, catalog); + expect(result.ok).toBe(true); + expect(JSON.stringify(state)).toBe(before); + }); + + it('does not share references between input and result', () => { + const catalog = loadFixtureCatalog(); + const state = structuredClone( + findCase('setLabel sets and clears a label').state, + ); + const result = applyHiveOps(state, [], catalog); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.state).not.toBe(state); + expect(result.state.stack[0]).not.toBe(state.stack[0]); + } + }); +}); + +describe('validateHiveState', () => { + const catalog = loadFixtureCatalog(); + + it('rejects non-objects and wrong schemaVersion', () => { + expect(validateHiveState(null, catalog).length).toBeGreaterThan(0); + expect( + validateHiveState({ schemaVersion: 2, stack: [] }, catalog).length, + ).toBeGreaterThan(0); + }); + + it('collects several violations', () => { + const state = { + schemaVersion: 1, + stack: [ + { id: 'a', partTypeId: 'hive-part-zl10-roof', state: {} }, + { id: 'a', partTypeId: 'hive-part-zl10-floor', state: { x: 1 } }, + ], + }; + const codes = validateHiveState(state, catalog).map((r) => r.split(':')[0]); + expect(codes).toEqual( + expect.arrayContaining([ + 'DUPLICATE_ID', + 'RULE_FLOOR_BOTTOM', + 'RULE_ROOF_TOP', + 'STATE_UNKNOWN_KEY', + ]), + ); + }); +}); diff --git a/ApiCore-Backend/src/hive/hive-ops.ts b/ApiCore-Backend/src/hive/hive-ops.ts new file mode 100644 index 0000000..f2c1083 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-ops.ts @@ -0,0 +1,244 @@ +import { MAX_OPS } from './hive-types'; +import type { + HiveApplyResult, + HiveCatalog, + HiveFrame, + HiveOp, + HivePart, + HiveState, +} from './hive-types'; +import { validateHiveState } from './hive-validate'; + +interface Location { + part: HivePart; + list: HivePart[]; + index: number; + isInsert: boolean; +} + +/** Result of one op on the working copy: a reason string, or null on success. */ +type OpOutcome = string | null; + +function locate(state: HiveState, id: unknown): Location | null { + for (let index = 0; index < state.stack.length; index++) { + const part = state.stack[index]; + if (part.id === id) { + return { part, list: state.stack, index, isInsert: false }; + } + const inserts = part.inserts ?? []; + for (let i = 0; i < inserts.length; i++) { + if (inserts[i].id === id) { + return { part: inserts[i], list: inserts, index: i, isInsert: true }; + } + } + } + return null; +} + +function missing(id: unknown): string { + return `MISSING_ID: no part with id ${String(id)}`; +} + +function clamp(value: unknown, max: number): number { + if (typeof value !== 'number' || Number.isNaN(value)) return max; + return Math.min(Math.max(Math.trunc(value), 0), max); +} + +/** Catalog membership and active check for a part to be added (and its inserts). */ +function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome { + if (typeof part !== 'object' || part === null) { + return 'MISSING_ID: part is not an object'; + } + const type = catalog.partTypes.get(part.partTypeId); + if (!type) { + return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`; + } + if (!type.active) { + return `INACTIVE_PART_TYPE: ${type.id} is inactive`; + } + for (const insert of part.inserts ?? []) { + const nested = checkAddable(insert, catalog); + if (nested) return nested; + } + return null; +} + +/** Deep copy of an incoming part without empty `inserts` arrays. */ +function normalisedCopy(part: HivePart): HivePart { + const copy = structuredClone(part); + if (Array.isArray(copy.inserts)) { + if (copy.inserts.length === 0) delete copy.inserts; + else copy.inserts = copy.inserts.map(normalisedCopy); + } + return copy; +} + +function applyAddPart( + state: HiveState, + op: Extract, + catalog: HiveCatalog, +): OpOutcome { + const problem = checkAddable(op.part, catalog); + if (problem) return problem; + const at = clamp(op.at, state.stack.length); + state.stack.splice(at, 0, normalisedCopy(op.part)); + return null; +} + +function applyAddInsert( + state: HiveState, + op: Extract, + catalog: HiveCatalog, +): OpOutcome { + const host = locate(state, op.hostId); + if (!host || host.isInsert) return missing(op.hostId); + const problem = checkAddable(op.part, catalog); + if (problem) return problem; + host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)]; + return null; +} + +function applyRemovePart( + state: HiveState, + op: Extract, +): OpOutcome { + const found = locate(state, op.id); + if (!found) return missing(op.id); + found.list.splice(found.index, 1); + if (found.isInsert) { + const host = state.stack.find((p) => p.inserts === found.list); + if (host && found.list.length === 0) delete host.inserts; + } + return null; +} + +function applyMovePart( + state: HiveState, + op: Extract, +): OpOutcome { + const found = locate(state, op.id); + if (!found) return missing(op.id); + if (found.isInsert) { + return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`; + } + const [moved] = state.stack.splice(found.index, 1); + state.stack.splice(clamp(op.to, state.stack.length), 0, moved); + return null; +} + +function applySetState( + state: HiveState, + op: Extract, +): OpOutcome { + const found = locate(state, op.id); + if (!found) return missing(op.id); + found.part.state = { ...found.part.state, [op.key]: op.value }; + return null; +} + +function locateBox(state: HiveState, boxId: unknown): Location | string { + const found = locate(state, boxId); + if (!found) return missing(boxId); + if (found.isInsert || !Array.isArray(found.part.frames)) { + return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`; + } + return found; +} + +function applySetFrame( + state: HiveState, + op: Extract, +): OpOutcome { + const box = locateBox(state, op.boxId); + if (typeof box === 'string') return box; + const frames = box.part.frames as HiveFrame[]; + if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) { + return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`; + } + // name: undefined keeps the current name, null removes it. + const current = frames[op.slot - 1]; + const name = op.name === undefined ? current.name : (op.name ?? undefined); + frames[op.slot - 1] = + name === undefined + ? { slot: op.slot, type: op.type } + : { slot: op.slot, type: op.type, name }; + return null; +} + +function applySetFrames( + state: HiveState, + op: Extract, +): OpOutcome { + const box = locateBox(state, op.boxId); + if (typeof box === 'string') return box; + box.part.frames = structuredClone(op.frames); + return null; +} + +function applySetLabel( + state: HiveState, + op: Extract, +): OpOutcome { + const found = locate(state, op.id); + if (!found) return missing(op.id); + if (op.label === null) { + delete found.part.label; + } else { + found.part.label = op.label; + } + return null; +} + +function applyOne( + state: HiveState, + op: HiveOp, + catalog: HiveCatalog, +): OpOutcome { + switch (op?.op) { + case 'addPart': + return applyAddPart(state, op, catalog); + case 'addInsert': + return applyAddInsert(state, op, catalog); + case 'removePart': + return applyRemovePart(state, op); + case 'movePart': + return applyMovePart(state, op); + case 'setState': + return applySetState(state, op); + case 'setFrame': + return applySetFrame(state, op); + case 'setFrames': + return applySetFrames(state, op); + case 'setLabel': + return applySetLabel(state, op); + default: + return 'UNKNOWN_OP: unsupported operation'; + } +} + +/** + * Applies ops in order and validates the whole state after each one. + * Never mutates its inputs: it works on a private deep copy. + */ +export function applyHiveOps( + state: HiveState, + ops: HiveOp[], + catalog: HiveCatalog, +): HiveApplyResult { + if (ops.length > MAX_OPS) { + return { + ok: false, + opIndex: MAX_OPS, + reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`, + }; + } + const working = structuredClone(state); + for (let opIndex = 0; opIndex < ops.length; opIndex++) { + const failure = + applyOne(working, ops[opIndex], catalog) ?? + validateHiveState(working, catalog)[0] ?? + null; + if (failure) return { ok: false, opIndex, reason: failure }; + } + return { ok: true, state: working }; +} diff --git a/ApiCore-Backend/src/hive/hive-state-schema.ts b/ApiCore-Backend/src/hive/hive-state-schema.ts new file mode 100644 index 0000000..29d9ab9 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-state-schema.ts @@ -0,0 +1,121 @@ +import { FeedType } from '../../generated/prisma'; +import type { StateField } from './hive-types'; + +const MAX_KEY = 40; +const MAX_LABEL = 60; +const MAX_UNIT = 10; +const MAX_OPTION = 40; +const FEED_TYPES: readonly string[] = Object.values(FeedType); + +type Obj = Record; + +const isObj = (value: unknown): value is Obj => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isPositive = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0; + +const isStringList = (value: unknown): value is string[] => + Array.isArray(value) && value.every((x) => typeof x === 'string'); + +function levelProblem(field: Obj, where: string): string | null { + if (typeof field.unit !== 'string' || field.unit.length > MAX_UNIT) { + return `${where}.unit must be a string of at most ${MAX_UNIT} characters`; + } + if (!isPositive(field.max)) return `${where}.max must be positive`; + if (!isPositive(field.step)) return `${where}.step must be positive`; + if ( + field.feedType !== undefined && + !(typeof field.feedType === 'string' && FEED_TYPES.includes(field.feedType)) + ) { + return `${where}.feedType must be one of ${FEED_TYPES.join('/')}`; + } + return null; +} + +function choiceProblem(field: Obj, where: string): string | null { + const { options, optionLabels } = field; + if (!isStringList(options) || options.length === 0) { + return `${where}.options must be a non-empty list of strings`; + } + if (new Set(options).size !== options.length) { + return `${where}.options must be unique`; + } + if (!isStringList(optionLabels) || optionLabels.length !== options.length) { + return `${where}.optionLabels must match options`; + } + if ([...options, ...optionLabels].some((text) => text.length > MAX_OPTION)) { + return `${where} options and labels must have at most ${MAX_OPTION} characters`; + } + return null; +} + +function fieldProblem(field: unknown, index: number): string | null { + const where = `stateSchema[${index}]`; + if (!isObj(field)) return `${where} must be an object`; + if ( + typeof field.key !== 'string' || + field.key === '' || + field.key.length > MAX_KEY + ) { + return `${where}.key must be 1-${MAX_KEY} characters`; + } + if ( + typeof field.label !== 'string' || + field.label === '' || + field.label.length > MAX_LABEL + ) { + return `${where}.label must be 1-${MAX_LABEL} characters`; + } + if (field.kind === 'level') return levelProblem(field, where); + if (field.kind === 'choice') return choiceProblem(field, where); + return `${where}.kind must be level or choice`; +} + +/** Returns the problems of a raw stateSchema value (empty list means valid). */ +export function stateSchemaProblems(raw: unknown): string[] { + if (!Array.isArray(raw)) return ['stateSchema must be a list']; + const problems: string[] = []; + const keys = new Set(); + raw.forEach((field: unknown, index) => { + const problem = fieldProblem(field, index); + if (problem) { + problems.push(problem); + return; + } + const key = (field as Obj).key as string; + if (keys.has(key)) problems.push(`duplicate key ${key}`); + keys.add(key); + }); + return problems; +} + +/** Casts a stored JSON value after the shape check; throws when malformed. */ +export function parseStateSchema(raw: unknown): StateField[] { + const problems = stateSchemaProblems(raw); + if (problems.length > 0) { + throw new Error(`Malformed stateSchema: ${problems.join('; ')}`); + } + return raw as StateField[]; +} + +/** Stable JSON text: object keys sorted, so key order does not matter. */ +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (isObj(value)) { + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +/** True when `next` keeps every field of `current` unchanged and only appends. */ +export function isAdditiveSchema( + current: StateField[], + next: StateField[], +): boolean { + if (next.length < current.length) return false; + return current.every((field, i) => canonical(field) === canonical(next[i])); +} diff --git a/ApiCore-Backend/src/hive/hive-test-utils.ts b/ApiCore-Backend/src/hive/hive-test-utils.ts new file mode 100644 index 0000000..14c88b8 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-test-utils.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { catalogFromRows } from './hive-types'; +import type { CatalogPartType, HiveCatalog } from './hive-types'; + +export const FIXTURE_DIR = join(__dirname, '../../test/fixtures/hive-ops'); + +interface CatalogFile { + systemId: string; + partTypes: CatalogPartType[]; +} + +/** Loads the shared catalog fixture; `inactiveIds` marks part types inactive. */ +export function loadFixtureCatalog(inactiveIds: string[] = []): HiveCatalog { + const file = JSON.parse( + readFileSync(join(FIXTURE_DIR, 'catalog.json'), 'utf8'), + ) as CatalogFile; + const rows = file.partTypes.map((row) => ({ + ...row, + active: !inactiveIds.includes(row.id), + })); + return catalogFromRows(rows, file.systemId); +} + +export function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + Object.values(value as Record).forEach((child) => + deepFreeze(child), + ); + } + return value; +} diff --git a/ApiCore-Backend/src/hive/hive-types.ts b/ApiCore-Backend/src/hive/hive-types.ts new file mode 100644 index 0000000..f396b7a --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-types.ts @@ -0,0 +1,156 @@ +// Pure domain types for the hive setup (spec 5.2, 6.1). No framework or +// Prisma imports: the frontend mirrors this module. + +export type HivePartKind = + | 'FLOOR' + | 'BOX' + | 'DIVIDER_BOARD' + | 'QUEEN_EXCLUDER' + | 'FEEDER' + | 'COVER_SHEET' + | 'PROPOLIS_SCREEN' + | 'INNER_LID' + | 'INSULATION' + | 'ROOF' + | 'FLOOR_INSERT' + | 'EVAPORATOR'; + +// Mirrors the Prisma enum HiveBoxDepth; used from Task 3 on. +export type HiveBoxDepth = 'FULL' | 'SHALLOW'; + +export type FrameType = + | 'BROOD' + | 'DRONE' + | 'HONEY' + | 'POLLEN' + | 'FEED' + | 'FOUNDATION' + | 'EMPTY_COMB' + | 'DRONE_FRAME' + | 'DIVIDER' + | 'NONE'; + +export const FRAME_TYPES: readonly FrameType[] = [ + 'BROOD', + 'DRONE', + 'HONEY', + 'POLLEN', + 'FEED', + 'FOUNDATION', + 'EMPTY_COMB', + 'DRONE_FRAME', + 'DIVIDER', + 'NONE', +]; + +export const MAX_STACK_PARTS = 30; +export const MAX_OPS = 100; +export const MAX_TEXT_LENGTH = 40; +export const LEVEL_TOLERANCE = 1e-9; + +export interface HiveFrame { + slot: number; + type: FrameType; + name?: string; +} + +export interface HivePart { + id: string; + partTypeId: string; + label?: string; + state: Record; + inserts?: HivePart[]; + frames?: HiveFrame[]; +} + +export interface HiveState { + schemaVersion: 1; + stack: HivePart[]; +} + +export type HiveOp = + | { op: 'addPart'; part: HivePart; at: number } + | { op: 'addInsert'; hostId: string; part: HivePart } + | { op: 'removePart'; id: string } + | { op: 'movePart'; id: string; to: number } + | { op: 'setState'; id: string; key: string; value: number | string } + | { + op: 'setFrame'; + boxId: string; + slot: number; + type: FrameType; + name?: string | null; + } + | { op: 'setFrames'; boxId: string; frames: HiveFrame[] } + | { op: 'setLabel'; id: string; label: string | null }; + +export interface LevelField { + key: string; + label: string; + kind: 'level'; + unit: string; + max: number; + step: number; + feedType?: string; +} + +export interface ChoiceField { + key: string; + label: string; + kind: 'choice'; + options: string[]; + optionLabels: string[]; +} + +export type StateField = LevelField | ChoiceField; + +export interface CatalogPartType { + id: string; + kind: HivePartKind; + name: string; + lengthMm: number; + widthMm: number; + heightMm: number; + frameSlots: number | null; + stateSchema: StateField[]; + active: boolean; +} + +export interface HiveCatalog { + systemId: string; + partTypes: Map; +} + +export function catalogFromRows( + rows: CatalogPartType[], + systemId: string, +): HiveCatalog { + return { + systemId, + partTypes: new Map(rows.map((row) => [row.id, row])), + }; +} + +export interface HiveLevelFact { + partId: string; + key: string; + label: string; + unit: string; + value: number; + max: number; + feedType?: string; +} + +export interface HiveFacts { + boxCount: number; + broodFrames: number; + honeyFrames: number; + builtFrames: number; + levels: HiveLevelFact[]; + hasFloorInsert: boolean; + evaporatorAcidMl: number | null; +} + +export type HiveApplyResult = + | { ok: true; state: HiveState } + | { ok: false; opIndex: number; reason: string }; diff --git a/ApiCore-Backend/src/hive/hive-validate.ts b/ApiCore-Backend/src/hive/hive-validate.ts new file mode 100644 index 0000000..f437c45 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive-validate.ts @@ -0,0 +1,269 @@ +import { + FRAME_TYPES, + LEVEL_TOLERANCE, + MAX_STACK_PARTS, + MAX_TEXT_LENGTH, +} from './hive-types'; +import type { CatalogPartType, HiveCatalog, StateField } from './hive-types'; + +// Check order decides which code is reported first; the frontend port must +// keep it: root shape (schemaVersion must be 1 -> STATE_OUT_OF_RANGE; extra +// root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per stack part: id, unknown +// fields (UNKNOWN_FIELD), type, label, state, frames (per frame: slot, unknown +// fields, type, name), inserts (each insert in the same order), then FLOOR, then ROOF. +// Text lengths are UTF-16 code units (`.length`); the frontend must do the same. +const ROOT_KEYS = ['schemaVersion', 'stack']; +const PART_KEYS = ['id', 'partTypeId', 'label', 'state', 'inserts', 'frames']; +const FRAME_KEYS = ['slot', 'type', 'name']; + +function unknownFields( + obj: Record, + allowed: string[], + where: string, + out: string[], +): void { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`); + } +} + +type Obj = Record; + +const isObj = (value: unknown): value is Obj => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Checks one state value against its schema field; returns a reason or null. */ +export function checkStateValue( + field: StateField, + value: unknown, + where: string, +): string | null { + if (field.kind === 'choice') { + if (typeof value !== 'string' || !field.options.includes(value)) { + return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join('/')}`; + } + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + return `STATE_OUT_OF_RANGE: ${where} must be a number`; + } + if (value < 0 || value > field.max) { + return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`; + } + const steps = value / field.step; + if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) { + return `STATE_STEP: ${where} must be a multiple of ${field.step}`; + } + return null; +} + +function validateState( + part: Obj, + type: CatalogPartType, + where: string, + out: string[], +): void { + if (!isObj(part.state)) { + out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`); + return; + } + for (const [key, value] of Object.entries(part.state)) { + const field = type.stateSchema.find((f) => f.key === key); + if (!field) { + out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`); + continue; + } + const problem = checkStateValue(field, value, `${where}.${key}`); + if (problem) out.push(problem); + } +} + +function validateFrames( + part: Obj, + type: CatalogPartType, + where: string, + out: string[], +): void { + const slots = type.kind === 'BOX' ? (type.frameSlots ?? 0) : 0; + const frames = part.frames; + if (type.kind !== 'BOX') { + if ( + frames !== undefined && + !(Array.isArray(frames) && frames.length === 0) + ) { + out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`); + } + return; + } + const list = frames === undefined && slots === 0 ? [] : frames; + if (!Array.isArray(list) || list.length !== slots) { + out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`); + return; + } + list.forEach((frame: unknown, index) => { + if (!isObj(frame) || frame.slot !== index + 1) { + out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`); + return; + } + unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out); + if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) { + out.push( + `STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`, + ); + } + if (frame.name !== undefined) { + if (typeof frame.name !== 'string') { + out.push( + `STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`, + ); + } else if (frame.name.length > MAX_TEXT_LENGTH) { + out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`); + } + } + }); +} + +interface WalkContext { + catalog: HiveCatalog; + seenIds: Set; + out: string[]; +} + +/** Checks id, catalog membership, label, state, frames. Returns the type or null. */ +function validatePartCommon( + part: unknown, + where: string, + ctx: WalkContext, +): { part: Obj; type: CatalogPartType } | null { + const { out } = ctx; + if (!isObj(part)) { + out.push(`MISSING_ID: ${where} is not an object`); + return null; + } + if (typeof part.id !== 'string' || part.id === '') { + out.push(`MISSING_ID: ${where} has no id`); + } else if (ctx.seenIds.has(part.id)) { + out.push(`DUPLICATE_ID: ${part.id}`); + } else { + ctx.seenIds.add(part.id); + } + unknownFields(part, PART_KEYS, where, out); + const type = + typeof part.partTypeId === 'string' + ? ctx.catalog.partTypes.get(part.partTypeId) + : undefined; + if (!type) { + out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`); + return null; + } + if (part.label !== undefined) { + if (typeof part.label !== 'string') { + out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`); + } else if (part.label.length > MAX_TEXT_LENGTH) { + out.push(`LIMIT_TEXT: ${where} label is too long`); + } + } + validateState(part, type, where, out); + validateFrames(part, type, where, out); + return { part, type }; +} + +function validateInserts( + host: Obj, + hostType: CatalogPartType, + where: string, + ctx: WalkContext, +): void { + const { out } = ctx; + const inserts = host.inserts; + if (inserts === undefined) return; + if (!Array.isArray(inserts)) { + out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`); + return; + } + const allowed = + hostType.kind === 'FLOOR' + ? 'FLOOR_INSERT' + : hostType.kind === 'BOX' + ? 'EVAPORATOR' + : null; + if (inserts.length > 0 && allowed === null) { + out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`); + } + if (allowed !== null && inserts.length > 1) { + out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`); + } + inserts.forEach((insert: unknown, index) => { + const here = `${where}.inserts[${index}]`; + const checked = validatePartCommon(insert, here, ctx); + if (!checked) return; + if (allowed !== null && checked.type.kind !== allowed) { + out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`); + } + if (checked.part.inserts !== undefined) { + const nested = checked.part.inserts; + if (!Array.isArray(nested) || nested.length > 0) { + out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`); + } + } + }); +} + +function validateStackRules( + kinds: (CatalogPartType | null)[], + out: string[], +): void { + const floors = kinds.filter((t) => t?.kind === 'FLOOR').length; + if (floors !== 1 || kinds[0]?.kind !== 'FLOOR') { + out.push( + 'RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom', + ); + } + const roofIndexes = kinds + .map((t, i) => (t?.kind === 'ROOF' ? i : -1)) + .filter((i) => i >= 0); + if ( + roofIndexes.length > 1 || + (roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1) + ) { + out.push('RULE_ROOF_TOP: the roof must be the single top part'); + } +} + +/** Validates a whole hive state. Empty result means valid. */ +export function validateHiveState( + state: unknown, + catalog: HiveCatalog, +): string[] { + const out: string[] = []; + if ( + !isObj(state) || + state.schemaVersion !== 1 || + !Array.isArray(state.stack) + ) { + return ['STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list']; + } + unknownFields(state, ROOT_KEYS, 'state', out); + if (state.stack.length > MAX_STACK_PARTS) { + out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`); + } + const ctx: WalkContext = { catalog, seenIds: new Set(), out }; + const types: (CatalogPartType | null)[] = []; + state.stack.forEach((entry: unknown, index) => { + const where = `stack[${index}]`; + const checked = validatePartCommon(entry, where, ctx); + types.push(checked?.type ?? null); + if (!checked) return; + if ( + checked.type.kind === 'FLOOR_INSERT' || + checked.type.kind === 'EVAPORATOR' + ) { + out.push( + `RULE_INSERT_HOST: ${where} must be an insert, not a stack part`, + ); + } + validateInserts(checked.part, checked.type, where, ctx); + }); + validateStackRules(types, out); + return out; +} diff --git a/ApiCore-Backend/src/hive/hive.controller.ts b/ApiCore-Backend/src/hive/hive.controller.ts new file mode 100644 index 0000000..49eeba1 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateHiveChangeDto } from './dto/create-hive-change.dto'; +import { CreateHiveSetupDto } from './dto/create-hive-setup.dto'; +import { HiveAtQueryDto, HiveHistoryQueryDto } from './dto/hive-queries.dto'; +import { HiveService } from './hive.service'; +import type { HiveOp } from './hive-types'; + +@ApiTags('hive') +@ApiBearerAuth() +@Controller('colonies/:id/hive') +export class HiveController { + constructor( + private readonly hive: HiveService, + private readonly prisma: PrismaService, + ) {} + + @RequirePermission('colonies:read') + @Get() + get(@Param('id') id: string) { + return this.hive.get(id); + } + + @RequirePermission('colonies:update') + @Idempotent() + @Post() + create( + @Param('id') id: string, + @Body() dto: CreateHiveSetupDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.hive.create(id, dto, actor.id); + } + + @RequirePermission('colonies:update') + @Idempotent() + @Post('changes') + applyChange( + @Param('id') id: string, + @Body() dto: CreateHiveChangeDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + // Plain objects only: the DTO instances carry class metadata. + const ops = JSON.parse(JSON.stringify(dto.ops)) as HiveOp[]; + return this.prisma.$transaction((tx) => + this.hive.applyChange( + tx, + id, + { + occurredOn: dto.occurredOn, + source: dto.source, + ops, + note: dto.note, + }, + actor.id, + ), + ); + } + + @RequirePermission('colonies:read') + @Get('changes') + history(@Param('id') id: string, @Query() query: HiveHistoryQueryDto) { + return this.hive.history(id, query.cursor, query.limit); + } + + @RequirePermission('colonies:read') + @Get('at') + at(@Param('id') id: string, @Query() query: HiveAtQueryDto) { + return this.hive.at(id, query.day); + } +} diff --git a/ApiCore-Backend/src/hive/hive.module.ts b/ApiCore-Backend/src/hive/hive.module.ts new file mode 100644 index 0000000..2125057 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { HiveCatalogController } from './hive-catalog.controller'; +import { HiveCatalogService } from './hive-catalog.service'; +import { HiveController } from './hive.controller'; +import { HiveService } from './hive.service'; + +@Module({ + controllers: [HiveCatalogController, HiveController], + providers: [HiveCatalogService, HiveService], + exports: [HiveCatalogService, HiveService], +}) +export class HiveModule {} diff --git a/ApiCore-Backend/src/hive/hive.service.spec.ts b/ApiCore-Backend/src/hive/hive.service.spec.ts new file mode 100644 index 0000000..80eb278 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive.service.spec.ts @@ -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(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); + }); + }); +}); diff --git a/ApiCore-Backend/src/hive/hive.service.ts b/ApiCore-Backend/src/hive/hive.service.ts new file mode 100644 index 0000000..b6f3b52 --- /dev/null +++ b/ApiCore-Backend/src/hive/hive.service.ts @@ -0,0 +1,457 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { HiveChangeSource, Prisma } from '../../generated/prisma'; +import { berlinToday, dayToDate } from '../common/dates'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import { + HiveConflictException, + hiveExists, + hiveNotFound, + invalidDay, +} from './hive-errors'; +import { deriveHiveFacts } from './hive-facts'; +import { HiveCatalogService } from './hive-catalog.service'; +import { canonicalOp } from './hive-canonical'; +import { applyHiveOps } from './hive-ops'; +import type { HiveFacts, HiveOp, HiveState } from './hive-types'; +import { validateHiveState } from './hive-validate'; + +export interface HiveSetupView { + colonyId: string; + systemId: string; + version: number; + state: HiveState; + facts: HiveFacts; + updatedAt: string; +} + +export interface HiveChangeListItem { + id: string; + version: number; + occurredOn: string; + source: HiveChangeSource; + inspectionId: string | null; + userId: string; + userName: string; + ops: unknown; + note: string | null; + createdAt: string; +} + +export interface ApplyChangeInput { + occurredOn: string; + source: HiveChangeSource; + ops: HiveOp[]; + note?: string; + inspectionId?: string; +} + +export interface CreateSetupInput { + systemId: string; + state: unknown; + occurredOn: string; +} + +const DEFAULT_HISTORY_LIMIT = 20; +const MAX_HISTORY_LIMIT = 100; + +interface HistoryCursor { + occurredOn: string; + version: number; +} + +const isoDay = (date: Date): string => date.toISOString().slice(0, 10); + +function parseDay(day: unknown, field: string): Date { + if (typeof day !== 'string') throw invalidDay(field); + try { + return dayToDate(day); + } catch { + throw invalidDay(field); + } +} + +/** A day (YYYY-MM-DD) that is valid and not after today in Berlin. */ +function parseOccurredOn(day: unknown): Date { + const date = parseDay(day, 'occurredOn'); + if (isoDay(date) > berlinToday()) { + throw new BadRequestException('occurredOn must not be in the future'); + } + return date; +} + +function encodeCursor(cursor: HistoryCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString('base64url'); +} + +function decodeCursor(raw: string): HistoryCursor { + try { + const value: unknown = JSON.parse( + Buffer.from(raw, 'base64url').toString('utf8'), + ); + const { occurredOn, version } = value as Partial; + if ( + typeof occurredOn === 'string' && + typeof version === 'number' && + Number.isInteger(version) + ) { + dayToDate(occurredOn); + return { occurredOn, version }; + } + } catch { + // falls through to the 400 below + } + throw new BadRequestException('cursor is not valid'); +} + +/** INIT and AI_SUGGESTION are not accepted here; an inspection id goes with INSPECTION only. */ +function assertSourceFits(input: ApplyChangeInput): void { + if (input.source === 'INIT' || input.source === 'AI_SUGGESTION') { + throw new BadRequestException(`source ${input.source} is not allowed`); + } + if ((input.source === 'INSPECTION') !== (input.inspectionId !== undefined)) { + throw new BadRequestException( + 'inspectionId is required for, and only allowed with, source INSPECTION', + ); + } +} + +@Injectable() +export class HiveService { + constructor( + private readonly prisma: PrismaService, + private readonly events: DomainEventsService, + private readonly catalogs: HiveCatalogService, + ) {} + + async get(colonyId: string): Promise { + const setup = await this.prisma.hiveSetup.findUnique({ + where: { colonyId }, + }); + if (!setup) throw hiveNotFound(); + return this.view(setup); + } + + async create( + colonyId: string, + input: CreateSetupInput, + userId: string, + ): Promise { + const occurredOn = parseOccurredOn(input.occurredOn); + return this.prisma.$transaction(async (tx) => { + await this.lockLivingColony(tx, colonyId); + await lockRowForUpdate(tx, 'HiveSetup', colonyId); + const existing = await tx.hiveSetup.findUnique({ where: { colonyId } }); + if (existing) throw hiveExists(); + + const catalog = await this.loadActiveSystemCatalog(tx, input.systemId); + const messages = validateHiveState(input.state, catalog); + if (messages.length > 0) { + throw new BadRequestException({ code: 'STATE_INVALID', messages }); + } + const state = input.state as HiveState; + + const setup = await tx.hiveSetup.create({ + data: { + colonyId, + systemId: input.systemId, + version: 1, + state: state as unknown as Prisma.InputJsonValue, + }, + }); + await tx.hiveChange.create({ + data: { + colonyId, + version: 1, + occurredOn, + source: 'INIT', + userId, + ops: [], + stateAfter: state as unknown as Prisma.InputJsonValue, + note: null, + }, + }); + await this.recordChanged(tx, colonyId, 1, 'INIT', null, userId); + return { + colonyId, + systemId: setup.systemId, + version: setup.version, + state, + facts: deriveHiveFacts(state, catalog), + updatedAt: setup.updatedAt.toISOString(), + }; + }); + } + + /** + * Applies ops to the current state under the Colony and HiveSetup locks and + * writes the change plus the event in the caller's transaction (also used + * by the inspection flow). + */ + async applyChange( + tx: Prisma.TransactionClient, + colonyId: string, + input: ApplyChangeInput, + userId: string, + ): Promise { + assertSourceFits(input); + const occurredOn = parseOccurredOn(input.occurredOn); + const ops = input.ops.map(canonicalOp); + await this.lockLivingColony(tx, colonyId); + if (!(await lockRowForUpdate(tx, 'HiveSetup', colonyId))) { + throw hiveNotFound(); + } + const setup = await tx.hiveSetup.findUnique({ where: { colonyId } }); + if (!setup) throw hiveNotFound(); + await this.assertNotBeforeInit(tx, colonyId, occurredOn); + + const catalog = await this.catalogs.loadCatalog(setup.systemId, tx); + const current = setup.state as unknown as HiveState; + const result = applyHiveOps(current, ops, catalog); + if (!result.ok) { + throw new HiveConflictException(result.opIndex, result.reason, { + version: setup.version, + state: current, + }); + } + + const version = setup.version + 1; + const stateJson = result.state as unknown as Prisma.InputJsonValue; + await tx.hiveChange.create({ + data: { + colonyId, + version, + occurredOn, + source: input.source, + inspectionId: input.inspectionId ?? null, + userId, + ops: ops as unknown as Prisma.InputJsonValue, + stateAfter: stateJson, + note: input.note ?? null, + }, + }); + const updated = await tx.hiveSetup.update({ + where: { colonyId }, + data: { version, state: stateJson }, + }); + await this.recordChanged( + tx, + colonyId, + version, + input.source, + input.inspectionId ?? null, + userId, + ); + return { + colonyId, + systemId: updated.systemId, + version, + state: result.state, + facts: deriveHiveFacts(result.state, catalog), + updatedAt: updated.updatedAt.toISOString(), + }; + } + + async history( + colonyId: string, + cursor?: string, + limit = DEFAULT_HISTORY_LIMIT, + ): Promise<{ items: HiveChangeListItem[]; nextCursor: string | null }> { + const take = Math.min(Math.max(Math.trunc(limit), 1), MAX_HISTORY_LIMIT); + const after = cursor === undefined ? null : decodeCursor(cursor); + await this.assertSetupExists(colonyId); + + const rows = await this.prisma.hiveChange.findMany({ + where: { + colonyId, + ...(after + ? { + OR: [ + { occurredOn: { lt: dayToDate(after.occurredOn) } }, + { + occurredOn: dayToDate(after.occurredOn), + version: { lt: after.version }, + }, + ], + } + : {}), + }, + orderBy: [{ occurredOn: 'desc' }, { version: 'desc' }], + take: take + 1, + select: { + id: true, + version: true, + occurredOn: true, + source: true, + inspectionId: true, + userId: true, + user: { select: { name: true } }, + ops: true, + note: true, + createdAt: true, + }, + }); + + const page = rows.slice(0, take); + const last = page[page.length - 1]; + const nextCursor = + rows.length > take && last + ? encodeCursor({ + occurredOn: isoDay(last.occurredOn), + version: last.version, + }) + : null; + return { + items: page.map((row) => ({ + id: row.id, + version: row.version, + occurredOn: isoDay(row.occurredOn), + source: row.source, + inspectionId: row.inspectionId, + userId: row.userId, + userName: row.user.name, + ops: row.ops, + note: row.note, + createdAt: row.createdAt.toISOString(), + })), + nextCursor, + }; + } + + /** The state at the end of a Berlin calendar day (spec 6.2). */ + async at( + colonyId: string, + day: string, + ): Promise<{ + version: number; + state: HiveState; + facts: HiveFacts; + occurredOn: string; + }> { + const dayDate = parseDay(day, 'day'); + const setup = await this.prisma.hiveSetup.findUnique({ + where: { colonyId }, + select: { systemId: true }, + }); + if (!setup) throw hiveNotFound(); + const change = await this.prisma.hiveChange.findFirst({ + where: { colonyId, occurredOn: { lte: dayDate } }, + orderBy: { version: 'desc' }, + select: { version: true, occurredOn: true, stateAfter: true }, + }); + if (!change) throw hiveNotFound(); + const catalog = await this.catalogs.loadCatalog(setup.systemId); + const state = change.stateAfter as unknown as HiveState; + return { + version: change.version, + state, + facts: deriveHiveFacts(state, catalog), + occurredOn: isoDay(change.occurredOn), + }; + } + + private async view(setup: { + colonyId: string; + systemId: string; + version: number; + state: Prisma.JsonValue; + updatedAt: Date; + }): Promise { + const catalog = await this.catalogs.loadCatalog(setup.systemId); + const state = setup.state as unknown as HiveState; + return { + colonyId: setup.colonyId, + systemId: setup.systemId, + version: setup.version, + state, + facts: deriveHiveFacts(state, catalog), + updatedAt: setup.updatedAt.toISOString(), + }; + } + + /** Lock order: Colony first; a colony that no longer lives is read-only. */ + private async lockLivingColony( + tx: Prisma.TransactionClient, + colonyId: string, + ): Promise { + if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) { + throw new NotFoundException('Colony not found'); + } + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { status: true }, + }); + if (!colony) throw new NotFoundException('Colony not found'); + if (!isLivingColonyStatus(colony.status)) { + throw new ConflictException( + `Das Volk besteht nicht mehr (Status: ${colony.status})`, + ); + } + } + + private async loadActiveSystemCatalog( + tx: Prisma.TransactionClient, + systemId: string, + ) { + const system = await tx.hiveSystem.findUnique({ + where: { id: systemId }, + select: { id: true, active: true }, + }); + if (!system) throw new NotFoundException('Hive system not found'); + if (!system.active) { + throw new BadRequestException({ + code: 'SYSTEM_INACTIVE', + message: 'Das Beutensystem ist ausgeblendet.', + }); + } + return this.catalogs.loadCatalog(systemId, tx); + } + + private async assertNotBeforeInit( + tx: Prisma.TransactionClient, + colonyId: string, + occurredOn: Date, + ): Promise { + const init = await tx.hiveChange.findUnique({ + where: { colonyId_version: { colonyId, version: 1 } }, + select: { occurredOn: true }, + }); + if (init && occurredOn < init.occurredOn) { + throw new BadRequestException( + 'occurredOn must not be before the day the hive setup was created', + ); + } + } + + private async assertSetupExists(colonyId: string): Promise { + const setup = await this.prisma.hiveSetup.findUnique({ + where: { colonyId }, + select: { colonyId: true }, + }); + if (!setup) throw hiveNotFound(); + } + + private recordChanged( + tx: Prisma.TransactionClient, + colonyId: string, + version: number, + source: HiveChangeSource, + inspectionId: string | null, + userId: string, + ): Promise { + return this.events.record(tx, { + type: 'HiveChanged', + aggregateType: 'Colony', + aggregateId: colonyId, + actorUserId: userId, + payload: { colonyId, version, source, inspectionId }, + }); + } +} diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts index 02e5c24..5ba2ca1 100644 --- a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMaxSize, + ArrayMinSize, ArrayUnique, IsArray, IsBoolean, @@ -13,15 +14,28 @@ import { IsString, Max, Min, + ValidateNested, } from 'class-validator'; import { COLONY_TAKEOVER_FIELDS, type ColonyTakeoverField, } from '../../colonies/colony-takeover'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; +import { HiveOpDto, MAX_OPS_PER_REQUEST } from '../../hive/dto/hive-op.dto'; import { ColonyCondition } from '../../../generated/prisma'; import { feedConditionFromInput } from '../../common/feed-vocabulary'; +/** Hive operations carried by an inspection (create only). */ +export class InspectionHiveChangeDto { + @ApiProperty({ type: [HiveOpDto], maxItems: MAX_OPS_PER_REQUEST }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(MAX_OPS_PER_REQUEST) + @ValidateNested({ each: true }) + @Type(() => HiveOpDto) + ops!: HiveOpDto[]; +} + export class CreateInspectionDto { @ApiProperty() @IsString() @@ -172,4 +186,14 @@ export class CreateInspectionDto { @ArrayMaxSize(3) @IsIn(COLONY_TAKEOVER_FIELDS, { each: true }) applyToColony?: ColonyTakeoverField[]; + + @ApiPropertyOptional({ + type: InspectionHiveChangeDto, + description: + 'Hive operations saved together with the inspection in one transaction. Needs colonies:update.', + }) + @IsOptional() + @ValidateNested() + @Type(() => InspectionHiveChangeDto) + hiveChange?: InspectionHiveChangeDto; } diff --git a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts index 1313cbd..58d9108 100644 --- a/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/update-inspection.dto.ts @@ -8,6 +8,6 @@ import { CreateInspectionDto } from './create-inspection.dto'; * not part of a correction: a correction takes nothing over to the colony. */ export class UpdateInspectionDto extends PartialType( - OmitType(CreateInspectionDto, ['applyToColony'] as const), + OmitType(CreateInspectionDto, ['applyToColony', 'hiveChange'] as const), { skipNullProperties: false }, ) {} diff --git a/ApiCore-Backend/src/inspections/inspections.module.ts b/ApiCore-Backend/src/inspections/inspections.module.ts index 8c13a0e..41cc250 100644 --- a/ApiCore-Backend/src/inspections/inspections.module.ts +++ b/ApiCore-Backend/src/inspections/inspections.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TasksModule } from '../tasks/tasks.module'; import { ColoniesModule } from '../colonies/colonies.module'; +import { HiveModule } from '../hive/hive.module'; import { InspectionsController } from './inspections.controller'; import { ColonyInspectionsController } from './colony-inspections.controller'; import { InspectionsService } from './inspections.service'; @@ -11,7 +12,7 @@ import { InspectionsService } from './inspections.service'; * PermissionResolverService comes from the global resolver module. */ @Module({ - imports: [TasksModule, ColoniesModule], + imports: [TasksModule, ColoniesModule, HiveModule], controllers: [InspectionsController, ColonyInspectionsController], providers: [InspectionsService], exports: [InspectionsService], diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index d48f353..37e32ad 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -6,6 +6,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service'; import { TasksService } from '../tasks/tasks.service'; import { ColoniesService } from '../colonies/colonies.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { HiveService } from '../hive/hive.service'; import { Prisma } from '../../generated/prisma'; describe('InspectionsService', () => { @@ -35,6 +36,7 @@ describe('InspectionsService', () => { let tasks: { createInTx: jest.Mock }; let colonies: { updateIn: jest.Mock }; let resolver: { getPermissions: jest.Mock }; + let hive: { applyChange: jest.Mock }; const inspection = { id: 'inspection-1', @@ -74,6 +76,7 @@ describe('InspectionsService', () => { resolver = { getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])), }; + hive = { applyChange: jest.fn().mockResolvedValue({ version: 2 }) }; service = new InspectionsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, @@ -81,6 +84,7 @@ describe('InspectionsService', () => { tasks as unknown as TasksService, colonies as unknown as ColoniesService, resolver as unknown as PermissionResolverService, + hive as unknown as HiveService, ); }); @@ -94,6 +98,7 @@ describe('InspectionsService', () => { TasksService, ColoniesService, PermissionResolverService, + HiveService, ]); }); @@ -314,6 +319,81 @@ describe('InspectionsService', () => { ).rejects.toThrow('outbox down'); }); + describe('hiveChange', () => { + const ops = [{ op: 'setLabel', id: 'box', label: 'x' }]; + const dto = { + colonyId: 'colony-1', + date: '2026-03-10T10:00:00.000Z', + hiveChange: { ops }, + } as never; + + beforeEach(() => { + tx.inspection.create.mockResolvedValue(inspection); + }); + + it('locks the colony, saves the inspection, then applies the ops with the inspection id', async () => { + const result = await service.create(dto, 'user-1'); + + const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0]; + expect(lockOrder).toBeLessThan( + tx.inspection.create.mock.invocationCallOrder[0], + ); + const [[created]] = tx.inspection.create.mock.calls as [ + [{ data: object }], + ]; + expect(created.data).not.toHaveProperty('hiveChange'); + expect(hive.applyChange).toHaveBeenCalledWith( + tx, + 'colony-1', + { + occurredOn: '2026-03-10', + source: 'INSPECTION', + ops, + inspectionId: 'inspection-1', + }, + 'user-1', + ); + expect(result).toMatchObject({ + id: 'inspection-1', + hive: { version: 2 }, + }); + }); + + it('answers 403 HIVE_FORBIDDEN without colonies:update and writes nothing', async () => { + resolver.getPermissions.mockResolvedValue(new Set()); + + await expect(service.create(dto, 'user-1')).rejects.toMatchObject({ + status: 403, + response: { code: 'HIVE_FORBIDDEN' }, + }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('resolves permissions once when hiveChange and applyToColony are both used', async () => { + await service.create( + { ...(dto as object), applyToColony: ['feedStatus'] } as never, + 'user-1', + ); + expect(resolver.getPermissions).toHaveBeenCalledTimes(1); + }); + + it('propagates an applyChange failure so the transaction aborts', async () => { + hive.applyChange.mockRejectedValue(new NotFoundException()); + await expect(service.create(dto, 'user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('omits hive from the response without hiveChange', async () => { + const result = await service.create( + { colonyId: 'colony-1', date: '2026-01-01' }, + 'user-1', + ); + expect(result).not.toHaveProperty('hive'); + expect(hive.applyChange).not.toHaveBeenCalled(); + }); + }); + it('update writes the correction, audit entry and InspectionCorrected in one transaction', async () => { tx.inspection.findUnique.mockResolvedValue(inspection); // Re-filed against another colony: the event names the colony it is diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index 6243f5f..b03c61f 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { isLivingColonyStatus } from '../colonies/colony-status'; import { PrismaService } from '../prisma/prisma.service'; import { DbClient } from '../prisma/db-client'; @@ -18,9 +22,14 @@ import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; import { UpdateInspectionDto } from './dto/update-inspection.dto'; import { optionalDate } from '../common/patch-values'; +import { berlinDayOf } from '../common/dates'; +import type { HiveOp } from '../hive/hive-types'; +import { HiveService, HiveSetupView } from '../hive/hive.service'; export type InspectionWithTakeover = Inspection & { colonyTakeover: ColonyTakeover; + /** Present only when the request carried a hiveChange. */ + hive?: HiveSetupView; }; /** @@ -41,6 +50,7 @@ export class InspectionsService { private readonly tasks: TasksService, private readonly colonies: ColoniesService, private readonly permissionResolver: PermissionResolverService, + private readonly hiveService: HiveService, ) {} async findAll(): Promise { @@ -62,16 +72,25 @@ export class InspectionsService { dto: CreateInspectionDto, actingUserId: string, ): Promise { - const { applyToColony = [], ...data } = dto; - // Resolved before the transaction (it may query the database): the - // permission is only needed when a takeover was requested. + const { applyToColony = [], hiveChange, ...data } = dto; + // Resolved before the transaction (it may query the database), once for + // both features: a takeover only applies with colonies:update, a hive + // change is refused without it. + const needsPermission = + applyToColony.length > 0 || hiveChange !== undefined; const canUpdateColony = - applyToColony.length > 0 && + needsPermission && (await this.permissionResolver.getPermissions(actingUserId)).has( 'colonies:update', ); + if (hiveChange && !canUpdateColony) { + throw new ForbiddenException({ + code: 'HIVE_FORBIDDEN', + message: 'Beute-Änderungen brauchen das Recht colonies:update.', + }); + } return this.prisma.$transaction(async (tx) => { - if (applyToColony.length > 0) { + if (applyToColony.length > 0 || hiveChange) { // Colony first (before the inspection row takes a key-share lock on // it): serializes concurrent inspections of one colony, so "is there // a newer inspection" below is decided on a stable state. @@ -104,7 +123,22 @@ export class InspectionsService { canUpdateColony, actingUserId, ); - return { ...inspection, colonyTakeover }; + if (!hiveChange) { + return { ...inspection, colonyTakeover }; + } + const hive = await this.hiveService.applyChange( + tx, + dto.colonyId, + { + occurredOn: berlinDayOf(new Date(dto.date)), + source: 'INSPECTION', + // Plain objects only: the DTO instances carry class metadata. + ops: JSON.parse(JSON.stringify(hiveChange.ops)) as HiveOp[], + inspectionId: inspection.id, + }, + actingUserId, + ); + return { ...inspection, colonyTakeover, hive }; }); } diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts index 956f3eb..fa235c4 100644 --- a/ApiCore-Backend/src/prisma/row-lock.spec.ts +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -226,10 +226,12 @@ describe('lock keys per table', () => { expect(tx.$queryRaw).not.toHaveBeenCalled(); }); - it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => { + it('keeps the map in lock order: Apiary before Colony, HiveSetup right after Colony, HivePartType after HiveSetup, the T6 tables last', () => { expect(LOCK_ORDER).toEqual([ 'Apiary', 'Colony', + 'HiveSetup', + 'HivePartType', 'Suggestion', 'Task', 'Queen', @@ -262,7 +264,10 @@ describe('lock keys per table', () => { it('every table of the map is lockable by id and each appears once', async () => { expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length); - const keyed = new Map([['NotificationPreference', 'userId']]); + const keyed = new Map([ + ['NotificationPreference', 'userId'], + ['HiveSetup', 'colonyId'], + ]); for (const table of LOCK_ORDER) { const tx = { $queryRaw: jest.fn().mockResolvedValue([]) }; diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts index ad8251f..7ee5f6b 100644 --- a/ApiCore-Backend/src/prisma/row-lock.ts +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -19,6 +19,11 @@ function lockable(table: string, key = 'id') { const LOCKABLE_TABLES = { Apiary: lockable('Apiary'), Colony: lockable('Colony'), + // Beute in 3D: one setup row per colony, keyed by the colony. Locked right + // after the colony row it belongs to. + HiveSetup: lockable('HiveSetup', 'colonyId'), + // Catalog part types: locked while their stateSchema is changed. + HivePartType: lockable('HivePartType'), Suggestion: lockable('Suggestion'), Task: lockable('Task'), Queen: lockable('Queen'), diff --git a/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts index 8fa7cbb..eb1b961 100644 --- a/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts @@ -87,12 +87,16 @@ describe('WebhookEventHandlers', () => { }); }); - it('none of the four new events is forwarded to webhooks', async () => { + it('none of the five new events is forwarded to webhooks', async () => { const herdEvents = [ ['ColonyCreated', { apiaryId: 'a1' }], ['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }], ['ApiaryChanged', { change: 'LOCATION_CHANGED' }], ['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }], + [ + 'HiveChanged', + { colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null }, + ], ] as const; for (const [type, payload] of herdEvents) { expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type); diff --git a/ApiCore-Backend/test/backup.e2e-spec.ts b/ApiCore-Backend/test/backup.e2e-spec.ts index e56b70f..1a90ec0 100644 --- a/ApiCore-Backend/test/backup.e2e-spec.ts +++ b/ApiCore-Backend/test/backup.e2e-spec.ts @@ -601,6 +601,156 @@ describe('Backup round trip (e2e)', () => { ).toBeNull(); }); + it( + 'restores hive setup and history into a fresh install with the seeded catalog', + async () => { + const systemId = 'hive-system-zander-liebig-10'; + const day = new Date('2026-07-02T00:00:00.000Z'); + const state = { schemaVersion: 1, stack: [{ id: 'p1', kind: 'FLOOR' }] }; + const apiary = await prisma.apiary.create({ + data: { name: `Hive-Stand ${suffix}` }, + }); + const colony = await prisma.colony.create({ + data: { + code: `HV-${suffix}`, + apiaryId: apiary.id, + colonyType: 'PRODUCTION_COLONY', + }, + }); + const inspection = await prisma.inspection.create({ + data: { colonyId: colony.id, userId, date: day }, + }); + await prisma.hiveSetup.create({ + data: { colonyId: colony.id, systemId, version: 2, state }, + }); + await prisma.hiveChange.createMany({ + data: [ + { + colonyId: colony.id, + version: 1, + occurredOn: day, + source: 'INIT', + userId, + ops: [], + stateAfter: state, + }, + { + colonyId: colony.id, + version: 2, + occurredOn: day, + source: 'INSPECTION', + inspectionId: inspection.id, + userId, + ops: [{ op: 'SET_NOTE' }], + stateAfter: state, + note: 'Beute', + }, + ], + }); + const catalogCounts = async () => ({ + systems: await prisma.hiveSystem.count(), + partTypes: await prisma.hivePartType.count(), + }); + const catalogBefore = await catalogCounts(); + expect(catalogBefore.systems).toBeGreaterThan(0); + expect(catalogBefore.partTypes).toBeGreaterThan(0); + const hiveRows = async () => ({ + setup: await prisma.hiveSetup.findUnique({ + where: { colonyId: colony.id }, + }), + changes: await prisma.hiveChange.findMany({ + where: { colonyId: colony.id }, + orderBy: { version: 'asc' }, + }), + }); + const before = await hiveRows(); + + try { + const bundle = await backup.exportAll(); + const ownColonyRows = (bundle.data.colony as Row[]).filter( + (row) => row.id === colony.id, + ); + const exportedHive = { + hiveSystem: bundle.data.hiveSystem as Row[], + hivePartType: bundle.data.hivePartType as Row[], + colony: ownColonyRows, + inspection: (bundle.data.inspection as Row[]).filter( + (row) => row.id === inspection.id, + ), + hiveSetup: (bundle.data.hiveSetup as Row[]).filter( + (row) => row.colonyId === colony.id, + ), + hiveChange: (bundle.data.hiveChange as Row[]).filter( + (row) => row.colonyId === colony.id, + ), + }; + expect(exportedHive.hiveSetup).toHaveLength(1); + expect(exportedHive.hiveChange).toHaveLength(2); + expect(exportedHive.hiveSystem.some((row) => row.id === systemId)).toBe( + true, + ); + + // The colony is lost; the catalog rows stay, as a fresh install has + // them from the migration. + await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } }); + await prisma.hiveSetup.delete({ where: { colonyId: colony.id } }); + await prisma.inspection.delete({ where: { id: inspection.id } }); + await prisma.colony.delete({ where: { id: colony.id } }); + + const summary = await backup.importAll( + { + version: 3, + exportedAt: new Date().toISOString(), + data: exportedHive, + }, + userId, + { trusted: true }, + ); + expect(summary.imported).toMatchObject({ + hiveSetup: 1, + hiveChange: 2, + }); + expect(await hiveRows()).toEqual(before); + expect(await catalogCounts()).toEqual(catalogBefore); + + // A second restore over an existing, diverged setup: the backup wins. + await prisma.hiveSetup.update({ + where: { colonyId: colony.id }, + data: { version: 9 }, + }); + await prisma.hiveChange.create({ + data: { + colonyId: colony.id, + version: 9, + occurredOn: day, + source: 'CARD', + userId, + ops: [], + stateAfter: state, + }, + }); + await backup.importAll( + { + version: 3, + exportedAt: new Date().toISOString(), + data: exportedHive, + }, + userId, + { trusted: true }, + ); + expect(await hiveRows()).toEqual(before); + expect(await catalogCounts()).toEqual(catalogBefore); + } finally { + await prisma.hiveChange.deleteMany({ where: { colonyId: colony.id } }); + await prisma.hiveSetup.deleteMany({ where: { colonyId: colony.id } }); + await prisma.inspection.deleteMany({ where: { colonyId: colony.id } }); + await prisma.colony.deleteMany({ where: { id: colony.id } }); + await prisma.apiary.deleteMany({ where: { id: apiary.id } }); + } + }, + ROUND_TRIP_TIMEOUT_MS, + ); + it('removes the file a restored document row replaced', async () => { const directory = path.join( path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'), diff --git a/ApiCore-Backend/test/fixtures/hive-ops/01-addpart-inserts-at-index.json b/ApiCore-Backend/test/fixtures/hive-ops/01-addpart-inserts-at-index.json new file mode 100644 index 0000000..e2724d2 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/01-addpart-inserts-at-index.json @@ -0,0 +1,146 @@ +{ + "name": "addPart inserts at index", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 2 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/02-addpart-at-beyond-end-is-clamped-to-end.json b/ApiCore-Backend/test/fixtures/hive-ops/02-addpart-at-beyond-end-is-clamped-to-end.json new file mode 100644 index 0000000..a1361ca --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/02-addpart-at-beyond-end-is-clamped-to-end.json @@ -0,0 +1,136 @@ +{ + "name": "addPart at beyond end is clamped to end", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 99 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/03-addpart-box-brings-its-own-frames.json b/ApiCore-Backend/test/fixtures/hive-ops/03-addpart-box-brings-its-own-frames.json new file mode 100644 index 0000000..3039bbd --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/03-addpart-box-brings-its-own-frames.json @@ -0,0 +1,136 @@ +{ + "name": "addPart box brings its own frames", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "EMPTY_COMB" + }, + { + "slot": 2, + "type": "EMPTY_COMB" + }, + { + "slot": 3, + "type": "EMPTY_COMB" + }, + { + "slot": 4, + "type": "EMPTY_COMB" + }, + { + "slot": 5, + "type": "EMPTY_COMB" + }, + { + "slot": 6, + "type": "EMPTY_COMB" + }, + { + "slot": 7, + "type": "EMPTY_COMB" + }, + { + "slot": 8, + "type": "EMPTY_COMB" + }, + { + "slot": 9, + "type": "EMPTY_COMB" + }, + { + "slot": 10, + "type": "EMPTY_COMB" + } + ] + }, + "at": 1 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "EMPTY_COMB" + }, + { + "slot": 2, + "type": "EMPTY_COMB" + }, + { + "slot": 3, + "type": "EMPTY_COMB" + }, + { + "slot": 4, + "type": "EMPTY_COMB" + }, + { + "slot": 5, + "type": "EMPTY_COMB" + }, + { + "slot": 6, + "type": "EMPTY_COMB" + }, + { + "slot": 7, + "type": "EMPTY_COMB" + }, + { + "slot": 8, + "type": "EMPTY_COMB" + }, + { + "slot": 9, + "type": "EMPTY_COMB" + }, + { + "slot": 10, + "type": "EMPTY_COMB" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/04-addinsert-floor-insert-into-floor.json b/ApiCore-Backend/test/fixtures/hive-ops/04-addinsert-floor-insert-into-floor.json new file mode 100644 index 0000000..f6a2f75 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/04-addinsert-floor-insert-into-floor.json @@ -0,0 +1,148 @@ +{ + "name": "addInsert floor insert into floor", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {}, + "inserts": [ + { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + ] + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/05-addinsert-evaporator-into-empty-box.json b/ApiCore-Backend/test/fixtures/hive-ops/05-addinsert-evaporator-into-empty-box.json new file mode 100644 index 0000000..3688b6f --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/05-addinsert-evaporator-into-empty-box.json @@ -0,0 +1,70 @@ +{ + "name": "addInsert evaporator into empty box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "e1", + "part": { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": { + "acidMl": 250 + } + } + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [], + "inserts": [ + { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": { + "acidMl": 250 + } + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/06-removepart-removes-a-stack-part.json b/ApiCore-Backend/test/fixtures/hive-ops/06-removepart-removes-a-stack-part.json new file mode 100644 index 0000000..3d795a0 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/06-removepart-removes-a-stack-part.json @@ -0,0 +1,89 @@ +{ + "name": "removePart removes a stack part", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "removePart", + "id": "b1" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/07-removepart-removes-an-insert.json b/ApiCore-Backend/test/fixtures/hive-ops/07-removepart-removes-an-insert.json new file mode 100644 index 0000000..59b1647 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/07-removepart-removes-an-insert.json @@ -0,0 +1,143 @@ +{ + "name": "removePart removes an insert", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {}, + "inserts": [ + { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + ] + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "removePart", + "id": "fi1" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/08-movepart-moves-within-the-stack.json b/ApiCore-Backend/test/fixtures/hive-ops/08-movepart-moves-within-the-stack.json new file mode 100644 index 0000000..2c9fd15 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/08-movepart-moves-within-the-stack.json @@ -0,0 +1,231 @@ +{ + "name": "movePart moves within the stack", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "movePart", + "id": "b2", + "to": 1 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/09-movepart-target-beyond-end-is-clamped.json b/ApiCore-Backend/test/fixtures/hive-ops/09-movepart-target-beyond-end-is-clamped.json new file mode 100644 index 0000000..de16131 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/09-movepart-target-beyond-end-is-clamped.json @@ -0,0 +1,137 @@ +{ + "name": "movePart target beyond end is clamped", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + } + ] + }, + "ops": [ + { + "op": "movePart", + "id": "b1", + "to": 50 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/10-setstate-sets-a-level.json b/ApiCore-Backend/test/fixtures/hive-ops/10-setstate-sets-a-level.json new file mode 100644 index 0000000..b2dbe92 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/10-setstate-sets-a-level.json @@ -0,0 +1,56 @@ +{ + "name": "setState sets a level", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 2.5 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": { + "syrupLiters": 2.5 + } + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/11-setstate-sets-a-choice.json b/ApiCore-Backend/test/fixtures/hive-ops/11-setstate-sets-a-choice.json new file mode 100644 index 0000000..4198742 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/11-setstate-sets-a-choice.json @@ -0,0 +1,140 @@ +{ + "name": "setState sets a choice", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "f1", + "key": "entrance", + "value": "NARROW" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": { + "entrance": "NARROW" + } + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/12-setstate-accepts-the-level-maximum.json b/ApiCore-Backend/test/fixtures/hive-ops/12-setstate-accepts-the-level-maximum.json new file mode 100644 index 0000000..72a884d --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/12-setstate-accepts-the-level-maximum.json @@ -0,0 +1,63 @@ +{ + "name": "setState accepts the level maximum", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 3 + }, + { + "op": "setState", + "id": "fd1", + "key": "fondantKg", + "value": 2.5 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": { + "syrupLiters": 3, + "fondantKg": 2.5 + } + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/13-setstate-on-an-insert.json b/ApiCore-Backend/test/fixtures/hive-ops/13-setstate-on-an-insert.json new file mode 100644 index 0000000..53bfef4 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/13-setstate-on-an-insert.json @@ -0,0 +1,72 @@ +{ + "name": "setState on an insert", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [], + "inserts": [ + { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "ev1", + "key": "acidMl", + "value": 290 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [], + "inserts": [ + { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": { + "acidMl": 290 + } + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/14-setframe-sets-type-and-name.json b/ApiCore-Backend/test/fixtures/hive-ops/14-setframe-sets-type-and-name.json new file mode 100644 index 0000000..ddc4ef2 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/14-setframe-sets-type-and-name.json @@ -0,0 +1,140 @@ +{ + "name": "setFrame sets type and name", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 3, + "type": "BROOD", + "name": "Königin" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "BROOD", + "name": "Königin" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/15-setframe-with-null-name-removes-the-name.json b/ApiCore-Backend/test/fixtures/hive-ops/15-setframe-with-null-name-removes-the-name.json new file mode 100644 index 0000000..bdc0ce1 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/15-setframe-with-null-name-removes-the-name.json @@ -0,0 +1,140 @@ +{ + "name": "setFrame with null name removes the name", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "BROOD", + "name": "x" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 3, + "type": "HONEY", + "name": null + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "HONEY" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/16-setframes-replaces-all-frames.json b/ApiCore-Backend/test/fixtures/hive-ops/16-setframes-replaces-all-frames.json new file mode 100644 index 0000000..9aca5e3 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/16-setframes-replaces-all-frames.json @@ -0,0 +1,178 @@ +{ + "name": "setFrames replaces all frames", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "BROOD" + }, + { + "slot": 2, + "type": "BROOD" + }, + { + "slot": 3, + "type": "BROOD" + }, + { + "slot": 4, + "type": "BROOD" + }, + { + "slot": 5, + "type": "BROOD" + }, + { + "slot": 6, + "type": "BROOD" + }, + { + "slot": 7, + "type": "BROOD" + }, + { + "slot": 8, + "type": "BROOD" + }, + { + "slot": 9, + "type": "BROOD" + }, + { + "slot": 10, + "type": "BROOD" + } + ] + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "BROOD" + }, + { + "slot": 2, + "type": "BROOD" + }, + { + "slot": 3, + "type": "BROOD" + }, + { + "slot": 4, + "type": "BROOD" + }, + { + "slot": 5, + "type": "BROOD" + }, + { + "slot": 6, + "type": "BROOD" + }, + { + "slot": 7, + "type": "BROOD" + }, + { + "slot": 8, + "type": "BROOD" + }, + { + "slot": 9, + "type": "BROOD" + }, + { + "slot": 10, + "type": "BROOD" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/17-setlabel-sets-and-clears-a-label.json b/ApiCore-Backend/test/fixtures/hive-ops/17-setlabel-sets-and-clears-a-label.json new file mode 100644 index 0000000..3c1b1d9 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/17-setlabel-sets-and-clears-a-label.json @@ -0,0 +1,148 @@ +{ + "name": "setLabel sets and clears a label", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "Honigraum 1" + }, + { + "op": "setLabel", + "id": "f1", + "label": "Boden" + }, + { + "op": "setLabel", + "id": "b1", + "label": null + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {}, + "label": "Boden" + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/18-multiple-ops-apply-in-order.json b/ApiCore-Backend/test/fixtures/hive-ops/18-multiple-ops-apply-in-order.json new file mode 100644 index 0000000..696e708 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/18-multiple-ops-apply-in-order.json @@ -0,0 +1,146 @@ +{ + "name": "multiple ops apply in order", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + "at": 1 + }, + { + "op": "addPart", + "part": { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + }, + "at": 2 + }, + { + "op": "setFrame", + "boxId": "b1", + "slot": 1, + "type": "POLLEN" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "POLLEN" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/19-empty-ops-return-the-state-unchanged.json b/ApiCore-Backend/test/fixtures/hive-ops/19-empty-ops-return-the-state-unchanged.json new file mode 100644 index 0000000..e6e9cb6 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/19-empty-ops-return-the-state-unchanged.json @@ -0,0 +1,131 @@ +{ + "name": "empty ops return the state unchanged", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/20-existing-parts-with-an-inactive-part-type-stay-valid.json b/ApiCore-Backend/test/fixtures/hive-ops/20-existing-parts-with-an-inactive-part-type-stay-valid.json new file mode 100644 index 0000000..bba4f9e --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/20-existing-parts-with-an-inactive-part-type-stay-valid.json @@ -0,0 +1,141 @@ +{ + "name": "existing parts with an inactive part type stay valid", + "inactivePartTypeIds": [ + "hive-part-zl10-roof" + ], + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "r1", + "label": "Dach" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {}, + "label": "Dach" + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/21-removing-the-floor-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/21-removing-the-floor-fails.json new file mode 100644 index 0000000..38fedfb --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/21-removing-the-floor-fails.json @@ -0,0 +1,76 @@ +{ + "name": "removing the floor fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "removePart", + "id": "f1" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FLOOR_BOTTOM" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/22-second-floor-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/22-second-floor-fails.json new file mode 100644 index 0000000..4cdbcbf --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/22-second-floor-fails.json @@ -0,0 +1,81 @@ +{ + "name": "second floor fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "f2", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + "at": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FLOOR_BOTTOM" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/23-part-below-the-floor-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/23-part-below-the-floor-fails.json new file mode 100644 index 0000000..197d43c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/23-part-below-the-floor-fails.json @@ -0,0 +1,81 @@ +{ + "name": "part below the floor fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 0 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FLOOR_BOTTOM" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/24-negative-at-is-clamped-to-0-and-then-fails-the-floor-rule.json b/ApiCore-Backend/test/fixtures/hive-ops/24-negative-at-is-clamped-to-0-and-then-fails-the-floor-rule.json new file mode 100644 index 0000000..7c590d9 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/24-negative-at-is-clamped-to-0-and-then-fails-the-floor-rule.json @@ -0,0 +1,81 @@ +{ + "name": "negative at is clamped to 0 and then fails the floor rule", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": -7 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_FLOOR_BOTTOM" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/25-part-above-the-roof-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/25-part-above-the-roof-fails.json new file mode 100644 index 0000000..1b7716c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/25-part-above-the-roof-fails.json @@ -0,0 +1,81 @@ +{ + "name": "part above the roof fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 3 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_ROOF_TOP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/26-at-beyond-the-end-is-clamped-and-fails-the-roof-rule.json b/ApiCore-Backend/test/fixtures/hive-ops/26-at-beyond-the-end-is-clamped-and-fails-the-roof-rule.json new file mode 100644 index 0000000..9bd792b --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/26-at-beyond-the-end-is-clamped-and-fails-the-roof-rule.json @@ -0,0 +1,81 @@ +{ + "name": "at beyond the end is clamped and fails the roof rule", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 99 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_ROOF_TOP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/27-second-roof-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/27-second-roof-fails.json new file mode 100644 index 0000000..b19dfce --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/27-second-roof-fails.json @@ -0,0 +1,81 @@ +{ + "name": "second roof fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "r2", + "partTypeId": "hive-part-zl10-roof", + "state": {} + }, + "at": 3 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_ROOF_TOP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/28-floor-insert-cannot-sit-in-the-stack.json b/ApiCore-Backend/test/fixtures/hive-ops/28-floor-insert-cannot-sit-in-the-stack.json new file mode 100644 index 0000000..a74b18c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/28-floor-insert-cannot-sit-in-the-stack.json @@ -0,0 +1,81 @@ +{ + "name": "floor insert cannot sit in the stack", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + }, + "at": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/29-evaporator-cannot-sit-in-the-stack.json b/ApiCore-Backend/test/fixtures/hive-ops/29-evaporator-cannot-sit-in-the-stack.json new file mode 100644 index 0000000..15491e3 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/29-evaporator-cannot-sit-in-the-stack.json @@ -0,0 +1,81 @@ +{ + "name": "evaporator cannot sit in the stack", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + }, + "at": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/30-evaporator-cannot-go-into-a-floor.json b/ApiCore-Backend/test/fixtures/hive-ops/30-evaporator-cannot-go-into-a-floor.json new file mode 100644 index 0000000..f0646e7 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/30-evaporator-cannot-go-into-a-floor.json @@ -0,0 +1,81 @@ +{ + "name": "evaporator cannot go into a floor", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/31-floor-insert-cannot-go-into-a-box.json b/ApiCore-Backend/test/fixtures/hive-ops/31-floor-insert-cannot-go-into-a-box.json new file mode 100644 index 0000000..51dd97c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/31-floor-insert-cannot-go-into-a-box.json @@ -0,0 +1,81 @@ +{ + "name": "floor insert cannot go into a box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/32-feeder-cannot-be-an-insert.json b/ApiCore-Backend/test/fixtures/hive-ops/32-feeder-cannot-be-an-insert.json new file mode 100644 index 0000000..edd994a --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/32-feeder-cannot-be-an-insert.json @@ -0,0 +1,81 @@ +{ + "name": "feeder cannot be an insert", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "b1", + "part": { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/33-only-one-floor-insert-per-floor.json b/ApiCore-Backend/test/fixtures/hive-ops/33-only-one-floor-insert-per-floor.json new file mode 100644 index 0000000..c95a496 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/33-only-one-floor-insert-per-floor.json @@ -0,0 +1,88 @@ +{ + "name": "only one floor insert per floor", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {}, + "inserts": [ + { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + ] + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "fi2", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_LIMIT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/34-only-one-evaporator-per-box.json b/ApiCore-Backend/test/fixtures/hive-ops/34-only-one-evaporator-per-box.json new file mode 100644 index 0000000..bd907d5 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/34-only-one-evaporator-per-box.json @@ -0,0 +1,47 @@ +{ + "name": "only one evaporator per box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [], + "inserts": [ + { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "e1", + "part": { + "id": "ev2", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_LIMIT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/35-movepart-cannot-move-an-insert.json b/ApiCore-Backend/test/fixtures/hive-ops/35-movepart-cannot-move-an-insert.json new file mode 100644 index 0000000..86c6315 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/35-movepart-cannot-move-an-insert.json @@ -0,0 +1,84 @@ +{ + "name": "movePart cannot move an insert", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {}, + "inserts": [ + { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + ] + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "movePart", + "id": "fi1", + "to": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_INSERT_HOST" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/36-movepart-past-the-roof-fails-the-roof-rule.json b/ApiCore-Backend/test/fixtures/hive-ops/36-movepart-past-the-roof-fails-the-roof-rule.json new file mode 100644 index 0000000..3dfc6a0 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/36-movepart-past-the-roof-fails-the-roof-rule.json @@ -0,0 +1,77 @@ +{ + "name": "movePart past the roof fails the roof rule", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "movePart", + "id": "b1", + "to": 99 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "RULE_ROOF_TOP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/37-setstate-with-unknown-key.json b/ApiCore-Backend/test/fixtures/hive-ops/37-setstate-with-unknown-key.json new file mode 100644 index 0000000..6cea127 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/37-setstate-with-unknown-key.json @@ -0,0 +1,78 @@ +{ + "name": "setState with unknown key", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "f1", + "key": "nope", + "value": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_UNKNOWN_KEY" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/38-setstate-level-above-max.json b/ApiCore-Backend/test/fixtures/hive-ops/38-setstate-level-above-max.json new file mode 100644 index 0000000..ebc0252 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/38-setstate-level-above-max.json @@ -0,0 +1,36 @@ +{ + "name": "setState level above max", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 3.5 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/39-setstate-level-below-zero.json b/ApiCore-Backend/test/fixtures/hive-ops/39-setstate-level-below-zero.json new file mode 100644 index 0000000..57dbac9 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/39-setstate-level-below-zero.json @@ -0,0 +1,36 @@ +{ + "name": "setState level below zero", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": -0.5 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/40-setstate-level-off-step.json b/ApiCore-Backend/test/fixtures/hive-ops/40-setstate-level-off-step.json new file mode 100644 index 0000000..b2d8841 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/40-setstate-level-off-step.json @@ -0,0 +1,36 @@ +{ + "name": "setState level off step", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 0.3 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_STEP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/41-setstate-evaporator-off-step-of-10.json b/ApiCore-Backend/test/fixtures/hive-ops/41-setstate-evaporator-off-step-of-10.json new file mode 100644 index 0000000..5d521b0 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/41-setstate-evaporator-off-step-of-10.json @@ -0,0 +1,44 @@ +{ + "name": "setState evaporator off step of 10", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [], + "inserts": [ + { + "id": "ev1", + "partTypeId": "hive-part-zl10-evaporator", + "state": {} + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "ev1", + "key": "acidMl", + "value": 255 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_STEP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/42-setstate-choice-not-in-options.json b/ApiCore-Backend/test/fixtures/hive-ops/42-setstate-choice-not-in-options.json new file mode 100644 index 0000000..fd5a7b9 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/42-setstate-choice-not-in-options.json @@ -0,0 +1,78 @@ +{ + "name": "setState choice not in options", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "f1", + "key": "entrance", + "value": "HALF" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/43-setstate-string-on-a-level.json b/ApiCore-Backend/test/fixtures/hive-ops/43-setstate-string-on-a-level.json new file mode 100644 index 0000000..0f0f788 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/43-setstate-string-on-a-level.json @@ -0,0 +1,36 @@ +{ + "name": "setState string on a level", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": "2" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/44-setstate-number-on-a-choice.json b/ApiCore-Backend/test/fixtures/hive-ops/44-setstate-number-on-a-choice.json new file mode 100644 index 0000000..76fbe7b --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/44-setstate-number-on-a-choice.json @@ -0,0 +1,78 @@ +{ + "name": "setState number on a choice", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "f1", + "key": "entrance", + "value": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/45-setstate-on-missing-id.json b/ApiCore-Backend/test/fixtures/hive-ops/45-setstate-on-missing-id.json new file mode 100644 index 0000000..5f586d0 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/45-setstate-on-missing-id.json @@ -0,0 +1,78 @@ +{ + "name": "setState on missing id", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "zzz", + "key": "entrance", + "value": "OPEN" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/46-addpart-with-unknown-state-key.json b/ApiCore-Backend/test/fixtures/hive-ops/46-addpart-with-unknown-state-key.json new file mode 100644 index 0000000..32f9d62 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/46-addpart-with-unknown-state-key.json @@ -0,0 +1,83 @@ +{ + "name": "addPart with unknown state key", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": { + "foo": 1 + } + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_UNKNOWN_KEY" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/47-setframes-with-too-few-frames.json b/ApiCore-Backend/test/fixtures/hive-ops/47-setframes-with-too-few-frames.json new file mode 100644 index 0000000..0091bda --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/47-setframes-with-too-few-frames.json @@ -0,0 +1,114 @@ +{ + "name": "setFrames with too few frames", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + } + ] + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/48-setframes-with-too-many-frames.json b/ApiCore-Backend/test/fixtures/hive-ops/48-setframes-with-too-many-frames.json new file mode 100644 index 0000000..bca66fd --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/48-setframes-with-too-many-frames.json @@ -0,0 +1,122 @@ +{ + "name": "setFrames with too many frames", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + }, + { + "slot": 11, + "type": "FOUNDATION" + } + ] + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/49-setframes-with-wrong-slot-numbers.json b/ApiCore-Backend/test/fixtures/hive-ops/49-setframes-with-wrong-slot-numbers.json new file mode 100644 index 0000000..0691542 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/49-setframes-with-wrong-slot-numbers.json @@ -0,0 +1,118 @@ +{ + "name": "setFrames with wrong slot numbers", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 1, + "type": "FOUNDATION" + } + ] + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/50-setframe-slot-above-frameslots.json b/ApiCore-Backend/test/fixtures/hive-ops/50-setframe-slot-above-frameslots.json new file mode 100644 index 0000000..226d75d --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/50-setframe-slot-above-frameslots.json @@ -0,0 +1,78 @@ +{ + "name": "setFrame slot above frameSlots", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 11, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/51-setframe-slot-zero.json b/ApiCore-Backend/test/fixtures/hive-ops/51-setframe-slot-zero.json new file mode 100644 index 0000000..36e4040 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/51-setframe-slot-zero.json @@ -0,0 +1,78 @@ +{ + "name": "setFrame slot zero", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 0, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/52-setframe-on-a-non-box.json b/ApiCore-Backend/test/fixtures/hive-ops/52-setframe-on-a-non-box.json new file mode 100644 index 0000000..721ed8f --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/52-setframe-on-a-non-box.json @@ -0,0 +1,78 @@ +{ + "name": "setFrame on a non-box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "f1", + "slot": 1, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/53-setframe-on-a-box-with-zero-slots.json b/ApiCore-Backend/test/fixtures/hive-ops/53-setframe-on-a-box-with-zero-slots.json new file mode 100644 index 0000000..02fd367 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/53-setframe-on-a-box-with-zero-slots.json @@ -0,0 +1,37 @@ +{ + "name": "setFrame on a box with zero slots", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "e1", + "partTypeId": "hive-part-zl10-box-empty", + "state": {}, + "frames": [] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "e1", + "slot": 1, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/54-setframe-with-unknown-frame-type.json b/ApiCore-Backend/test/fixtures/hive-ops/54-setframe-with-unknown-frame-type.json new file mode 100644 index 0000000..3aee9ea --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/54-setframe-with-unknown-frame-type.json @@ -0,0 +1,78 @@ +{ + "name": "setFrame with unknown frame type", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 1, + "type": "GOLD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/55-addpart-box-with-wrong-frame-count.json b/ApiCore-Backend/test/fixtures/hive-ops/55-addpart-box-with-wrong-frame-count.json new file mode 100644 index 0000000..6886903 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/55-addpart-box-with-wrong-frame-count.json @@ -0,0 +1,68 @@ +{ + "name": "addPart box with wrong frame count", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + } + ] + }, + "at": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/56-addpart-box-without-frames.json b/ApiCore-Backend/test/fixtures/hive-ops/56-addpart-box-without-frames.json new file mode 100644 index 0000000..0df2580 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/56-addpart-box-without-frames.json @@ -0,0 +1,34 @@ +{ + "name": "addPart box without frames", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {} + }, + "at": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/57-frames-on-a-non-box-part.json b/ApiCore-Backend/test/fixtures/hive-ops/57-frames-on-a-non-box-part.json new file mode 100644 index 0000000..dc88337 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/57-frames-on-a-non-box-part.json @@ -0,0 +1,87 @@ +{ + "name": "frames on a non-box part", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + } + ] + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FRAMES_LENGTH" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/58-the-31st-part-in-the-stack-fails.json b/ApiCore-Backend/test/fixtures/hive-ops/58-the-31st-part-in-the-stack-fails.json new file mode 100644 index 0000000..e3f1d2e --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/58-the-31st-part-in-the-stack-fails.json @@ -0,0 +1,174 @@ +{ + "name": "the 31st part in the stack fails", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "q0", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q2", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q3", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q4", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q5", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q6", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q7", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q8", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q9", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q10", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q11", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q12", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q13", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q14", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q15", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q16", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q17", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q18", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q19", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q20", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q21", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q22", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q23", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q24", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q25", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q26", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q27", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q28", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q99", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 30 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "LIMIT_PARTS" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/59-a-stack-of-30-parts-is-allowed.json b/ApiCore-Backend/test/fixtures/hive-ops/59-a-stack-of-30-parts-is-allowed.json new file mode 100644 index 0000000..53bc5ce --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/59-a-stack-of-30-parts-is-allowed.json @@ -0,0 +1,322 @@ +{ + "name": "a stack of 30 parts is allowed", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "q0", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q2", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q3", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q4", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q5", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q6", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q7", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q8", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q9", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q10", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q11", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q12", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q13", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q14", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q15", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q16", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q17", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q18", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q19", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q20", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q21", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q22", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q23", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q24", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q25", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q26", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q27", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q28", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 29 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "q0", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q2", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q3", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q4", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q5", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q6", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q7", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q8", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q9", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q10", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q11", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q12", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q13", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q14", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q15", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q16", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q17", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q18", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q19", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q20", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q21", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q22", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q23", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q24", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q25", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q26", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q27", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + { + "id": "q28", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/60-label-of-41-characters.json b/ApiCore-Backend/test/fixtures/hive-ops/60-label-of-41-characters.json new file mode 100644 index 0000000..9fc52d8 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/60-label-of-41-characters.json @@ -0,0 +1,77 @@ +{ + "name": "label of 41 characters", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "LIMIT_TEXT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/61-label-of-40-characters.json b/ApiCore-Backend/test/fixtures/hive-ops/61-label-of-40-characters.json new file mode 100644 index 0000000..ff9fb79 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/61-label-of-40-characters.json @@ -0,0 +1,138 @@ +{ + "name": "label of 40 characters", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "label": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/62-frame-name-of-41-characters.json b/ApiCore-Backend/test/fixtures/hive-ops/62-frame-name-of-41-characters.json new file mode 100644 index 0000000..2bae44c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/62-frame-name-of-41-characters.json @@ -0,0 +1,79 @@ +{ + "name": "frame name of 41 characters", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 1, + "type": "BROOD", + "name": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "LIMIT_TEXT" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/63-more-than-100-ops.json b/ApiCore-Backend/test/fixtures/hive-ops/63-more-than-100-ops.json new file mode 100644 index 0000000..e790699 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/63-more-than-100-ops.json @@ -0,0 +1,577 @@ +{ + "name": "more than 100 ops", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + } + ], + "expect": { + "ok": false, + "opIndex": 100, + "reasonCode": "LIMIT_OPS" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/64-exactly-100-ops.json b/ApiCore-Backend/test/fixtures/hive-ops/64-exactly-100-ops.json new file mode 100644 index 0000000..cc100e4 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/64-exactly-100-ops.json @@ -0,0 +1,633 @@ +{ + "name": "exactly 100 ops", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + }, + { + "op": "setLabel", + "id": "b1", + "label": "a" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "label": "a" + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/65-duplicate-id-on-addpart.json b/ApiCore-Backend/test/fixtures/hive-ops/65-duplicate-id-on-addpart.json new file mode 100644 index 0000000..76f0d0a --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/65-duplicate-id-on-addpart.json @@ -0,0 +1,81 @@ +{ + "name": "duplicate id on addPart", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "DUPLICATE_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/66-duplicate-id-between-insert-and-stack-part.json b/ApiCore-Backend/test/fixtures/hive-ops/66-duplicate-id-between-insert-and-stack-part.json new file mode 100644 index 0000000..a2a0dd3 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/66-duplicate-id-between-insert-and-stack-part.json @@ -0,0 +1,81 @@ +{ + "name": "duplicate id between insert and stack part", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "b1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "DUPLICATE_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/67-addpart-with-empty-id.json b/ApiCore-Backend/test/fixtures/hive-ops/67-addpart-with-empty-id.json new file mode 100644 index 0000000..796d174 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/67-addpart-with-empty-id.json @@ -0,0 +1,81 @@ +{ + "name": "addPart with empty id", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/68-removepart-with-missing-id.json b/ApiCore-Backend/test/fixtures/hive-ops/68-removepart-with-missing-id.json new file mode 100644 index 0000000..3e6f357 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/68-removepart-with-missing-id.json @@ -0,0 +1,76 @@ +{ + "name": "removePart with missing id", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "removePart", + "id": "zzz" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/69-movepart-with-missing-id.json b/ApiCore-Backend/test/fixtures/hive-ops/69-movepart-with-missing-id.json new file mode 100644 index 0000000..6923762 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/69-movepart-with-missing-id.json @@ -0,0 +1,77 @@ +{ + "name": "movePart with missing id", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "movePart", + "id": "zzz", + "to": 1 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/70-setlabel-with-missing-id.json b/ApiCore-Backend/test/fixtures/hive-ops/70-setlabel-with-missing-id.json new file mode 100644 index 0000000..99c0c59 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/70-setlabel-with-missing-id.json @@ -0,0 +1,77 @@ +{ + "name": "setLabel with missing id", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "zzz", + "label": "a" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/71-addinsert-with-missing-host.json b/ApiCore-Backend/test/fixtures/hive-ops/71-addinsert-with-missing-host.json new file mode 100644 index 0000000..a46fc3d --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/71-addinsert-with-missing-host.json @@ -0,0 +1,81 @@ +{ + "name": "addInsert with missing host", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "zzz", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/72-setframe-with-missing-box.json b/ApiCore-Backend/test/fixtures/hive-ops/72-setframe-with-missing-box.json new file mode 100644 index 0000000..9e02b35 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/72-setframe-with-missing-box.json @@ -0,0 +1,78 @@ +{ + "name": "setFrame with missing box", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "zzz", + "slot": 1, + "type": "BROOD" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/73-foreign-part-type.json b/ApiCore-Backend/test/fixtures/hive-ops/73-foreign-part-type.json new file mode 100644 index 0000000..6a5e5d8 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/73-foreign-part-type.json @@ -0,0 +1,81 @@ +{ + "name": "foreign part type", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "x1", + "partTypeId": "hive-part-other-system-box", + "state": {} + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "FOREIGN_PART_TYPE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/74-inactive-part-type-on-addpart.json b/ApiCore-Backend/test/fixtures/hive-ops/74-inactive-part-type-on-addpart.json new file mode 100644 index 0000000..8023ecd --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/74-inactive-part-type-on-addpart.json @@ -0,0 +1,84 @@ +{ + "name": "inactive part type on addPart", + "inactivePartTypeIds": [ + "hive-part-zl10-queen-excluder" + ], + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {} + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "INACTIVE_PART_TYPE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/75-inactive-part-type-on-addinsert.json b/ApiCore-Backend/test/fixtures/hive-ops/75-inactive-part-type-on-addinsert.json new file mode 100644 index 0000000..dd711b5 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/75-inactive-part-type-on-addinsert.json @@ -0,0 +1,84 @@ +{ + "name": "inactive part type on addInsert", + "inactivePartTypeIds": [ + "hive-part-zl10-floor-insert" + ], + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "INACTIVE_PART_TYPE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/76-failing-op-index-is-reported.json b/ApiCore-Backend/test/fixtures/hive-ops/76-failing-op-index-is-reported.json new file mode 100644 index 0000000..75d093e --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/76-failing-op-index-is-reported.json @@ -0,0 +1,86 @@ +{ + "name": "failing op index is reported", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "ok" + }, + { + "op": "removePart", + "id": "zzz" + }, + { + "op": "setLabel", + "id": "b1", + "label": "never" + } + ], + "expect": { + "ok": false, + "opIndex": 1, + "reasonCode": "MISSING_ID" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/77-unknown-op.json b/ApiCore-Backend/test/fixtures/hive-ops/77-unknown-op.json new file mode 100644 index 0000000..bc5290c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/77-unknown-op.json @@ -0,0 +1,76 @@ +{ + "name": "unknown op", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "explode", + "id": "b1" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "UNKNOWN_OP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/78-setframe-without-name-keeps-an-existing-name.json b/ApiCore-Backend/test/fixtures/hive-ops/78-setframe-without-name-keeps-an-existing-name.json new file mode 100644 index 0000000..b0e7eef --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/78-setframe-without-name-keeps-an-existing-name.json @@ -0,0 +1,140 @@ +{ + "name": "setFrame without name keeps an existing name", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "BROOD", + "name": "Königin" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 3, + "type": "HONEY" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "HONEY", + "name": "Königin" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/79-setframe-without-name-on-an-unnamed-frame-stays-unnamed.json b/ApiCore-Backend/test/fixtures/hive-ops/79-setframe-without-name-on-an-unnamed-frame-stays-unnamed.json new file mode 100644 index 0000000..9b7fd3c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/79-setframe-without-name-on-an-unnamed-frame-stays-unnamed.json @@ -0,0 +1,138 @@ +{ + "name": "setFrame without name on an unnamed frame stays unnamed", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 2, + "type": "HONEY" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "HONEY" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/80-setstate-accepts-a-value-within-the-1e-9-step-tolerance.json b/ApiCore-Backend/test/fixtures/hive-ops/80-setstate-accepts-a-value-within-the-1e-9-step-tolerance.json new file mode 100644 index 0000000..3cb1af1 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/80-setstate-accepts-a-value-within-the-1e-9-step-tolerance.json @@ -0,0 +1,56 @@ +{ + "name": "setState accepts a value within the 1e-9 step tolerance", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 2.5000000001 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": { + "syrupLiters": 2.5000000001 + } + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/81-setstate-rejects-a-value-just-beyond-the-step-tolerance.json b/ApiCore-Backend/test/fixtures/hive-ops/81-setstate-rejects-a-value-just-beyond-the-step-tolerance.json new file mode 100644 index 0000000..d06279a --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/81-setstate-rejects-a-value-just-beyond-the-step-tolerance.json @@ -0,0 +1,36 @@ +{ + "name": "setState rejects a value just beyond the step tolerance", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "fd1", + "partTypeId": "hive-part-zl10-feeder", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setState", + "id": "fd1", + "key": "syrupLiters", + "value": 2.500001 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_STEP" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/82-setlabel-with-a-non-string-label.json b/ApiCore-Backend/test/fixtures/hive-ops/82-setlabel-with-a-non-string-label.json new file mode 100644 index 0000000..8036ad1 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/82-setlabel-with-a-non-string-label.json @@ -0,0 +1,77 @@ +{ + "name": "setLabel with a non-string label", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": 5 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/83-setframe-with-a-non-string-name.json b/ApiCore-Backend/test/fixtures/hive-ops/83-setframe-with-a-non-string-name.json new file mode 100644 index 0000000..aaa870f --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/83-setframe-with-a-non-string-name.json @@ -0,0 +1,79 @@ +{ + "name": "setFrame with a non-string name", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrame", + "boxId": "b1", + "slot": 1, + "type": "BROOD", + "name": 5 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "STATE_OUT_OF_RANGE" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/84-addpart-normalises-an-empty-inserts-array-away.json b/ApiCore-Backend/test/fixtures/hive-ops/84-addpart-normalises-an-empty-inserts-array-away.json new file mode 100644 index 0000000..cd6d10a --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/84-addpart-normalises-an-empty-inserts-array-away.json @@ -0,0 +1,137 @@ +{ + "name": "addPart normalises an empty inserts array away", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ], + "inserts": [] + }, + "at": 1 + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b2", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/85-addinsert-then-removepart-leaves-no-empty-inserts-array.json b/ApiCore-Backend/test/fixtures/hive-ops/85-addinsert-then-removepart-leaves-no-empty-inserts-array.json new file mode 100644 index 0000000..af210ab --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/85-addinsert-then-removepart-leaves-no-empty-inserts-array.json @@ -0,0 +1,145 @@ +{ + "name": "addInsert then removePart leaves no empty inserts array", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {} + } + }, + { + "op": "removePart", + "id": "fi1" + } + ], + "expect": { + "ok": true, + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + } + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/86-unknown-key-on-the-state-root.json b/ApiCore-Backend/test/fixtures/hive-ops/86-unknown-key-on-the-state-root.json new file mode 100644 index 0000000..234344c --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/86-unknown-key-on-the-state-root.json @@ -0,0 +1,78 @@ +{ + "name": "unknown key on the state root", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ], + "extra": 1 + }, + "ops": [ + { + "op": "setLabel", + "id": "b1", + "label": "a" + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "UNKNOWN_FIELD" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/87-unknown-key-on-a-part.json b/ApiCore-Backend/test/fixtures/hive-ops/87-unknown-key-on-a-part.json new file mode 100644 index 0000000..e797a57 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/87-unknown-key-on-a-part.json @@ -0,0 +1,82 @@ +{ + "name": "unknown key on a part", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addPart", + "part": { + "id": "q1", + "partTypeId": "hive-part-zl10-queen-excluder", + "state": {}, + "extra": 1 + }, + "at": 2 + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "UNKNOWN_FIELD" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/88-unknown-key-on-an-insert.json b/ApiCore-Backend/test/fixtures/hive-ops/88-unknown-key-on-an-insert.json new file mode 100644 index 0000000..f1f9459 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/88-unknown-key-on-an-insert.json @@ -0,0 +1,82 @@ +{ + "name": "unknown key on an insert", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "addInsert", + "hostId": "f1", + "part": { + "id": "fi1", + "partTypeId": "hive-part-zl10-floor-insert", + "state": {}, + "extra": 1 + } + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "UNKNOWN_FIELD" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/89-unknown-key-on-a-frame.json b/ApiCore-Backend/test/fixtures/hive-ops/89-unknown-key-on-a-frame.json new file mode 100644 index 0000000..ddbd380 --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/89-unknown-key-on-a-frame.json @@ -0,0 +1,128 @@ +{ + "name": "unknown key on a frame", + "state": { + "schemaVersion": 1, + "stack": [ + { + "id": "f1", + "partTypeId": "hive-part-zl10-floor", + "state": {} + }, + { + "id": "b1", + "partTypeId": "hive-part-zl10-box-10", + "state": {}, + "frames": [ + { + "slot": 1, + "type": "FOUNDATION" + }, + { + "slot": 2, + "type": "FOUNDATION" + }, + { + "slot": 3, + "type": "FOUNDATION" + }, + { + "slot": 4, + "type": "FOUNDATION" + }, + { + "slot": 5, + "type": "FOUNDATION" + }, + { + "slot": 6, + "type": "FOUNDATION" + }, + { + "slot": 7, + "type": "FOUNDATION" + }, + { + "slot": 8, + "type": "FOUNDATION" + }, + { + "slot": 9, + "type": "FOUNDATION" + }, + { + "slot": 10, + "type": "FOUNDATION" + } + ] + }, + { + "id": "r1", + "partTypeId": "hive-part-zl10-roof", + "state": {} + } + ] + }, + "ops": [ + { + "op": "setFrames", + "boxId": "b1", + "frames": [ + { + "slot": 1, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 2, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 3, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 4, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 5, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 6, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 7, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 8, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 9, + "type": "FOUNDATION", + "extra": 1 + }, + { + "slot": 10, + "type": "FOUNDATION", + "extra": 1 + } + ] + } + ], + "expect": { + "ok": false, + "opIndex": 0, + "reasonCode": "UNKNOWN_FIELD" + } +} diff --git a/ApiCore-Backend/test/fixtures/hive-ops/catalog.json b/ApiCore-Backend/test/fixtures/hive-ops/catalog.json new file mode 100644 index 0000000..e15ccfc --- /dev/null +++ b/ApiCore-Backend/test/fixtures/hive-ops/catalog.json @@ -0,0 +1,246 @@ +{ + "systemId": "hive-system-zander-liebig-10", + "partTypes": [ + { + "id": "hive-part-zl10-floor", + "kind": "FLOOR", + "name": "Liebig Wanderboden", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 120, + "frameSlots": null, + "stateSchema": [ + { + "key": "entrance", + "label": "Flugloch", + "kind": "choice", + "options": [ + "OPEN", + "NARROW", + "CLOSED" + ], + "optionLabels": [ + "offen", + "eng", + "zu" + ] + }, + { + "key": "varroaTray", + "label": "Varroa-Schublade", + "kind": "choice", + "options": [ + "IN", + "OUT" + ], + "optionLabels": [ + "drin", + "draußen" + ] + } + ], + "active": true + }, + { + "id": "hive-part-zl10-floor-insert", + "kind": "FLOOR_INSERT", + "name": "Liebig Bausperre", + "lengthMm": 475, + "widthMm": 375, + "heightMm": 5, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-box-10", + "kind": "BOX", + "name": "Liebig Zander Ganzzarge, 10 Rähmchen", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 230, + "frameSlots": 10, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-box-empty", + "kind": "BOX", + "name": "Leerzarge (Liebig Zander Ganzzarge ohne Rähmchen)", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 230, + "frameSlots": 0, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-divider-board", + "kind": "DIVIDER_BOARD", + "name": "Liebig Zwischenboden", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 50, + "frameSlots": null, + "stateSchema": [ + { + "key": "bungHoles", + "label": "Spundlöcher", + "kind": "choice", + "options": [ + "OPEN", + "CLOSED", + "BEE_ESCAPE" + ], + "optionLabels": [ + "offen", + "zu", + "Bienenflucht" + ] + } + ], + "active": true + }, + { + "id": "hive-part-zl10-queen-excluder", + "kind": "QUEEN_EXCLUDER", + "name": "Niro-Absperrgitter Zander Liebig", + "lengthMm": 478, + "widthMm": 376, + "heightMm": 2, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-feeder", + "kind": "FEEDER", + "name": "Liebig Zweikammer-Futterzarge", + "lengthMm": 520, + "widthMm": 420, + "heightMm": 70, + "frameSlots": null, + "stateSchema": [ + { + "key": "syrupLiters", + "label": "Flüssigfutter", + "kind": "level", + "unit": "l", + "max": 3, + "step": 0.5, + "feedType": "SUGAR_WATER" + }, + { + "key": "fondantKg", + "label": "Futterteig", + "kind": "level", + "unit": "kg", + "max": 2.5, + "step": 0.5, + "feedType": "FEED_PASTE" + } + ], + "active": true + }, + { + "id": "hive-part-zl10-cover-abdeckfix", + "kind": "COVER_SHEET", + "name": "Abdeck-Fix (Folie, klar, 0,4 mm)", + "lengthMm": 478, + "widthMm": 378, + "heightMm": 1, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-cover-lotus", + "kind": "COVER_SHEET", + "name": "Imgut Lotus-Abdeckung (0,5 mm)", + "lengthMm": 478, + "widthMm": 378, + "heightMm": 1, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-propolis-screen", + "kind": "PROPOLIS_SCREEN", + "name": "Imgut Propolisgitter", + "lengthMm": 478, + "widthMm": 378, + "heightMm": 3, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-inner-lid", + "kind": "INNER_LID", + "name": "Liebig Holzdeckel", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 35, + "frameSlots": null, + "stateSchema": [ + { + "key": "feedHole", + "label": "Futterloch", + "kind": "choice", + "options": [ + "OPEN", + "CLOSED" + ], + "optionLabels": [ + "offen", + "zu" + ] + } + ], + "active": true + }, + { + "id": "hive-part-zl10-insulation", + "kind": "INSULATION", + "name": "Styropor-Einlage (selbst gebaut)", + "lengthMm": 465, + "widthMm": 365, + "heightMm": 20, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-roof", + "kind": "ROOF", + "name": "Edelstahldeckel konisch Liebig", + "lengthMm": 525, + "widthMm": 430, + "heightMm": 60, + "frameSlots": null, + "stateSchema": [], + "active": true + }, + { + "id": "hive-part-zl10-evaporator", + "kind": "EVAPORATOR", + "name": "Nassenheider Verdunster Professional", + "lengthMm": 200, + "widthMm": 140, + "heightMm": 120, + "frameSlots": null, + "stateSchema": [ + { + "key": "acidMl", + "label": "Ameisensäure", + "kind": "level", + "unit": "ml", + "max": 290, + "step": 10 + } + ], + "active": true + } + ] +} diff --git a/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts b/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts new file mode 100644 index 0000000..40d98ee --- /dev/null +++ b/ApiCore-Backend/test/hive-catalog-seed.e2e-spec.ts @@ -0,0 +1,176 @@ +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; + 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(); + }); +}); diff --git a/ApiCore-Backend/test/hive-catalog.e2e-spec.ts b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts new file mode 100644 index 0000000..100c13d --- /dev/null +++ b/ApiCore-Backend/test/hive-catalog.e2e-spec.ts @@ -0,0 +1,303 @@ +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; + stateSchema: unknown[]; + active: boolean; +} +interface TemplateBody { + schemaVersion: number; + stack: { id: 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, + 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([]); + }); +}); diff --git a/ApiCore-Backend/test/hive.e2e-spec.ts b/ApiCore-Backend/test/hive.e2e-spec.ts new file mode 100644 index 0000000..cebc9fd --- /dev/null +++ b/ApiCore-Backend/test/hive.e2e-spec.ts @@ -0,0 +1,494 @@ +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'); + }); +}); diff --git a/ApiCore-Frontend/src/lib/system/labels.spec.ts b/ApiCore-Frontend/src/lib/system/labels.spec.ts index 3491440..b9cc04f 100644 --- a/ApiCore-Frontend/src/lib/system/labels.spec.ts +++ b/ApiCore-Frontend/src/lib/system/labels.spec.ts @@ -82,4 +82,8 @@ describe("DOMAIN_EVENT_LABELS completeness", () => { TskReferenceDayReached: "TSK-Stichtag erreicht", }); }); + + it("names HiveChanged", () => { + expect(DOMAIN_EVENT_LABELS.HiveChanged).toBe("Beute geändert"); + }); }); diff --git a/ApiCore-Frontend/src/lib/system/labels.ts b/ApiCore-Frontend/src/lib/system/labels.ts index 39887ce..fd4081b 100644 --- a/ApiCore-Frontend/src/lib/system/labels.ts +++ b/ApiCore-Frontend/src/lib/system/labels.ts @@ -62,6 +62,7 @@ export const DOMAIN_EVENT_LABELS: Record = { ColonyMoved: "Volk umgesetzt", ApiaryChanged: "Stand geändert", TskReferenceDayReached: "TSK-Stichtag erreicht", + HiveChanged: "Beute geändert", }; /** The oldest open event is this old (seconds) before the worker counts as stuck. */ diff --git a/TASKS.md b/TASKS.md index 5976051..a044171 100644 --- a/TASKS.md +++ b/TASKS.md @@ -336,7 +336,7 @@ Plan: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, Annahmen ab 59 in AR Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Teil 1 (Daten) und 2 (3D-Ansicht) in dieser Spec, Teil 3 (KI-Bewertung aus Foto, Aufbau-Vorschlag) und 4 (KI-Gespräch in der Durchsicht, auf T7) folgen mit eigenen Specs. -- [ ] L1 Daten und API (Katalog Liebig-Zander, Aufbau je Volk, Operationen, Historie) +- [x] L1 Daten und API (Katalog Liebig-Zander, Aufbau je Volk, Operationen, Historie) - [ ] L2 Karte ohne 3D (SVG, Aufbau-Liste, Sheets, Volk-Seite, Historie, Vorschläge Fütterung/Behandlung) - [ ] L3 3D-Szene (holografischer Stil, Einpassen, Fokus-Modus, Pegel, Bewegungsstufen) - [ ] L4 Umbauen (Vollbild, Drag & Drop, Rückgängig)