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
+248
-548
No files matched your search
+112
-137
@@ -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", "[email protected]");
|
||||
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", "[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
|
||||
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/);
|
||||
});
|
||||
});
|
||||
+107
-328
@@ -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<ApiCoreSession> {
|
||||
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<Response> {
|
||||
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() });
|
||||
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<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
|
||||
* 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<typeof productSchema>;
|
||||
|
||||
/**
|
||||
* 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<ApiCoreProduct[]> {
|
||||
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<typeof bottlingBatchSchema>;
|
||||
|
||||
/**
|
||||
* 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<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({
|
||||
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<ApiCoreShopData> {
|
||||
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<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 = 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<Api
|
||||
}
|
||||
const parsed = shopFeedSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Shop-Daten.");
|
||||
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Shop-Daten.");
|
||||
}
|
||||
|
||||
return {
|
||||
harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
|
||||
harvests: parsed.data.harvests,
|
||||
products: parsed.data.products,
|
||||
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
|
||||
...bottling,
|
||||
|
||||
@@ -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> = {}): ApiCoreProduct {
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarvest(
|
||||
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
|
||||
): ApiCoreHarvestWithApiaryName {
|
||||
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): 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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
APICORE_SYNC_PASSWORD: "pw",
|
||||
}).find((s) => s.key === "apicore");
|
||||
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: "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;
|
||||
|
||||
Reference in new issue
Block a user