fix(apicore-sync): keep internal notes out of the shop and protect edited drafts
- Harvest descriptions no longer include ApiCore's internal harvest notes. The description becomes public on "Veröffentlichen", so a draft published without a careful read could have carried operational notes into the shop. - A repeated sync overwrote unpublished harvest drafts, undoing the admin's corrections (season instead of honey type, location, sales text). Drafts with an admin audit entry are now left alone and reported as protected. - Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted the connection but never answered left the admin action hanging. - The sync panel shows the time of the last complete run. - Client tests: timeout, failed fetch is never treated as an empty source, malformed responses are rejected as a whole. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
e0b47faf45
commit
0482fd490e
9 files changed
+194
-17
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
openApiCoreSession,
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreBottlingBatches,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreSession,
|
||||
} from "./apicore-client";
|
||||
|
||||
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,23 @@ import { z } from "zod";
|
||||
export class ApiCoreConfigError extends Error {}
|
||||
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.
|
||||
*/
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
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.
|
||||
const name = typeof error === "object" && error !== null && "name" in error ? error.name : undefined;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
return `keine Antwort innerhalb von ${REQUEST_TIMEOUT_MS / 1000} Sekunden`;
|
||||
}
|
||||
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() }),
|
||||
@@ -79,10 +96,10 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
||||
@@ -116,10 +133,10 @@ async function getJson<T>(
|
||||
try {
|
||||
res = await fetch(`${session.baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(
|
||||
|
||||
@@ -64,19 +64,31 @@ describe("slugForApiCoreSource", () => {
|
||||
});
|
||||
|
||||
describe("buildHarvestDescription", () => {
|
||||
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
|
||||
test("fasst die vorhandenen Mengenangaben zusammen", () => {
|
||||
// Arrange
|
||||
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
|
||||
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41 });
|
||||
|
||||
// Act
|
||||
const description = buildHarvestDescription(source);
|
||||
|
||||
// Assert
|
||||
expect(description).toBe(
|
||||
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
|
||||
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg"
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
const source = makeHarvest();
|
||||
|
||||
@@ -24,14 +24,18 @@ export function slugForApiCoreSource(sourceId: string): string {
|
||||
/**
|
||||
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
||||
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
|
||||
* vorhandenen Mengenfeldern, die der Admin beim Review verfeinert.
|
||||
*
|
||||
* ApiCores `notes` werden bewusst NICHT uebernommen: das sind interne
|
||||
* Betriebsnotizen des Imkers (Beobachtungen, Probleme, Behandlungshinweise),
|
||||
* 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 {
|
||||
const parts: string[] = [];
|
||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
|
||||
if (source.notes?.trim()) parts.push(source.notes.trim());
|
||||
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user