/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]>
212 lines
8.3 KiB
TypeScript
212 lines
8.3 KiB
TypeScript
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);
|
||
});
|
||
});
|