Files
Wabenhain/src/lib/apicore-mapping.test.ts
T
Nick SzaunigandClaude Opus 5.5 3bc0693e4d refactor(apicore-sync): API key and shop feed only, no password login
The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:05:11 +02:00

330 lines
11 KiB
TypeScript

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> = {}): 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("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);
});
});