feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen

/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.

- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
  Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
  damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 03:27:58 +02:00
1 parent 2659db64bd
commit 01eaca39dc
10 files changed
+674 -5

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const ENV_KEYS = ["DHL_API_KEY", "DHL_TRACKING_API_KEY"] as const;
const originalEnv: Record<string, string | undefined> = {};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
/** Ein Ereignis im Format der DHL Shipment Tracking – Unified API. */
function dhlEvent(overrides: Record<string, unknown> = {}) {
return {
timestamp: "2026-09-24T08:15:00Z",
location: { address: { addressLocality: "Augsburg", postalCode: "86150", countryCode: "DE" } },
statusCode: "transit",
status: "In Zustellung",
description: "Die Sendung wurde in das Zustellfahrzeug geladen.",
...overrides,
};
}
function dhlPayload(shipmentOverrides: Record<string, unknown> = {}) {
return {
shipments: [
{
id: "00340434161234567890",
service: "parcel-de",
status: dhlEvent(),
estimatedTimeOfDelivery: "2026-09-25T18:00:00Z",
events: [
dhlEvent({
timestamp: "2026-09-23T17:40:00Z",
statusCode: "pre-transit",
status: "Elektronisch angekündigt",
description: "Die Sendung wurde elektronisch angekündigt.",
location: undefined,
}),
dhlEvent(),
],
...shipmentOverrides,
},
],
};
}
const QUERY = { trackingNumber: "00340434161234567890", postalCode: "86450", locale: "de" as const };
// dhl-tracking.ts haelt Ergebnisse in einem modulweiten Cache — pro Test ein
// frisches Modul, damit kein Treffer aus einem vorherigen Test den erwarteten
// fetch-Aufruf stillschweigend ueberspringt (gleiches Muster wie dhl.test.ts).
beforeEach(() => {
vi.resetModules();
for (const key of ENV_KEYS) {
originalEnv[key] = process.env[key];
delete process.env[key];
}
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) delete process.env[key];
else process.env[key] = originalEnv[key];
}
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("parseTrackingResponse", () => {
it("liefert Status, voraussichtliche Zustellung und Ereignisse, neuestes zuerst", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload());
expect(info?.status).toBe("transit");
expect(info?.statusText).toBe("In Zustellung");
expect(info?.estimatedDelivery?.toISOString()).toBe("2026-09-25T18:00:00.000Z");
expect(info?.events.map((e) => e.status)).toEqual(["transit", "pre-transit"]);
expect(info?.events[0].location).toBe("Augsburg");
expect(info?.events[1].location).toBeNull();
});
it("bildet unbekannte Statuscodes auf 'unknown' ab, statt die Antwort zu verwerfen", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ status: dhlEvent({ statusCode: "neuer-code" }) }));
expect(info?.status).toBe("unknown");
});
it("ueberspringt Ereignisse mit ungueltigem Zeitstempel", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ timestamp: "kein-datum" }), dhlEvent()] }));
expect(info?.events).toHaveLength(1);
});
it("kuerzt uebermaessig lange Texte, damit eine fremde Antwort das Layout nicht sprengt", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
const info = parseTrackingResponse(dhlPayload({ events: [dhlEvent({ description: "x".repeat(5000) })] }));
expect(info?.events[0].description.length).toBeLessThanOrEqual(300);
});
it("liefert null bei einer Antwort ohne Sendung", async () => {
const { parseTrackingResponse } = await import("./dhl-tracking");
expect(parseTrackingResponse({ shipments: [] })).toBeNull();
expect(parseTrackingResponse("<html>")).toBeNull();
});
});
describe("getDhlTracking", () => {
it("ruft ohne API-Key das Netzwerk gar nicht erst auf", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "disabled" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("schickt eine offensichtlich ungueltige Sendungsnummer nicht an DHL", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking({ ...QUERY, trackingNumber: "../x?y=1" })).resolves.toEqual({ kind: "not-found" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("fragt mit API-Key-Header, Service, PLZ und Sprache an und liefert die Ereignisse", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
const result = await getDhlTracking(QUERY);
expect(result.kind).toBe("ok");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedUrl = new URL(url);
expect(parsedUrl.origin + parsedUrl.pathname).toBe("https://api-eu.dhl.com/track/shipments");
expect(parsedUrl.searchParams.get("trackingNumber")).toBe(QUERY.trackingNumber);
expect(parsedUrl.searchParams.get("service")).toBe("parcel-de");
expect(parsedUrl.searchParams.get("recipientPostalCode")).toBe("86450");
expect(parsedUrl.searchParams.get("language")).toBe("de");
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("api-key");
});
it("bevorzugt DHL_TRACKING_API_KEY, falls das Tracking in einer eigenen DHL-App freigeschaltet ist", async () => {
process.env.DHL_API_KEY = "shipping-key";
process.env.DHL_TRACKING_API_KEY = "tracking-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect((init.headers as Record<string, string>)["DHL-API-Key"]).toBe("tracking-key");
});
it("beantwortet eine wiederholte Abfrage aus dem Cache, um das DHL-Kontingent zu schonen", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(dhlPayload()));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
await getDhlTracking(QUERY);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("meldet 404 als 'not-found' — die Sendung ist DHL (noch) nicht bekannt", async () => {
process.env.DHL_API_KEY = "api-key";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ status: 404 }, 404)));
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "not-found" });
});
it.each([
["Kontingent erschoepft", () => Promise.resolve(jsonResponse({}, 429))],
["falscher Key", () => Promise.resolve(jsonResponse({}, 401))],
["Netzwerkfehler", () => Promise.reject(new TypeError("fetch failed"))],
["kaputtes JSON", () => Promise.resolve(new Response("<html>", { status: 200 }))],
])("liefert bei %s 'unavailable' statt die Seite scheitern zu lassen", async (_label, impl) => {
process.env.DHL_API_KEY = "api-key";
vi.stubGlobal("fetch", vi.fn().mockImplementation(impl));
const { getDhlTracking } = await import("./dhl-tracking");
await expect(getDhlTracking(QUERY)).resolves.toEqual({ kind: "unavailable" });
});
it("wiederholt eine fehlgeschlagene Abfrage nicht sofort, sondern haelt kurz Abstand", async () => {
process.env.DHL_API_KEY = "api-key";
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 429));
vi.stubGlobal("fetch", fetchMock);
const { getDhlTracking } = await import("./dhl-tracking");
await getDhlTracking(QUERY);
await getDhlTracking(QUERY);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});