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]>
330 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|