import { describe, test, expect } from "vitest"; import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client"; import { slugForApiCoreSource, buildHarvestDescription, buildProductDescription, deriveWeightGrams, deriveJarWeightGrams, slugifyProductName, productSlugCandidates, harvestFactChanges, batchFactChanges, } from "./apicore-mapping"; function makeProduct(overrides: Partial = {}): 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 { 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("slugForApiCoreSource", () => { test("leitet den Slug ausschliesslich aus der ApiCore-ID ab", () => { // Arrange const sourceId = "cm-product-1"; // Act const slug = slugForApiCoreSource(sourceId); // Assert expect(slug).toBe("apicore-cm-product-1"); }); test("bleibt stabil, wenn sich in ApiCore nur der Name aendert", () => { // Arrange: derselbe Datensatz, in ApiCore umbenannt. const before = makeProduct({ name: "Sommertracht 500 g" }); const after = makeProduct({ name: "Sommertracht cremig, 500 g" }); // Act & Assert: sonst wuerde der Sync ein zweites Produkt anlegen. expect(slugForApiCoreSource(after.id)).toBe(slugForApiCoreSource(before.id)); }); }); 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); }); });