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]>
This commit is contained in:
1 parent
55be1fe881
commit
3bc0693e4d
10 files changed
+220
-520
No files matched your search
+6
-20
@@ -91,29 +91,15 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_FROM="Wabenhain <[email protected]>"
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
|
|
||||||
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
|
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
|
||||||
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
|
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
|
||||||
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
|
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
|
||||||
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
|
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
|
||||||
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
|
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
|
||||||
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
|
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
|
||||||
# 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
|
|
||||||
# Details siehe src/lib/apicore-client.ts.
|
# Details siehe src/lib/apicore-client.ts.
|
||||||
APICORE_API_URL=
|
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=
|
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) —
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||||
|
|||||||
@@ -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-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
|
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` 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
|
## Vollständiger Docker-Betrieb
|
||||||
|
|
||||||
|
|||||||
@@ -102,19 +102,12 @@ stringData:
|
|||||||
# ist dagegen oeffentlich und steht dort.
|
# ist dagegen oeffentlich und steht dort.
|
||||||
GOOGLE_PLACES_API_KEY: ""
|
GOOGLE_PLACES_API_KEY: ""
|
||||||
|
|
||||||
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
|
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
|
||||||
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
|
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
|
||||||
# Konfigurationsfehler, statt still nichts zu tun.
|
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
|
||||||
#
|
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
|
||||||
# Empfohlen: API-Schluessel aus ApiCore (Verwaltung -> Automatisierung ->
|
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
|
||||||
# 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.
|
|
||||||
APICORE_API_KEY: ""
|
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
|
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
||||||
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
getApiCoreConnection,
|
||||||
fetchApiCoreShopData,
|
fetchApiCoreShopData,
|
||||||
isSellableStatus,
|
isSellableStatus,
|
||||||
ApiCoreConfigError,
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreShopData,
|
type ApiCoreShopData,
|
||||||
type ApiCoreHarvestWithApiaryName,
|
type ApiCoreHarvest,
|
||||||
type ApiCoreProduct,
|
type ApiCoreProduct,
|
||||||
type ApiCoreBottlingBatchWithHarvestId,
|
type ApiCoreBottlingBatchWithHarvestId,
|
||||||
} from "@/lib/apicore-client";
|
} from "@/lib/apicore-client";
|
||||||
@@ -128,7 +128,7 @@ function describeError(error: unknown): string {
|
|||||||
// Ernten
|
// Ernten
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
|
async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
|
||||||
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
@@ -480,7 +480,7 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
let source: ApiCoreShopData;
|
let source: ApiCoreShopData;
|
||||||
try {
|
try {
|
||||||
source = await fetchApiCoreShopData(await openApiCoreSession());
|
source = await fetchApiCoreShopData(getApiCoreConnection());
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-111
@@ -1,14 +1,14 @@
|
|||||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, vi, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
openApiCoreSession,
|
getApiCoreConnection,
|
||||||
fetchApiCoreHarvests,
|
|
||||||
fetchApiCoreBottlingBatches,
|
|
||||||
fetchApiCoreShopData,
|
fetchApiCoreShopData,
|
||||||
|
ApiCoreConfigError,
|
||||||
ApiCoreRequestError,
|
ApiCoreRequestError,
|
||||||
type ApiCoreSession,
|
type ApiCoreConnection,
|
||||||
} from "./apicore-client";
|
} 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 {
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -17,92 +17,6 @@ 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", "[email protected]");
|
|
||||||
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
|
|
||||||
// Arrange
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError"))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nutzt einen API-Schluessel direkt als Bearer-Token, ohne Passwort-Login", 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");
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(openApiCoreSession()).rejects.toThrow(/Format eines ApiCore-API-Schlüssels/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("setzt an jeder Anfrage ein Zeitlimit", async () => {
|
|
||||||
// Arrange
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(fetchApiCoreHarvests(session)).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 }])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchApiCoreShopData", () => {
|
|
||||||
const feed = {
|
const feed = {
|
||||||
contractVersion: 1,
|
contractVersion: 1,
|
||||||
generatedAt: "2026-09-27T10:00:00.000Z",
|
generatedAt: "2026-09-27T10:00:00.000Z",
|
||||||
@@ -135,36 +49,97 @@ describe("apicore-client", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => {
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", "[email protected]");
|
||||||
|
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
|
// Arrange
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const data = await fetchApiCoreShopData(session);
|
const data = await fetchApiCoreShopData(connection);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync");
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null });
|
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.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
|
||||||
expect(data.products[0].id).toBe("p1");
|
expect(data.products[0].id).toBe("p1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => {
|
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const fetchMock = vi.fn(async (url: string) =>
|
vi.stubGlobal(
|
||||||
url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([])
|
"fetch",
|
||||||
|
vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("benennt einen widerrufenen oder abgelaufenen Schluessel", async () => {
|
||||||
|
// Arrange
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Unauthorized" }, 401)));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/API-Schlüssel abgelehnt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
// Act
|
// Act & Assert
|
||||||
const data = await fetchApiCoreShopData(session);
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Assert
|
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
|
||||||
const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, ""));
|
// Arrange
|
||||||
expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"]));
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
|
||||||
expect(data).toEqual({ harvests: [], products: [], bottlings: [] });
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(fetchApiCoreShopData(connection)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
|
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
|
||||||
@@ -172,15 +147,15 @@ describe("apicore-client", () => {
|
|||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/);
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Schnittstellenversion 2/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => {
|
test("verwirft eine Antwort mit unerwarteter Struktur als Ganzes", async () => {
|
||||||
// Arrange
|
// Arrange: Abfuellung ohne Pflichtfeld lotNumber.
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403)));
|
const broken = { ...feed, bottlings: [{ ...feed.bottlings[0], lotNumber: undefined }] };
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(broken)));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
|
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/unerwartete Antwortstruktur/);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+105
-326
@@ -1,49 +1,28 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
|
* HTTP-Client fuer ApiCore (separates Projekt, NestJS-Backend mit eigener
|
||||||
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
|
* Datenbank). Wabenhain liest dort ausschliesslich die dafuer abgesicherte
|
||||||
* die die Rohdaten fuer den Admin-Sync bereitstellt
|
* Shop-Schnittstelle GET /shop-sync: eine Feld-Allowlist aus Ernte-Fakten,
|
||||||
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
|
* Artikelstamm und Abfuellungen, ohne interne Notizen.
|
||||||
*
|
*
|
||||||
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
|
* Zugang NUR per API-Schluessel (in ApiCore unter Verwaltung →
|
||||||
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
|
* Automatisierung → API-Schluessel angelegt, Berechtigung "shop-sync:read").
|
||||||
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
|
* Der Schluessel wird direkt als Bearer-Token gesendet: kein Benutzerkonto,
|
||||||
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
|
* kein Passwort, kein Login, in ApiCore jederzeit widerrufbar. Einen
|
||||||
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
|
* Rueckfall auf Benutzer/Passwort oder allgemeine Lese-Endpunkte gibt es
|
||||||
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
|
* bewusst nicht mehr.
|
||||||
* 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".
|
|
||||||
*
|
*
|
||||||
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
* Benoetigte Umgebungsvariablen (siehe .env.example):
|
||||||
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
|
* APICORE_API_URL z.B. "https://apicore.example.org/api/v1"
|
||||||
* "api" laut ApiCore-Backend/src/app.config.ts)
|
* APICORE_API_KEY der Schluessel, Format ack_<8>_<43>
|
||||||
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
|
|
||||||
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
|
|
||||||
*
|
*
|
||||||
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
|
* Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError —
|
||||||
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
|
* der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an.
|
||||||
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
|
|
||||||
*
|
*
|
||||||
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
|
* Fuer Wabenhains "Charge" (model Batch) ist ApiCores Abfuellung
|
||||||
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
|
* (BottlingBatch) das Gegenstueck, nicht die Rohhonig-Charge: lotNumber/code,
|
||||||
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
|
* date/fillDate, bestBeforeDate, jarCount/glassCount.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class ApiCoreConfigError extends Error {}
|
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,
|
* Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore,
|
||||||
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt — der Knopf
|
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt.
|
||||||
* dreht sich, und es gibt weder Ergebnis noch Fehlermeldung.
|
|
||||||
*/
|
*/
|
||||||
const REQUEST_TIMEOUT_MS = 20_000;
|
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 {
|
function describeFetchFailure(error: unknown): string {
|
||||||
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
|
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
|
||||||
// a DOMException, which is not an Error subclass in every runtime.
|
// a DOMException, which is not an Error subclass in every runtime.
|
||||||
@@ -66,36 +47,20 @@ function describeFetchFailure(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : "Unbekannter Fehler";
|
return error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginResponseSchema = z.union([
|
/** Adresse und Schluessel fuer ApiCore. */
|
||||||
z.object({ accessToken: z.string().min(1) }),
|
export interface ApiCoreConnection {
|
||||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
|
|
||||||
export interface ApiCoreSession {
|
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
readonly token: string;
|
readonly apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiCoreCredentials =
|
export function getApiCoreConnection(): ApiCoreConnection {
|
||||||
| { 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 {
|
|
||||||
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
|
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
|
||||||
const apiKey = process.env.APICORE_API_KEY?.trim();
|
const apiKey = process.env.APICORE_API_KEY?.trim();
|
||||||
const email = process.env.APICORE_SYNC_EMAIL;
|
if (!baseUrl || !apiKey) {
|
||||||
const password = process.env.APICORE_SYNC_PASSWORD;
|
throw new ApiCoreConfigError(
|
||||||
if (baseUrl && apiKey) {
|
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!API_KEY_PATTERN.test(apiKey)) {
|
if (!API_KEY_PATTERN.test(apiKey)) {
|
||||||
throw new ApiCoreConfigError(
|
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."
|
"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."
|
||||||
@@ -103,156 +68,43 @@ function getConfig(): ApiCoreCredentials {
|
|||||||
}
|
}
|
||||||
return { baseUrl, apiKey };
|
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<ApiCoreSession> {
|
// ---------------------------------------------------------------------------
|
||||||
const config = getConfig();
|
// Datenvertrag der Shop-Schnittstelle
|
||||||
// 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) {
|
|
||||||
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)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { baseUrl, token: parsed.data.accessToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
|
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
|
||||||
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
|
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
|
||||||
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
|
* Versionswechsel dazu und werden hier ignoriert.
|
||||||
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
|
|
||||||
* ApiCore-Version.
|
|
||||||
*/
|
*/
|
||||||
async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise<Response> {
|
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
|
||||||
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<T>(
|
|
||||||
session: ApiCoreSession,
|
|
||||||
path: string,
|
|
||||||
schema: z.ZodType<T>,
|
|
||||||
label: string
|
|
||||||
): Promise<T> {
|
|
||||||
return parseResponse(await fetchFromApiCore(session, path), schema, label);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseResponse<T>(res: Response, schema: z.ZodType<T>, label: string): Promise<T> {
|
|
||||||
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() });
|
|
||||||
|
|
||||||
const harvestSchema = z.object({
|
const harvestSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
apiaryId: z.string(),
|
apiaryId: z.string(),
|
||||||
|
apiaryName: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
honeyType: z.string().nullable().optional(),
|
honeyType: z.string().nullable(),
|
||||||
honeySuperCount: z.number().nullable().optional(),
|
honeySuperCount: z.number().nullable(),
|
||||||
grossWeight: z.number().nullable().optional(),
|
grossWeight: z.number().nullable(),
|
||||||
netWeight: z.number().nullable().optional(),
|
netWeight: z.number().nullable(),
|
||||||
notes: z.string().nullable().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
|
||||||
|
|
||||||
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
|
|
||||||
apiaryName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
|
|
||||||
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
|
|
||||||
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<ApiCoreHarvestWithApiaryName[]> {
|
|
||||||
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
|
* ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell:
|
||||||
* Abweichungen vom dortigen Prisma-Modell:
|
|
||||||
*
|
*
|
||||||
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
|
* - `status` wird als freier String gelesen, nicht als Enum: kaeme in ApiCore
|
||||||
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
|
* ein weiterer Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort
|
||||||
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
|
* verwerfen, statt nur den einen unbekannten Artikel zu ignorieren. Siehe
|
||||||
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
|
* isSellableStatus().
|
||||||
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
|
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore keine Einheit.
|
||||||
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
|
* Angenommen wird GRAMM, weil ApiCore dieselbe Groesse an der Abfuellung
|
||||||
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
|
* (jarSize) mit Werten wie 500 fuehrt und in der eigenen Oberflaeche als
|
||||||
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
|
* "g" ausgibt. Das uebernommene Gewicht ist am importierten Entwurf im Admin
|
||||||
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
|
* sichtbar und aenderbar, bevor das Produkt verkauft werden kann.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -268,165 +120,92 @@ const productSchema = z.object({
|
|||||||
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
export type ApiCoreProduct = z.infer<typeof productSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
|
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere wird bewusst als "nicht
|
||||||
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
|
* verkaufsfaehig" behandelt: ein unbekannter Status darf nicht dazu fuehren,
|
||||||
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
|
* dass ein Artikel ungeprueft als Shop-Entwurf auftaucht.
|
||||||
* ungeprueft als Shop-Entwurf auftaucht.
|
|
||||||
*/
|
*/
|
||||||
export function isSellableStatus(status: string): boolean {
|
export function isSellableStatus(status: string): boolean {
|
||||||
return status === "ACTIVE";
|
return status === "ACTIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
|
const bottlingSchema = z.object({
|
||||||
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({
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
lotNumber: z.string(),
|
lotNumber: z.string(),
|
||||||
date: z.string(),
|
date: z.string(),
|
||||||
bestBeforeDate: z.string(),
|
bestBeforeDate: z.string(),
|
||||||
|
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
|
||||||
|
jarSize: z.number().nullable(),
|
||||||
jarCount: z.number(),
|
jarCount: z.number(),
|
||||||
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
|
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
|
||||||
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
|
productId: z.string().nullable(),
|
||||||
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
|
harvestIds: z.array(z.string()),
|
||||||
// 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(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
|
/** Eine Abfuellung, wie der Sync sie braucht. */
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus
|
|
||||||
* der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren
|
|
||||||
* Einzel-Endpunkten stammt.
|
|
||||||
*/
|
|
||||||
export interface ApiCoreBottlingBatchWithHarvestId {
|
export interface ApiCoreBottlingBatchWithHarvestId {
|
||||||
id: string;
|
id: string;
|
||||||
lotNumber: string;
|
lotNumber: string;
|
||||||
date: string;
|
date: string;
|
||||||
bestBeforeDate: string;
|
bestBeforeDate: string;
|
||||||
jarCount: number;
|
jarCount: number;
|
||||||
jarSize?: number | null;
|
jarSize: number | null;
|
||||||
productId?: string | null;
|
productId: string | null;
|
||||||
/**
|
/**
|
||||||
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
|
* ApiCore-ID der ersten (aeltesten) Ernte hinter der Abfuellung, oder null.
|
||||||
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
|
* Eine Schleuderung kann mehrere Ernten buendeln, Wabenhains Batch.harvestId
|
||||||
* Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest
|
* ist dagegen ein einzelner Fremdschluessel — der Admin kann die Zuordnung
|
||||||
* ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein
|
* in Wabenhain jederzeit korrigieren.
|
||||||
* einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste
|
|
||||||
* verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit
|
|
||||||
* auf die tatsaechlich passende Ernte korrigieren.
|
|
||||||
*/
|
*/
|
||||||
harvestSourceId: string | null;
|
harvestSourceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiCoreBottlingBatches(
|
|
||||||
session: ApiCoreSession
|
|
||||||
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
|
|
||||||
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<string, string | null>(
|
|
||||||
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({
|
const shopFeedSchema = z.object({
|
||||||
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
|
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
|
||||||
harvests: z.array(
|
harvests: z.array(harvestSchema),
|
||||||
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(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
products: z.array(productSchema),
|
products: z.array(productSchema),
|
||||||
bottlings: z.array(
|
bottlings: z.array(bottlingSchema),
|
||||||
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()),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ApiCoreShopData {
|
export interface ApiCoreShopData {
|
||||||
harvests: ApiCoreHarvestWithApiaryName[];
|
harvests: ApiCoreHarvest[];
|
||||||
products: ApiCoreProduct[];
|
products: ApiCoreProduct[];
|
||||||
bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function httpErrorMessage(status: number): string {
|
||||||
* Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores
|
if (status === 401) {
|
||||||
* eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine
|
return "ApiCore hat den API-Schlüssel abgelehnt (HTTP 401) — widerrufen, abgelaufen oder falsch kopiert. Bitte in ApiCore einen neuen Schlüssel anlegen.";
|
||||||
* Notizen) und braucht nur die Berechtigung "shop-sync:read".
|
}
|
||||||
*
|
if (status === 403) {
|
||||||
* Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand),
|
return "Dem API-Schlüssel fehlt die Berechtigung „Shop-Daten lesen“ (HTTP 403).";
|
||||||
* faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen
|
}
|
||||||
* beide Systeme unabhaengig voneinander aktualisiert werden.
|
if (status === 404) {
|
||||||
*/
|
return "ApiCore bietet die Shop-Schnittstelle nicht an (HTTP 404) — bitte ApiCore aktualisieren oder APICORE_API_URL prüfen.";
|
||||||
export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<ApiCoreShopData> {
|
}
|
||||||
const res = await fetchFromApiCore(session, "/shop-sync");
|
return `ApiCore: Shop-Daten konnten nicht geladen werden (HTTP ${status}).`;
|
||||||
if (res.status === 404) {
|
|
||||||
const [harvests, products, bottlings] = await Promise.all([
|
|
||||||
fetchApiCoreHarvests(session),
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
fetchApiCoreBottlingBatches(session),
|
|
||||||
]);
|
|
||||||
return { harvests, products, bottlings };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await parseResponse(res, z.unknown(), "Shop-Daten");
|
/**
|
||||||
|
* 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(connection: ApiCoreConnection): Promise<ApiCoreShopData> {
|
||||||
|
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: unknown = await res.json();
|
||||||
const version = z.object({ contractVersion: z.number() }).safeParse(body);
|
const version = z.object({ contractVersion: z.number() }).safeParse(body);
|
||||||
if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) {
|
if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) {
|
||||||
throw new ApiCoreRequestError(
|
throw new ApiCoreRequestError(
|
||||||
@@ -435,11 +214,11 @@ export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<Api
|
|||||||
}
|
}
|
||||||
const parsed = shopFeedSchema.safeParse(body);
|
const parsed = shopFeedSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Shop-Daten.");
|
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Shop-Daten.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
|
harvests: parsed.data.harvests,
|
||||||
products: parsed.data.products,
|
products: parsed.data.products,
|
||||||
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
|
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
|
||||||
...bottling,
|
...bottling,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
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 {
|
import {
|
||||||
slugForApiCoreSource,
|
slugForApiCoreSource,
|
||||||
buildHarvestDescription,
|
buildHarvestDescription,
|
||||||
@@ -26,9 +26,7 @@ function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeHarvest(
|
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
|
||||||
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
|
|
||||||
): ApiCoreHarvestWithApiaryName {
|
|
||||||
return {
|
return {
|
||||||
id: "cm-harvest-1",
|
id: "cm-harvest-1",
|
||||||
apiaryId: "cm-apiary-1",
|
apiaryId: "cm-apiary-1",
|
||||||
@@ -37,7 +35,6 @@ function makeHarvest(
|
|||||||
honeySuperCount: null,
|
honeySuperCount: null,
|
||||||
grossWeight: null,
|
grossWeight: null,
|
||||||
netWeight: null,
|
netWeight: null,
|
||||||
notes: null,
|
|
||||||
apiaryName: "Streuobstwiese",
|
apiaryName: "Streuobstwiese",
|
||||||
...overrides,
|
...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", () => {
|
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -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
|
* 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
|
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
|
||||||
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
|
* 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[] = [];
|
const parts: string[] = [];
|
||||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ const FULL = {
|
|||||||
DHL_GKP_PASSWORD: "w",
|
DHL_GKP_PASSWORD: "w",
|
||||||
DHL_ACCOUNT_NUMBER: "12345678901234",
|
DHL_ACCOUNT_NUMBER: "12345678901234",
|
||||||
APICORE_API_URL: "https://api.example.org/api",
|
APICORE_API_URL: "https://api.example.org/api",
|
||||||
APICORE_SYNC_EMAIL: "[email protected]",
|
APICORE_API_KEY: "ack_secret",
|
||||||
APICORE_SYNC_PASSWORD: "pw",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("readIntegrationStatus", () => {
|
describe("readIntegrationStatus", () => {
|
||||||
@@ -47,23 +46,15 @@ describe("readIntegrationStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never returns a configured value", () => {
|
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", () => {
|
it("needs the ApiCore API key - there is no password login any more", () => {
|
||||||
const { APICORE_SYNC_EMAIL, APICORE_SYNC_PASSWORD, ...withoutPassword } = FULL;
|
const apicore = readIntegrationStatus({
|
||||||
void APICORE_SYNC_EMAIL;
|
APICORE_API_URL: "https://api.example.org/api",
|
||||||
void APICORE_SYNC_PASSWORD;
|
APICORE_SYNC_EMAIL: "[email protected]",
|
||||||
const apicore = readIntegrationStatus({ ...withoutPassword, APICORE_API_KEY: "ack_x" }).find(
|
APICORE_SYNC_PASSWORD: "pw",
|
||||||
(s) => s.key === "apicore"
|
}).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"
|
|
||||||
);
|
|
||||||
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
|
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: "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: "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"] },
|
{ 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"] },
|
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -46,18 +45,9 @@ function stripeWarning(env: Env): IntegrationWarning | undefined {
|
|||||||
return 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[] {
|
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
|
||||||
return INTEGRATIONS.map(({ key, required, variables }) => {
|
return INTEGRATIONS.map(({ key, required, variables }) => {
|
||||||
const missing =
|
const missing = variables.filter((name) => !env[name]?.trim());
|
||||||
key === "apicore" ? apicoreMissing(env) : variables.filter((name) => !env[name]?.trim());
|
|
||||||
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
|
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
|
||||||
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
|
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
|
||||||
return warning ? { ...status, warning } : status;
|
return warning ? { ...status, warning } : status;
|
||||||
|
|||||||
Reference in new issue
Block a user