Compare commits
2
Commits
e0b47faf45
...
45ca0ea745
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ca0ea745 | ||
|
|
0482fd490e |
No files matched your search
@@ -37,8 +37,11 @@ import { requireAdmin } from "./_shared";
|
|||||||
* ein Mensch freigibt.
|
* ein Mensch freigibt.
|
||||||
*
|
*
|
||||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
||||||
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
|
* false) bzw. als solche aktualisiert — aber nur, solange niemand den
|
||||||
* synchronisierte Ernten bleiben unberuehrt.
|
* Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der
|
||||||
|
* Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins
|
||||||
|
* (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder
|
||||||
|
* zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt.
|
||||||
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||||
@@ -68,6 +71,23 @@ interface SyncCounts {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HarvestSyncCounts extends SyncCounts {
|
||||||
|
/** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */
|
||||||
|
protectedDrafts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||||
|
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||||
|
* damit nicht mit.
|
||||||
|
*/
|
||||||
|
async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
|
||||||
|
const entries = await prisma.auditLog.count({
|
||||||
|
where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } },
|
||||||
|
});
|
||||||
|
return entries > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
||||||
* siehe syncProducts().
|
* siehe syncProducts().
|
||||||
@@ -95,8 +115,8 @@ function describeError(error: unknown): string {
|
|||||||
// Ernten
|
// Ernten
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
|
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
|
||||||
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const slug = slugForApiCoreSource(source.id);
|
const slug = slugForApiCoreSource(source.id);
|
||||||
@@ -114,6 +134,10 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Sy
|
|||||||
counts.skipped++;
|
counts.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (existing && (await wasEditedByAdmin(existing.id))) {
|
||||||
|
counts.protectedDrafts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
year: date.getFullYear(),
|
year: date.getFullYear(),
|
||||||
@@ -419,7 +443,9 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
entityType: "Harvest",
|
entityType: "Harvest",
|
||||||
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
detail:
|
||||||
|
`${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` +
|
||||||
|
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||||
@@ -458,6 +484,7 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
|||||||
redirect(
|
redirect(
|
||||||
`${returnTo}?syncDone=1` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
|
`&hProtected=${harvests.protectedDrafts}` +
|
||||||
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { syncApiCoreAction } from "@/app/admin/actions";
|
import { syncApiCoreAction } from "@/app/admin/actions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
@@ -38,6 +39,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
if (!done && syncError === undefined) return null;
|
if (!done && syncError === undefined) return null;
|
||||||
|
|
||||||
const pending = count(searchParams.pPending);
|
const pending = count(searchParams.pPending);
|
||||||
|
const protectedDrafts = count(searchParams.hProtected);
|
||||||
const discontinued = count(searchParams.pDiscontinued);
|
const discontinued = count(searchParams.pDiscontinued);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +65,11 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
) : null}
|
) : null}
|
||||||
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||||
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||||
|
{protectedDrafts > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.protectedDrafts(protectedDrafts)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{pending > 0 ? (
|
{pending > 0 ? (
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
{t.pending(pending)}
|
{t.pending(pending)}
|
||||||
@@ -88,12 +95,31 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
|||||||
* daraus keine offene Weiterleitung wird.
|
* daraus keine offene Weiterleitung wird.
|
||||||
*/
|
*/
|
||||||
export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||||
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale).adminApiCoreSync;
|
||||||
|
// Jeder vollstaendige Lauf schreibt zuletzt den Produkt-Eintrag — der ist
|
||||||
|
// damit der Zeitpunkt des letzten Laufs, der bis zum Ende kam.
|
||||||
|
const lastRun = await prisma.auditLog.findFirst({
|
||||||
|
where: { action: "product.sync_apicore" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
|
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
|
||||||
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||||
|
<p className="text-sm text-ink/65 mb-4">
|
||||||
|
{lastRun
|
||||||
|
? t.lastRun(
|
||||||
|
lastRun.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: t.neverRun}
|
||||||
|
</p>
|
||||||
<form action={syncApiCoreAction}>
|
<form action={syncApiCoreAction}>
|
||||||
<input type="hidden" name="returnTo" value={returnTo} />
|
<input type="hidden" name="returnTo" value={returnTo} />
|
||||||
<Button type="submit" className="px-5 py-2.5">
|
<Button type="submit" className="px-5 py-2.5">
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@ export const de: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore-Synchronisierung",
|
heading: "ApiCore-Synchronisierung",
|
||||||
intro:
|
intro:
|
||||||
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene Einträge werden nie überschrieben.",
|
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene und im Admin bearbeitete Einträge werden nie überschrieben; interne ApiCore-Notizen werden nicht übernommen.",
|
||||||
button: "Alles synchronisieren",
|
button: "Alles synchronisieren",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||||
@@ -1168,6 +1168,10 @@ export const de: Dictionary = {
|
|||||||
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
|
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
|
||||||
discontinued: (count) =>
|
discontinued: (count) =>
|
||||||
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
||||||
|
protectedDrafts: (count) =>
|
||||||
|
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet und deshalb nicht überschrieben.`,
|
||||||
|
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||||
|
neverRun: "Noch kein vollständiger Lauf.",
|
||||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1131,7 +1131,7 @@ export const en: Dictionary = {
|
|||||||
adminApiCoreSync: {
|
adminApiCoreSync: {
|
||||||
heading: "ApiCore sync",
|
heading: "ApiCore sync",
|
||||||
intro:
|
intro:
|
||||||
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released are never overwritten.",
|
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released or edited in the admin are never overwritten; internal ApiCore notes are not copied.",
|
||||||
button: "Sync everything",
|
button: "Sync everything",
|
||||||
resultHarvests: (created, updated, skipped, failed) =>
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
@@ -1143,6 +1143,10 @@ export const en: Dictionary = {
|
|||||||
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
|
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
|
||||||
discontinued: (count) =>
|
discontinued: (count) =>
|
||||||
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
||||||
|
protectedDrafts: (count) =>
|
||||||
|
`${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin and therefore not overwritten.`,
|
||||||
|
lastRun: (when) => `Last complete run: ${when}`,
|
||||||
|
neverRun: "No complete run yet.",
|
||||||
error: "Sync with ApiCore failed.",
|
error: "Sync with ApiCore failed.",
|
||||||
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -733,6 +733,9 @@ export interface Dictionary {
|
|||||||
resultProducts: (created: number, skipped: number, failed: number) => string;
|
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||||
pending: (count: number) => string;
|
pending: (count: number) => string;
|
||||||
discontinued: (count: number) => string;
|
discontinued: (count: number) => string;
|
||||||
|
protectedDrafts: (count: number) => string;
|
||||||
|
lastRun: (when: string) => string;
|
||||||
|
neverRun: string;
|
||||||
error: string;
|
error: string;
|
||||||
batchLoadError: string;
|
batchLoadError: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ApiCoreConfigError extends Error {}
|
||||||
export class ApiCoreRequestError 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([
|
const loginResponseSchema = z.union([
|
||||||
z.object({ accessToken: z.string().min(1) }),
|
z.object({ accessToken: z.string().min(1) }),
|
||||||
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
|
||||||
@@ -79,10 +96,10 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
||||||
@@ -116,10 +133,10 @@ async function getJson<T>(
|
|||||||
try {
|
try {
|
||||||
res = await fetch(`${session.baseUrl}${path}`, {
|
res = await fetch(`${session.baseUrl}${path}`, {
|
||||||
headers: { Authorization: `Bearer ${session.token}` },
|
headers: { Authorization: `Bearer ${session.token}` },
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new ApiCoreRequestError(
|
throw new ApiCoreRequestError(
|
||||||
|
|||||||
@@ -64,19 +64,31 @@ describe("slugForApiCoreSource", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildHarvestDescription", () => {
|
describe("buildHarvestDescription", () => {
|
||||||
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
|
test("fasst die vorhandenen Mengenangaben zusammen", () => {
|
||||||
// Arrange
|
// 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
|
// Act
|
||||||
const description = buildHarvestDescription(source);
|
const description = buildHarvestDescription(source);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(description).toBe(
|
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", () => {
|
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const source = makeHarvest();
|
const source = makeHarvest();
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ export function slugForApiCoreSource(sourceId: string): string {
|
|||||||
/**
|
/**
|
||||||
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||||
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
* 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 {
|
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): 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`);
|
||||||
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} 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.";
|
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||||
return `Automatisch aus ApiCore importiert. ${detail}`;
|
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,14 @@ describe("Session-Token", () => {
|
|||||||
role: "KUNDE",
|
role: "KUNDE",
|
||||||
sessionVersion: 0,
|
sessionVersion: 0,
|
||||||
});
|
});
|
||||||
// Letztes Zeichen der Signatur veraendern.
|
// Ein Zeichen MITTEN in der Signatur veraendern. Das letzte Zeichen taugt
|
||||||
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
|
// nicht: bei 32 Signaturbytes traegt es in Base64url teils nur Fuellbits,
|
||||||
|
// die der Decoder ignoriert — der Test schlug dann zufaellig fehl, obwohl
|
||||||
|
// die Signatur korrekt geprueft wurde.
|
||||||
|
const signatureStart = token.lastIndexOf(".") + 1;
|
||||||
|
const position = signatureStart + 10;
|
||||||
|
const replacement = token[position] === "A" ? "B" : "A";
|
||||||
|
const tampered = token.slice(0, position) + replacement + token.slice(position + 1);
|
||||||
|
|
||||||
await expect(verifySessionToken(tampered)).resolves.toBeNull();
|
await expect(verifySessionToken(tampered)).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user