From 3bc0693e4da970354f49b0c6a4654c5d042ccb99 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 28 Sep 2026 06:05:11 +0200 Subject: [PATCH] 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) --- .env.example | 26 +- README.md | 2 +- k8s/02-secret.example.yaml | 17 +- src/app/admin/actions/apicore-sync.ts | 8 +- src/lib/apicore-client.test.ts | 249 +++++++-------- src/lib/apicore-client.ts | 435 +++++++------------------- src/lib/apicore-mapping.test.ts | 18 +- src/lib/apicore-mapping.ts | 4 +- src/lib/integration-status.test.ts | 25 +- src/lib/integration-status.ts | 12 +- 10 files changed, 248 insertions(+), 548 deletions(-) diff --git a/.env.example b/.env.example index 466f1aa..7ab45e5 100644 --- a/.env.example +++ b/.env.example @@ -91,29 +91,15 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY= SMTP_USER= SMTP_FROM="Wabenhain " -# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten -# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte -> -# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen -# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein -# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein -# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines -# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten -# Admin-Zugangs. Benoetigte Berechtigung (in ApiCore unter Benutzer & Rollen -# eine eigene Rolle anlegen, die NUR diese eine Berechtigung hat): -# shop-sync:read freigegebene Shop-Daten (GET /shop-sync) — liefert nur -# Ernte-Fakten, Artikelstamm und Abfuellungen, keine Notizen -# Nur fuer aeltere ApiCore-Staende ohne /shop-sync (Rueckfall) noetig: -# harvests:read, products:read, extractions:read, bottling:read, apiaries:read +# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt +# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET +# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter +# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung +# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit +# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler. # Details siehe src/lib/apicore-client.ts. APICORE_API_URL= -# Empfohlen: API-Schluessel aus ApiCore → Verwaltung → Automatisierung → -# API-Schluessel (Berechtigung "Shop-Daten lesen"). Kein Passwort, keine MFA, -# jederzeit widerrufbar. Ist er gesetzt, werden die beiden Werte darunter -# nicht gebraucht. APICORE_API_KEY= -# Nur fuer aeltere ApiCore-Staende ohne API-Schluessel: -APICORE_SYNC_EMAIL= -APICORE_SYNC_PASSWORD= # DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) — # ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im diff --git a/README.md b/README.md index e1ef5e3..76ee430 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`. -**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Für ältere ApiCore-Stände ohne API-Schlüssel funktioniert weiter `APICORE_SYNC_EMAIL`/`APICORE_SYNC_PASSWORD` eines eigenen, MFA-freien Service-Kontos. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`. +**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`. ## Vollständiger Docker-Betrieb diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 3b51b26..97caa18 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -102,19 +102,12 @@ stringData: # ist dagegen oeffentlich und steht dort. GOOGLE_PLACES_API_KEY: "" - # Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte -> - # "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen - # Konfigurationsfehler, statt still nichts zu tun. - # - # Empfohlen: API-Schluessel aus ApiCore (Verwaltung -> Automatisierung -> - # API-Schluessel, Berechtigung "Shop-Daten lesen"). Kein Passwort, keine - # MFA, jederzeit widerrufbar. Die Adresse APICORE_API_URL ist nicht geheim - # und steht in 01-configmap.yaml. + # API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles + # synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung -> + # API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort, + # jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen + # Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml. APICORE_API_KEY: "" - # Nur fuer aeltere ApiCore-Staende ohne API-Schluessel: eigenes, - # MFA-freies Service-Konto mit Leserechten statt eines Admin-Zugangs. - APICORE_SYNC_EMAIL: "" - APICORE_SYNC_PASSWORD: "" # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet diff --git a/src/app/admin/actions/apicore-sync.ts b/src/app/admin/actions/apicore-sync.ts index b59014e..e5807f0 100644 --- a/src/app/admin/actions/apicore-sync.ts +++ b/src/app/admin/actions/apicore-sync.ts @@ -5,13 +5,13 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { - openApiCoreSession, + getApiCoreConnection, fetchApiCoreShopData, isSellableStatus, ApiCoreConfigError, ApiCoreRequestError, type ApiCoreShopData, - type ApiCoreHarvestWithApiaryName, + type ApiCoreHarvest, type ApiCoreProduct, type ApiCoreBottlingBatchWithHarvestId, } from "@/lib/apicore-client"; @@ -128,7 +128,7 @@ function describeError(error: unknown): string { // Ernten // --------------------------------------------------------------------------- -async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise { +async function syncHarvests(sources: ApiCoreHarvest[]): Promise { const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 }; for (const source of sources) { @@ -480,7 +480,7 @@ export async function syncApiCoreAction(formData: FormData): Promise { let source: ApiCoreShopData; try { - source = await fetchApiCoreShopData(await openApiCoreSession()); + source = await fetchApiCoreShopData(getApiCoreConnection()); } catch (error: unknown) { redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`); } diff --git a/src/lib/apicore-client.test.ts b/src/lib/apicore-client.test.ts index 1b1ff99..e4874b7 100644 --- a/src/lib/apicore-client.test.ts +++ b/src/lib/apicore-client.test.ts @@ -1,14 +1,14 @@ -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, test, expect, vi, afterEach } from "vitest"; import { - openApiCoreSession, - fetchApiCoreHarvests, - fetchApiCoreBottlingBatches, + getApiCoreConnection, fetchApiCoreShopData, + ApiCoreConfigError, ApiCoreRequestError, - type ApiCoreSession, + type ApiCoreConnection, } from "./apicore-client"; -const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" }; +const API_KEY = `ack_abcd1234_${"A".repeat(43)}`; +const connection: ApiCoreConnection = { baseUrl: "http://apicore.test/api/v1", apiKey: API_KEY }; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -17,16 +17,92 @@ function jsonResponse(body: unknown, status = 200): Response { }); } -describe("apicore-client", () => { - beforeEach(() => { - vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1"); - vi.stubEnv("APICORE_SYNC_EMAIL", "sync@example.com"); - vi.stubEnv("APICORE_SYNC_PASSWORD", "secret"); +const feed = { + contractVersion: 1, + generatedAt: "2026-09-27T10:00:00.000Z", + harvests: [ + { + id: "h1", + apiaryId: "a1", + apiaryName: "Streuobstwiese", + date: "2026-07-14T00:00:00.000Z", + honeyType: "Sommertracht", + honeySuperCount: null, + grossWeight: null, + netWeight: 41, + // Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen. + futureField: "wird ignoriert", + }, + ], + products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }], + bottlings: [ + { + id: "b1", + lotNumber: "WH-2026-0001", + date: "2026-08-01T00:00:00.000Z", + bestBeforeDate: "2028-08-01T00:00:00.000Z", + jarSize: 500, + jarCount: 60, + productId: "p1", + harvestIds: ["h1", "h2"], + }, + ], +}; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("getApiCoreConnection", () => { + test("liefert Adresse und API-Schluessel", () => { + // Arrange + vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1/"); + vi.stubEnv("APICORE_API_KEY", API_KEY); + + // Act & Assert + expect(getApiCoreConnection()).toEqual(connection); }); - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); + test("verlangt einen API-Schluessel — Benutzer und Passwort reichen nicht mehr", () => { + // Arrange: nur die alte Konfiguration. + vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1"); + vi.stubEnv("APICORE_API_KEY", ""); + vi.stubEnv("APICORE_SYNC_EMAIL", "sync@example.com"); + vi.stubEnv("APICORE_SYNC_PASSWORD", "secret"); + + // Act & Assert + expect(() => getApiCoreConnection()).toThrow(ApiCoreConfigError); + }); + + test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", () => { + // Arrange + vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1"); + vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz"); + + // Act & Assert + expect(() => getApiCoreConnection()).toThrow(/Format eines ApiCore-API-Schlüssels/); + }); +}); + +describe("fetchApiCoreShopData", () => { + test("holt alles in einem Abruf, mit dem Schluessel als Bearer-Token und Zeitlimit", async () => { + // Arrange + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed)); + vi.stubGlobal("fetch", fetchMock); + + // Act + const data = await fetchApiCoreShopData(connection); + + // Assert + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://apicore.test/api/v1/shop-sync"); + expect(init.headers).toEqual({ Authorization: `Bearer ${API_KEY}` }); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese" }); + expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 }); + expect(data.products[0].id).toBe("p1"); }); test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => { @@ -37,150 +113,49 @@ describe("apicore-client", () => { ); // Act & Assert - await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/); + await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/); }); - test("nutzt einen API-Schluessel direkt als Bearer-Token, ohne Passwort-Login", async () => { + test("benennt einen widerrufenen oder abgelaufenen Schluessel", async () => { // Arrange - const key = `ack_abcd1234_${"A".repeat(43)}`; - vi.stubEnv("APICORE_API_KEY", key); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - // Act - const opened = await openApiCoreSession(); - - // Assert - expect(opened).toEqual({ baseUrl: "http://apicore.test/api/v1", token: key }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", async () => { - // Arrange - vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Unauthorized" }, 401))); // Act & Assert - await expect(openApiCoreSession()).rejects.toThrow(/Format eines ApiCore-API-Schlüssels/); + await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/API-Schlüssel abgelehnt/); }); - test("setzt an jeder Anfrage ein Zeitlimit", async () => { - // Arrange - const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + test("hat keinen Rueckfall auf andere Endpunkte, wenn die Schnittstelle fehlt", async () => { + // Arrange: aelteres ApiCore ohne /shop-sync. + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, 404)); vi.stubGlobal("fetch", fetchMock); - // Act - await fetchApiCoreHarvests(session); - - // Assert - expect(fetchMock).toHaveBeenCalled(); - for (const call of fetchMock.mock.calls) { - expect((call[1] as RequestInit).signal).toBeInstanceOf(AbortSignal); - } + // Act & Assert + await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/); + expect(fetchMock).toHaveBeenCalledTimes(1); }); test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => { - // Arrange: ApiCore antwortet mit 500 — eine leere Liste daraus zu machen, - // hiesse fuer den Sync "es gibt nichts", und darauf darf nichts aufbauen. + // Arrange vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500))); // Act & Assert - await expect(fetchApiCoreHarvests(session)).rejects.toBeInstanceOf(ApiCoreRequestError); + await expect(fetchApiCoreShopData(connection)).rejects.toBeInstanceOf(ApiCoreRequestError); }); - test("verwirft eine Antwort mit unerwarteter Struktur statt sie teilweise zu uebernehmen", async () => { - // Arrange: Schleuderungen ok, Chargen ohne Pflichtfeld lotNumber. - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => - url.endsWith("/extractions") - ? jsonResponse([]) - : jsonResponse([{ id: "b1", date: "2026-08-01", bestBeforeDate: "2028-08-01", jarCount: 10 }]) - ) - ); + test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => { + // Arrange + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 }))); // Act & Assert - await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError); + await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Schnittstellenversion 2/); }); - describe("fetchApiCoreShopData", () => { - const feed = { - contractVersion: 1, - generatedAt: "2026-09-27T10:00:00.000Z", - harvests: [ - { - id: "h1", - apiaryId: "a1", - apiaryName: "Streuobstwiese", - date: "2026-07-14T00:00:00.000Z", - honeyType: "Sommertracht", - honeySuperCount: null, - grossWeight: null, - netWeight: 41, - // Ein spaeter in ApiCore ergaenztes Feld darf den Sync nicht brechen. - futureField: "wird ignoriert", - }, - ], - products: [{ id: "p1", name: "Sommertracht 500 g", status: "ACTIVE" }], - bottlings: [ - { - id: "b1", - lotNumber: "WH-2026-0001", - date: "2026-08-01T00:00:00.000Z", - bestBeforeDate: "2028-08-01T00:00:00.000Z", - jarSize: 500, - jarCount: 60, - productId: "p1", - harvestIds: ["h1", "h2"], - }, - ], - }; + test("verwirft eine Antwort mit unerwarteter Struktur als Ganzes", async () => { + // Arrange: Abfuellung ohne Pflichtfeld lotNumber. + const broken = { ...feed, bottlings: [{ ...feed.bottlings[0], lotNumber: undefined }] }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(broken))); - test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => { - // Arrange - const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed)); - vi.stubGlobal("fetch", fetchMock); - - // Act - const data = await fetchApiCoreShopData(session); - - // Assert - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync"); - expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null }); - expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 }); - expect(data.products[0].id).toBe("p1"); - }); - - test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => { - // Arrange - const fetchMock = vi.fn(async (url: string) => - url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([]) - ); - vi.stubGlobal("fetch", fetchMock); - - // Act - const data = await fetchApiCoreShopData(session); - - // Assert - const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, "")); - expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"])); - expect(data).toEqual({ harvests: [], products: [], bottlings: [] }); - }); - - test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => { - // Arrange - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 }))); - - // Act & Assert - await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/); - }); - - test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => { - // Arrange - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403))); - - // Act & Assert - await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError); - }); + // Act & Assert + await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/unerwartete Antwortstruktur/); }); }); diff --git a/src/lib/apicore-client.ts b/src/lib/apicore-client.ts index 7559397..20e7f15 100644 --- a/src/lib/apicore-client.ts +++ b/src/lib/apicore-client.ts @@ -1,49 +1,28 @@ import { z } from "zod"; /** - * HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter - * .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB), - * die die Rohdaten fuer den Admin-Sync bereitstellt - * (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen. + * HTTP-Client fuer ApiCore (separates Projekt, NestJS-Backend mit eigener + * Datenbank). Wabenhain liest dort ausschliesslich die dafuer abgesicherte + * Shop-Schnittstelle GET /shop-sync: eine Feld-Allowlist aus Ernte-Fakten, + * Artikelstamm und Abfuellungen, ohne interne Notizen. * - * Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT - * (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen - * ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN - * API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den - * interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt - * (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen - * automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto - * mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs: - * "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData, - * ganz unten). Nur aeltere ApiCore-Staende ohne diese Schnittstelle brauchen - * noch "harvests:read", "products:read", "extractions:read", "bottling:read" - * und optional "apiaries:read". + * Zugang NUR per API-Schluessel (in ApiCore unter Verwaltung → + * Automatisierung → API-Schluessel angelegt, Berechtigung "shop-sync:read"). + * Der Schluessel wird direkt als Bearer-Token gesendet: kein Benutzerkonto, + * kein Passwort, kein Login, in ApiCore jederzeit widerrufbar. Einen + * Rueckfall auf Benutzer/Passwort oder allgemeine Lese-Endpunkte gibt es + * bewusst nicht mehr. * * Benoetigte Umgebungsvariablen (siehe .env.example): - * APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix - * "api" laut ApiCore-Backend/src/app.config.ts) - * APICORE_SYNC_EMAIL E-Mail des Service-Kontos - * APICORE_SYNC_PASSWORD Passwort des Service-Kontos + * APICORE_API_URL z.B. "https://apicore.example.org/api/v1" + * APICORE_API_KEY der Schluessel, Format ack_<8>_<43> * - * Ohne diese drei Variablen wirft openApiCoreSession() sofort einen - * ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message - * dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen. + * Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError — + * der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an. * - * Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt - * (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten, - * Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins - * gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko, - * den eigenen Service-Zugang auszusperren. - * - * Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT - * ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor - * dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch" - * (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen - * Felder passen fast 1:1: lotNumber/code, date/fillDate, - * bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte - * ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId - * -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle - * Schleuderungen geladen (GET /extractions) und clientseitig verknuepft. + * Fuer Wabenhains "Charge" (model Batch) ist ApiCores Abfuellung + * (BottlingBatch) das Gegenstueck, nicht die Rohhonig-Charge: lotNumber/code, + * date/fillDate, bestBeforeDate, jarCount/glassCount. */ export class ApiCoreConfigError extends Error {} @@ -51,11 +30,13 @@ export class ApiCoreRequestError extends Error {} /** * Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore, - * das die Verbindung annimmt, aber nie antwortet, unbegrenzt — der Knopf - * dreht sich, und es gibt weder Ergebnis noch Fehlermeldung. + * das die Verbindung annimmt, aber nie antwortet, unbegrenzt. */ const REQUEST_TIMEOUT_MS = 20_000; +// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel. +const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/; + function describeFetchFailure(error: unknown): string { // Checked by name, not instanceof Error: AbortSignal.timeout() rejects with // a DOMException, which is not an Error subclass in every runtime. @@ -66,193 +47,64 @@ function describeFetchFailure(error: unknown): string { return error instanceof Error ? error.message : "Unbekannter Fehler"; } -const loginResponseSchema = z.union([ - z.object({ accessToken: z.string().min(1) }), - z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }), -]); - -/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */ -export interface ApiCoreSession { +/** Adresse und Schluessel fuer ApiCore. */ +export interface ApiCoreConnection { readonly baseUrl: string; - readonly token: string; + readonly apiKey: string; } -type ApiCoreCredentials = - | { baseUrl: string; apiKey: string } - | { baseUrl: string; email: string; password: string }; - -// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel. -const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/; - -/** - * Bevorzugt einen ApiCore-API-Schluessel (APICORE_API_KEY): kein Passwort, - * keine MFA-Frage, nur die Berechtigung "shop-sync:read", in ApiCore - * jederzeit widerrufbar. E-Mail/Passwort eines Service-Kontos bleibt als - * Rueckfall fuer aeltere ApiCore-Staende ohne API-Schluessel. - */ -function getConfig(): ApiCoreCredentials { +export function getApiCoreConnection(): ApiCoreConnection { const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, ""); const apiKey = process.env.APICORE_API_KEY?.trim(); - const email = process.env.APICORE_SYNC_EMAIL; - const password = process.env.APICORE_SYNC_PASSWORD; - if (baseUrl && apiKey) { - if (!API_KEY_PATTERN.test(apiKey)) { - throw new ApiCoreConfigError( - "APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen." - ); - } - return { baseUrl, apiKey }; - } - if (baseUrl && email && password) { - return { baseUrl, email, password }; - } - throw new ApiCoreConfigError( - "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)." - ); -} - -export async function openApiCoreSession(): Promise { - const config = getConfig(); - // Ein API-Schluessel wird direkt als Bearer-Token gesendet — kein Login. - if ("apiKey" in config) { - return { baseUrl: config.baseUrl, token: config.apiKey }; - } - const { baseUrl, email, password } = config; - - let res: Response; - try { - res = await fetch(`${baseUrl}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - } catch (error: unknown) { - throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`); - } - if (!res.ok) { - throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`); - } - const parsed = loginResponseSchema.safeParse(await res.json()); - if (!parsed.success) { - throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur."); - } - if ("mfaRequired" in parsed.data) { + if (!baseUrl || !apiKey) { throw new ApiCoreConfigError( - "Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)." + "ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)." ); } - return { baseUrl, token: parsed.data.accessToken }; + if (!API_KEY_PATTERN.test(apiKey)) { + throw new ApiCoreConfigError( + "APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen." + ); + } + return { baseUrl, apiKey }; } +// --------------------------------------------------------------------------- +// Datenvertrag der Shop-Schnittstelle +// --------------------------------------------------------------------------- + /** - * Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht - * erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt - * gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche - * URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte - * ApiCore-Version. + * Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore + * erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne + * Versionswechsel dazu und werden hier ignoriert. */ -async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise { - try { - return await fetch(`${session.baseUrl}${path}`, { - headers: { Authorization: `Bearer ${session.token}` }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - } catch (error: unknown) { - throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`); - } -} - -async function getJson( - session: ApiCoreSession, - path: string, - schema: z.ZodType, - label: string -): Promise { - return parseResponse(await fetchFromApiCore(session, path), schema, label); -} - -async function parseResponse(res: Response, schema: z.ZodType, label: string): Promise { - if (!res.ok) { - throw new ApiCoreRequestError( - res.status === 403 - ? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.` - : `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).` - ); - } - const parsed = schema.safeParse(await res.json()); - if (!parsed.success) { - throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`); - } - return parsed.data; -} - -// --------------------------------------------------------------------------- -// Ernten -// --------------------------------------------------------------------------- - -const apiarySchema = z.object({ id: z.string(), name: z.string() }); +export const SUPPORTED_SHOP_SYNC_CONTRACT = 1; const harvestSchema = z.object({ id: z.string(), apiaryId: z.string(), + apiaryName: z.string(), date: z.string(), - honeyType: z.string().nullable().optional(), - honeySuperCount: z.number().nullable().optional(), - grossWeight: z.number().nullable().optional(), - netWeight: z.number().nullable().optional(), - notes: z.string().nullable().optional(), + honeyType: z.string().nullable(), + honeySuperCount: z.number().nullable(), + grossWeight: z.number().nullable(), + netWeight: z.number().nullable(), }); export type ApiCoreHarvest = z.infer; -export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest { - apiaryName: string | null; -} - -/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */ -async function fetchApiaryNames(session: ApiCoreSession): Promise> { - try { - const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte"); - return new Map(apiaries.map((a) => [a.id, a.name])); - } catch { - return new Map(); - } -} - -export async function fetchApiCoreHarvests( - session: ApiCoreSession -): Promise { - const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten"); - const apiaryNames = await fetchApiaryNames(session); - return harvests.map((harvest) => ({ - ...harvest, - apiaryName: apiaryNames.get(harvest.apiaryId) ?? null, - })); -} - -// --------------------------------------------------------------------------- -// Produkte -// --------------------------------------------------------------------------- - /** - * ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste - * Abweichungen vom dortigen Prisma-Modell: + * ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell: * - * - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores - * ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein - * dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen - * und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den - * einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus(). - * - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit - * — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse", - * "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil - * ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit - * Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g" - * ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist - * damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das - * uebernommene Gewicht am importierten Entwurf im Admin sichtbar und - * aenderbar, bevor das Produkt ueberhaupt verkauft werden kann. + * - `status` wird als freier String gelesen, nicht als Enum: kaeme in ApiCore + * ein weiterer Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort + * verwerfen, statt nur den einen unbekannten Artikel zu ignorieren. Siehe + * isSellableStatus(). + * - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore keine Einheit. + * Angenommen wird GRAMM, weil ApiCore dieselbe Groesse an der Abfuellung + * (jarSize) mit Werten wie 500 fuehrt und in der eigenen Oberflaeche als + * "g" ausgibt. Das uebernommene Gewicht ist am importierten Entwurf im Admin + * sichtbar und aenderbar, bevor das Produkt verkauft werden kann. */ const productSchema = z.object({ id: z.string(), @@ -268,165 +120,92 @@ const productSchema = z.object({ export type ApiCoreProduct = z.infer; /** - * Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED, - * morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig" - * behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel - * ungeprueft als Shop-Entwurf auftaucht. + * Nur ACTIVE gilt als verkaufsfaehig. Alles andere wird bewusst als "nicht + * verkaufsfaehig" behandelt: ein unbekannter Status darf nicht dazu fuehren, + * dass ein Artikel ungeprueft als Shop-Entwurf auftaucht. */ export function isSellableStatus(status: string): boolean { return status === "ACTIVE"; } -export async function fetchApiCoreProducts(session: ApiCoreSession): Promise { - return getJson(session, "/products", z.array(productSchema), "Produkte"); -} - -// --------------------------------------------------------------------------- -// Chargen (BottlingBatch, siehe Modul-Kommentar oben) -// --------------------------------------------------------------------------- - -const extractionSchema = z.object({ - id: z.string(), - // ApiCores GET /extractions liefert je Schleuderung die verknuepften - // Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die - // uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht. - harvests: z.array(z.object({ harvestId: z.string() })), -}); - -const bottlingBatchSchema = z.object({ +const bottlingSchema = z.object({ id: z.string(), lotNumber: z.string(), date: z.string(), bestBeforeDate: z.string(), + // Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema). + jarSize: z.number().nullable(), jarCount: z.number(), - // Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an - // productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld - // heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am - // Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu - // verwerfen. - jarSize: z.number().nullable().optional(), - // Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein. - // Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge - // mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts). - productId: z.string().nullable().optional(), - honeyBatch: z.object({ - id: z.string(), - extractionId: z.string(), - }), + // Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein. + productId: z.string().nullable(), + harvestIds: z.array(z.string()), }); -export type ApiCoreBottlingBatch = z.infer; - -/** - * Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus - * der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren - * Einzel-Endpunkten stammt. - */ +/** Eine Abfuellung, wie der Sync sie braucht. */ export interface ApiCoreBottlingBatchWithHarvestId { id: string; lotNumber: string; date: string; bestBeforeDate: string; jarCount: number; - jarSize?: number | null; - productId?: string | null; + jarSize: number | null; + productId: string | null; /** - * ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null - * wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine - * Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest - * ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein - * einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste - * verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit - * auf die tatsaechlich passende Ernte korrigieren. + * ApiCore-ID der ersten (aeltesten) Ernte hinter der Abfuellung, oder null. + * Eine Schleuderung kann mehrere Ernten buendeln, Wabenhains Batch.harvestId + * ist dagegen ein einzelner Fremdschluessel — der Admin kann die Zuordnung + * in Wabenhain jederzeit korrigieren. */ harvestSourceId: string | null; } -export async function fetchApiCoreBottlingBatches( - session: ApiCoreSession -): Promise { - const [extractions, batches] = await Promise.all([ - getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"), - getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"), - ]); - - const firstHarvestByExtraction = new Map( - extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null]) - ); - - return batches.map((batch) => ({ - ...batch, - harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null, - })); -} - -// --------------------------------------------------------------------------- -// Shop-Schnittstelle (ApiCore GET /shop-sync) -// --------------------------------------------------------------------------- - -/** - * Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore - * erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne - * Versionswechsel dazu und werden hier ignoriert. - */ -export const SUPPORTED_SHOP_SYNC_CONTRACT = 1; - const shopFeedSchema = z.object({ contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT), - harvests: z.array( - z.object({ - id: z.string(), - apiaryId: z.string(), - apiaryName: z.string(), - date: z.string(), - honeyType: z.string().nullable(), - honeySuperCount: z.number().nullable(), - grossWeight: z.number().nullable(), - netWeight: z.number().nullable(), - }) - ), + harvests: z.array(harvestSchema), products: z.array(productSchema), - bottlings: z.array( - z.object({ - id: z.string(), - lotNumber: z.string(), - date: z.string(), - bestBeforeDate: z.string(), - jarSize: z.number().nullable(), - jarCount: z.number(), - productId: z.string().nullable(), - harvestIds: z.array(z.string()), - }) - ), + bottlings: z.array(bottlingSchema), }); export interface ApiCoreShopData { - harvests: ApiCoreHarvestWithApiaryName[]; + harvests: ApiCoreHarvest[]; products: ApiCoreProduct[]; bottlings: ApiCoreBottlingBatchWithHarvestId[]; } +function httpErrorMessage(status: number): string { + if (status === 401) { + return "ApiCore hat den API-Schlüssel abgelehnt (HTTP 401) — widerrufen, abgelaufen oder falsch kopiert. Bitte in ApiCore einen neuen Schlüssel anlegen."; + } + if (status === 403) { + return "Dem API-Schlüssel fehlt die Berechtigung „Shop-Daten lesen“ (HTTP 403)."; + } + if (status === 404) { + return "ApiCore bietet die Shop-Schnittstelle nicht an (HTTP 404) — bitte ApiCore aktualisieren oder APICORE_API_URL prüfen."; + } + return `ApiCore: Shop-Daten konnten nicht geladen werden (HTTP ${status}).`; +} + /** - * Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores - * eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine - * Notizen) und braucht nur die Berechtigung "shop-sync:read". - * - * Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand), - * faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen - * beide Systeme unabhaengig voneinander aktualisiert werden. + * Alles, was der Sync braucht, in EINEM konsistenten Abruf. Jeder Fehler + * (nicht erreichbar, abgelehnt, unbekannte Version, unerwartete Struktur) + * bricht ab — ein fehlgeschlagener Abruf wird nie als leere Datenquelle + * behandelt. */ -export async function fetchApiCoreShopData(session: ApiCoreSession): Promise { - const res = await fetchFromApiCore(session, "/shop-sync"); - if (res.status === 404) { - const [harvests, products, bottlings] = await Promise.all([ - fetchApiCoreHarvests(session), - fetchApiCoreProducts(session), - fetchApiCoreBottlingBatches(session), - ]); - return { harvests, products, bottlings }; +export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promise { + let res: Response; + try { + res = await fetch(`${connection.baseUrl}/shop-sync`, { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error: unknown) { + throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`); + } + if (!res.ok) { + throw new ApiCoreRequestError(httpErrorMessage(res.status)); } - const body = await parseResponse(res, z.unknown(), "Shop-Daten"); + const body: unknown = await res.json(); const version = z.object({ contractVersion: z.number() }).safeParse(body); if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) { throw new ApiCoreRequestError( @@ -435,11 +214,11 @@ export async function fetchApiCoreShopData(session: ApiCoreSession): Promise ({ ...harvest, notes: null })), + harvests: parsed.data.harvests, products: parsed.data.products, bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({ ...bottling, diff --git a/src/lib/apicore-mapping.test.ts b/src/lib/apicore-mapping.test.ts index 14da1ee..9142d1b 100644 --- a/src/lib/apicore-mapping.test.ts +++ b/src/lib/apicore-mapping.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client"; +import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client"; import { slugForApiCoreSource, buildHarvestDescription, @@ -26,9 +26,7 @@ function makeProduct(overrides: Partial = {}): ApiCoreProduct { }; } -function makeHarvest( - overrides: Partial = {} -): ApiCoreHarvestWithApiaryName { +function makeHarvest(overrides: Partial = {}): ApiCoreHarvest { return { id: "cm-harvest-1", apiaryId: "cm-apiary-1", @@ -37,7 +35,6 @@ function makeHarvest( honeySuperCount: null, grossWeight: null, netWeight: null, - notes: null, apiaryName: "Streuobstwiese", ...overrides, }; @@ -79,17 +76,6 @@ describe("buildHarvestDescription", () => { ); }); - test("uebernimmt interne ApiCore-Notizen nie in den Shoptext", () => { - // Arrange: Betriebsnotiz, die nicht oeffentlich werden darf. - const source = makeHarvest({ netWeight: 41, notes: "Volk 7 nach Varroa-Behandlung schwach" }); - - // Act - const description = buildHarvestDescription(source); - - // Assert - expect(description).not.toContain("Varroa"); - expect(description).not.toContain("Volk 7"); - }); test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => { // Arrange diff --git a/src/lib/apicore-mapping.ts b/src/lib/apicore-mapping.ts index c97093e..046b55a 100644 --- a/src/lib/apicore-mapping.ts +++ b/src/lib/apicore-mapping.ts @@ -1,4 +1,4 @@ -import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client"; +import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client"; /** * Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von @@ -31,7 +31,7 @@ export function slugForApiCoreSource(sourceId: string): string { * und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick * auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen. */ -export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string { +export function buildHarvestDescription(source: ApiCoreHarvest): string { const parts: string[] = []; if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`); if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`); diff --git a/src/lib/integration-status.test.ts b/src/lib/integration-status.test.ts index 2c9e958..60c4ac2 100644 --- a/src/lib/integration-status.test.ts +++ b/src/lib/integration-status.test.ts @@ -20,8 +20,7 @@ const FULL = { DHL_GKP_PASSWORD: "w", DHL_ACCOUNT_NUMBER: "12345678901234", APICORE_API_URL: "https://api.example.org/api", - APICORE_SYNC_EMAIL: "sync@example.org", - APICORE_SYNC_PASSWORD: "pw", + APICORE_API_KEY: "ack_secret", }; describe("readIntegrationStatus", () => { @@ -47,23 +46,15 @@ describe("readIntegrationStatus", () => { }); it("never returns a configured value", () => { - expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/); + expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|ack_secret/); }); - it("accepts an ApiCore API key instead of the legacy email/password", () => { - const { APICORE_SYNC_EMAIL, APICORE_SYNC_PASSWORD, ...withoutPassword } = FULL; - void APICORE_SYNC_EMAIL; - void APICORE_SYNC_PASSWORD; - const apicore = readIntegrationStatus({ ...withoutPassword, APICORE_API_KEY: "ack_x" }).find( - (s) => s.key === "apicore" - ); - expect(apicore).toMatchObject({ configured: true, missing: [] }); - }); - - it("asks for the API key when neither key nor legacy login is set", () => { - const apicore = readIntegrationStatus({ APICORE_API_URL: "https://api.example.org/api" }).find( - (s) => s.key === "apicore" - ); + it("needs the ApiCore API key - there is no password login any more", () => { + const apicore = readIntegrationStatus({ + APICORE_API_URL: "https://api.example.org/api", + APICORE_SYNC_EMAIL: "sync@example.org", + APICORE_SYNC_PASSWORD: "pw", + }).find((s) => s.key === "apicore"); expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] }); }); diff --git a/src/lib/integration-status.ts b/src/lib/integration-status.ts index f685354..6faead7 100644 --- a/src/lib/integration-status.ts +++ b/src/lib/integration-status.ts @@ -28,7 +28,6 @@ const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[ { key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] }, { key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] }, { key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] }, - // Zugang per APICORE_API_KEY; E-Mail/Passwort zaehlt als Rueckfall, siehe apicoreMissing(). { key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] }, ]; @@ -46,18 +45,9 @@ function stripeWarning(env: Env): IntegrationWarning | undefined { return undefined; } -/** Ein API-Schluessel ODER (Rueckfall) E-Mail + Passwort des Service-Kontos. */ -function apicoreMissing(env: Env): string[] { - const missing = env.APICORE_API_URL?.trim() ? [] : ["APICORE_API_URL"]; - const hasKey = Boolean(env.APICORE_API_KEY?.trim()); - const hasPassword = Boolean(env.APICORE_SYNC_EMAIL?.trim() && env.APICORE_SYNC_PASSWORD?.trim()); - return hasKey || hasPassword ? missing : [...missing, "APICORE_API_KEY"]; -} - export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] { return INTEGRATIONS.map(({ key, required, variables }) => { - const missing = - key === "apicore" ? apicoreMissing(env) : variables.filter((name) => !env[name]?.trim()); + const missing = variables.filter((name) => !env[name]?.trim()); const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing }; const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined; return warning ? { ...status, warning } : status;