Files
Wabenhain/src/lib/apicore-mapping.test.ts
T
Nick SzaunigandClaude Opus 5.5 641afa1ae4
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
fix(harvests): allow several harvests of the same type per year
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.

- Drop the (year, type) unique index. The slug is now the only unique,
  public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
  bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
  "apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
  (decomposed umlauts) and writes out umlauts. The migration repairs
  manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
  writers cannot produce duplicates; known slugs are skipped up front to
  keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:25:02 +02:00

318 lines
10 KiB
TypeScript

import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
import {
hasLegacyApiCoreSlug,
buildHarvestDescription,
buildProductDescription,
deriveWeightGrams,
deriveJarWeightGrams,
slugifyProductName,
productSlugCandidates,
harvestFactChanges,
batchFactChanges,
} from "./apicore-mapping";
function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
return {
id: "cm-product-1",
name: "Sommertracht 500 g",
description: null,
sku: null,
packagingSize: null,
weight: null,
salesUnit: null,
status: "ACTIVE",
...overrides,
};
}
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
date: "2026-07-14T00:00:00.000Z",
honeyType: "Lindenhonig",
honeySuperCount: null,
grossWeight: null,
netWeight: null,
apiaryName: "Streuobstwiese",
...overrides,
};
}
describe("hasLegacyApiCoreSlug", () => {
test("erkennt das alte Slug-Format 'apicore-<ID>'", () => {
expect(hasLegacyApiCoreSlug("apicore-cmtx1ri7x001j012bvcqh9tpt")).toBe(true);
});
test("laesst lesbare Adressen in Ruhe", () => {
expect(hasLegacyApiCoreSlug("bluetenhonig-2026")).toBe(false);
});
});
describe("buildHarvestDescription", () => {
test("fasst die vorhandenen Mengenangaben zusammen", () => {
// Arrange
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41 });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg"
);
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
const source = makeHarvest();
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).toContain("Bitte Beschreibung ergänzen.");
});
});
describe("buildProductDescription", () => {
test("uebernimmt ApiCores Beschreibung und nennt Artikelnummer und Verkaufseinheit", () => {
// Arrange
const source = makeProduct({ description: "Cremig gerührt.", sku: "WH-500", salesUnit: "Glas" });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Artikelnummer WH-500 · Verkaufseinheit Glas). Cremig gerührt."
);
});
test("setzt eine Aufforderung ein, statt einen leeren Verkaufstext zu erzeugen", () => {
// Arrange: ApiCores description ist eine interne Notiz und oft leer.
const source = makeProduct({ description: " " });
// Act
const description = buildProductDescription(source);
// Assert
expect(description).toContain("Bitte Produktbeschreibung für den Shop ergänzen.");
});
});
describe("deriveWeightGrams", () => {
test("nimmt das Gewicht des Artikels", () => {
// Arrange
const source = makeProduct({ weight: 500, packagingSize: 250 });
// Act & Assert: weight schlaegt packagingSize.
expect(deriveWeightGrams(source)).toBe(500);
});
test("faellt auf die Gebindegroesse zurueck, wenn kein Gewicht gepflegt ist", () => {
// Arrange
const source = makeProduct({ weight: null, packagingSize: 250 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(250);
});
test("rundet auf volle Gramm", () => {
// Arrange
const source = makeProduct({ weight: 499.6 });
// Act & Assert
expect(deriveWeightGrams(source)).toBe(500);
});
test("gibt 0 zurueck statt zu raten, wenn ApiCore keine Menge kennt", () => {
// Arrange
const source = makeProduct();
// Act & Assert: 0 ist in Wabenhain kein speicherbares Gewicht und zwingt
// damit zur manuellen Pflege, bevor das Produkt verkauft werden kann.
expect(deriveWeightGrams(source)).toBe(0);
});
test("behandelt 0 und negative Werte wie eine fehlende Angabe", () => {
// Arrange
const zero = makeProduct({ weight: 0 });
const negative = makeProduct({ weight: -500 });
// Act & Assert
expect(deriveWeightGrams(zero)).toBe(0);
expect(deriveWeightGrams(negative)).toBe(0);
});
});
describe("slugifyProductName", () => {
test("macht aus einem Namen eine adresstaugliche Form", () => {
expect(slugifyProductName("Waldhonig")).toBe("waldhonig");
expect(slugifyProductName("Waldhonig, cremig")).toBe("waldhonig-cremig");
});
test("schreibt Umlaute aus, statt sie zu verschlucken", () => {
// Arrange & Act & Assert: eine blosse Filterung auf a-z machte aus
// "Bluetenhonig" ein "bl-tenhonig".
expect(slugifyProductName("Blütenhonig")).toBe("bluetenhonig");
expect(slugifyProductName("Süßer Honig")).toBe("suesser-honig");
});
});
describe("productSlugCandidates", () => {
test("stellt den Jahrgang voran, weil er die Jahrgaenge unterscheidet", () => {
// Arrange & Act
const candidates = productSlugCandidates("Waldhonig", 2026, "FH-26-01");
// Assert: erste Wahl ist die lesbare Adresse mit Jahreszahl.
expect(candidates[0]).toBe("waldhonig-2026");
});
test("haelt die Losnummer als Ausweichadresse bereit", () => {
// Arrange & Act & Assert: deckt den Fall ab, dass derselbe Artikel im
// selben Jahr mehrfach abgefuellt wurde.
expect(productSlugCandidates("Waldhonig", 2026, "FH-26-02")).toEqual([
"waldhonig-2026",
"waldhonig-2026-fh-26-02",
]);
});
test("zwei Jahrgaenge desselben Namens kollidieren nicht", () => {
// Arrange & Act
const first = productSlugCandidates("Waldhonig", 2026, "FH-26-01")[0];
const second = productSlugCandidates("Waldhonig", 2027, "FH-27-01")[0];
// Assert: genau das ist der Grund fuer die Jahreszahl — Product.slug ist
// eindeutig, und ohne sie waere der zweite Jahrgang nicht anlegbar.
expect(first).not.toBe(second);
});
test("faellt bei einem Namen ohne verwertbare Zeichen nicht auf eine leere Adresse zurueck", () => {
// Arrange & Act & Assert: ein leerer Slug waere eine ungueltige URL.
expect(productSlugCandidates("***", 2026, "")).toEqual(["produkt-2026"]);
});
});
describe("deriveJarWeightGrams", () => {
test("nimmt die Glasgroesse der Abfuellung", () => {
// Arrange
const source = makeProduct({ weight: 250 });
// Act & Assert: die Abfuellung sagt, was tatsaechlich in dieses Glas kam
// — der Artikel nur, was ueblicherweise vorgesehen ist.
expect(deriveJarWeightGrams(500, source)).toBe(500);
});
test("faellt auf die Angaben am Artikel zurueck, wenn die Abfuellung keine Groesse fuehrt", () => {
// Arrange
const source = makeProduct({ weight: 250 });
// Act & Assert
expect(deriveJarWeightGrams(null, source)).toBe(250);
expect(deriveJarWeightGrams(0, source)).toBe(250);
});
test("gibt 0 zurueck, wenn weder Abfuellung noch Artikel eine Menge kennen", () => {
expect(deriveJarWeightGrams(null, makeProduct())).toBe(0);
});
});
describe("buildProductDescription mit Jahrgang", () => {
test("nennt den Jahrgang zuerst", () => {
// Arrange
const source = makeProduct({ description: "Kräftig.", sku: "WH-500" });
// Act
const description = buildProductDescription(source, 2026);
// Assert
expect(description).toBe(
"Automatisch aus ApiCore importiert (Jahrgang 2026 · Artikelnummer WH-500). Kräftig."
);
});
});
describe("isSellableStatus", () => {
test("erkennt ACTIVE als verkaufsfaehig", () => {
expect(isSellableStatus("ACTIVE")).toBe(true);
});
test("behandelt DISCONTINUED und unbekannte Status als nicht verkaufsfaehig", () => {
// Arrange & Act & Assert: ein kuenftiger, hier noch unbekannter
// ApiCore-Status darf nicht versehentlich als Shop-Entwurf durchrutschen.
expect(isSellableStatus("DISCONTINUED")).toBe(false);
expect(isSellableStatus("SEASONAL")).toBe(false);
expect(isSellableStatus("")).toBe(false);
});
});
describe("harvestFactChanges", () => {
test("liefert nichts, wenn Datum und Jahr unveraendert sind", () => {
// Arrange
const date = new Date("2026-07-14T00:00:00.000Z");
// Act
const changes = harvestFactChanges({ harvestDate: date, year: 2026 }, new Date(date));
// Assert
expect(changes).toEqual({});
});
test("uebernimmt ein in ApiCore korrigiertes Erntedatum samt Jahr", () => {
// Arrange
const existing = { harvestDate: new Date("2025-12-31T12:00:00.000Z"), year: 2025 };
const corrected = new Date("2026-01-02T12:00:00.000Z");
// Act
const changes = harvestFactChanges(existing, corrected);
// Assert
expect(changes).toEqual({ harvestDate: corrected, year: 2026 });
});
});
describe("batchFactChanges", () => {
const base = {
code: "WH-2026-0001",
fillDate: new Date("2026-08-01T00:00:00.000Z"),
bestBeforeDate: new Date("2028-08-01T00:00:00.000Z"),
glassCount: 100,
};
test("aktualisiert nur die ApiCore-eigenen Felder, die sich geaendert haben", () => {
// Arrange: MHD in ApiCore korrigiert, Shop hat 40 Glaeser verkauft.
const source = { ...base, bestBeforeDate: new Date("2028-07-31T00:00:00.000Z") };
// Act
const result = batchFactChanges({ ...base, remainingGlasses: 60 }, source);
// Assert
expect(result.changes).toEqual({ bestBeforeDate: source.bestBeforeDate });
expect(result.changes).not.toHaveProperty("remainingGlasses");
expect(result.stockExceedsFilled).toBe(false);
});
test("uebernimmt eine korrigierte Losnummer statt eine zweite Charge anzulegen", () => {
// Act
const result = batchFactChanges({ ...base, remainingGlasses: 100 }, { ...base, code: "WH-2026-0002" });
// Assert
expect(result.changes).toEqual({ code: "WH-2026-0002" });
});
test("meldet einen Shop-Bestand ueber der korrigierten Abfuellmenge, statt ihn anzugleichen", () => {
// Arrange: ApiCore korrigiert 100 auf 80 Glaeser, im Shop stehen noch 90.
const source = { ...base, glassCount: 80 };
// Act
const result = batchFactChanges({ ...base, remainingGlasses: 90 }, source);
// Assert
expect(result.changes).toEqual({ glassCount: 80 });
expect(result.stockExceedsFilled).toBe(true);
});
});