/** * propertydata-api.ts behaviour with mocked fetch + mocked neo4j store. * Covers: * - missing-key path throws KeyNotRegisteredError * - 4xx upstream maps to PropertyDataError with the status preserved * - 5xx upstream maps to PropertyDataError with the status preserved * - PropertyData JSON envelope with status=error maps to PropertyDataError * - non-JSON body maps to BadResponseError * - successful call returns the payload as a JSON string */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../lib/neo4j.js", () => { let stored: { encryptedKey: string } | null = null; return { getSession: () => ({ run: vi.fn(async (cypher: string, params: Record) => { if (/MATCH \(k:PropertyDataKey/.test(cypher) && /RETURN/.test(cypher)) { return { records: stored ? [ { get: (k: string) => { if (k === "encryptedKey") return stored!.encryptedKey; if (k === "createdAt") return "2026-01-01T00:00:00Z"; if (k === "updatedAt") return "2026-01-01T00:00:00Z"; return null; }, }, ] : [], }; } if (/MERGE \(k:PropertyDataKey/.test(cypher)) { stored = { encryptedKey: String(params.encryptedKey) }; return { records: [] }; } if (/DELETE k/.test(cypher)) { const count = stored ? 1 : 0; stored = null; return { records: [{ get: (k: string) => (k === "deleted" ? { toNumber: () => count } : null) }], }; } return { records: [] }; }), close: vi.fn(async () => {}), }), closeDriver: vi.fn(async () => {}), }; }); vi.mock("../lib/crypto.js", () => ({ encrypt: (s: string) => `enc:${s}`, decrypt: (s: string) => s.replace(/^enc:/, ""), })); import { propertyDataCall, KeyNotRegisteredError, PropertyDataError, BadResponseError, } from "../lib/propertydata-api.js"; import { keyRegister } from "../tools/key-register.js"; const originalFetch = globalThis.fetch; function mockFetch(impl: (url: string) => Response | Promise) { globalThis.fetch = vi.fn(async (url: string | URL | Request) => impl(String(url)), ) as unknown as typeof fetch; } beforeEach(() => { // no-op }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); async function registerTestKey() { mockFetch(() => new Response(JSON.stringify({ status: "success", uk_hpi: 100 }), { status: 200 })); await keyRegister({ apiKey: "test-key-12345", accountId: "acc-1" }); } describe("propertyDataCall", () => { it("throws KeyNotRegisteredError when no key is stored", async () => { await expect( propertyDataCall({ endpoint: "demand", toolName: "property-data-demand", accountId: "ghost-account", params: { postcode: "SN7" }, }), ).rejects.toBeInstanceOf(KeyNotRegisteredError); }); it("returns the JSON payload as a string on success", async () => { await registerTestKey(); mockFetch( () => new Response(JSON.stringify({ status: "success", point_estimate: 350000 }), { status: 200 }), ); const result = await propertyDataCall({ endpoint: "valuation-sale", toolName: "property-data-valuation-sale", accountId: "acc-1", params: { postcode: "SN7", type: "detached", bedrooms: 3 }, }); expect(result).toContain("point_estimate"); expect(JSON.parse(result).point_estimate).toBe(350000); }); it("maps HTTP 4xx to PropertyDataError preserving the status", async () => { await registerTestKey(); mockFetch(() => new Response("bad postcode", { status: 400 })); try { await propertyDataCall({ endpoint: "demand", toolName: "property-data-demand", accountId: "acc-1", params: { postcode: "INVALID" }, }); throw new Error("expected PropertyDataError"); } catch (err) { expect(err).toBeInstanceOf(PropertyDataError); expect((err as PropertyDataError).status).toBe(400); } }); it("maps HTTP 5xx to PropertyDataError preserving the status", async () => { await registerTestKey(); mockFetch(() => new Response("upstream down", { status: 503 })); try { await propertyDataCall({ endpoint: "demand", toolName: "property-data-demand", accountId: "acc-1", params: { postcode: "SN7" }, }); throw new Error("expected PropertyDataError"); } catch (err) { expect(err).toBeInstanceOf(PropertyDataError); expect((err as PropertyDataError).status).toBe(503); } }); it("maps PropertyData status=error envelope to PropertyDataError", async () => { await registerTestKey(); mockFetch( () => new Response(JSON.stringify({ status: "error", message: "Insufficient comparables" }), { status: 200, }), ); try { await propertyDataCall({ endpoint: "valuation-sale", toolName: "property-data-valuation-sale", accountId: "acc-1", params: { postcode: "SN7" }, }); throw new Error("expected PropertyDataError"); } catch (err) { expect(err).toBeInstanceOf(PropertyDataError); expect((err as PropertyDataError).upstreamMessage).toMatch(/Insufficient/); } }); it("maps non-JSON body to BadResponseError", async () => { await registerTestKey(); mockFetch(() => new Response("service maintenance", { status: 200 })); await expect( propertyDataCall({ endpoint: "demand", toolName: "property-data-demand", accountId: "acc-1", params: { postcode: "SN7" }, }), ).rejects.toBeInstanceOf(BadResponseError); }); });