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:
1 parent
2659db64bd
commit
01eaca39dc
10 files changed
+674
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user