/** * Task 144 — per-endpoint coverage for the 10 new PropertyData adapters. * For each new endpoint, assert: * 1. happy path — JSON parses and round-trips through the adapter * 2. 4xx upstream → PropertyDataError with status preserved * 3. non-JSON body → BadResponseError * 4. missing key → KeyNotRegisteredError * * The existing propertydata-api.test.ts proves the four paths fire from * `propertyDataCall`; this file proves each of the 10 adapter exports * threads its own endpoint path through that same plumbing so the * MCP tool actually hits the right URL. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock the Neo4j layer keyed by accountId so each test can choose an // already-registered account (happy / 4xx / bad-response) or a brand-new // "ghost" account (missing-key) without ordering coupling. vi.mock("../lib/neo4j.js", () => { const stored = new Map(); return { getSession: () => ({ run: vi.fn(async (cypher: string, params: Record) => { const accountId = String(params.accountId ?? ""); if (/MATCH \(k:PropertyDataKey/.test(cypher) && /RETURN/.test(cypher)) { const encryptedKey = stored.get(accountId); return { records: encryptedKey ? [ { get: (k: string) => { if (k === "encryptedKey") return 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.set(accountId, String(params.encryptedKey)); return { records: [] }; } if (/DELETE k/.test(cypher)) { const had = stored.delete(accountId); return { records: [ { get: (k: string) => k === "deleted" ? { toNumber: () => (had ? 1 : 0) } : 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 { pricesPerSqf, soldPricesPerSqf, crime, floodRisk, councilTax, planningApplications, addressMatchUprn, growthPsf, demandRent, propertyTypes, } from "../tools/endpoints.js"; import { 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) { const fn = vi.fn(async (url: string | URL | Request) => impl(String(url))) as unknown as typeof fetch; globalThis.fetch = fn; return fn as unknown as ReturnType; } async function registerTestKey(accountId: string) { mockFetch(() => new Response(JSON.stringify({ status: "success", uk_hpi: 100 }), { status: 200 })); await keyRegister({ apiKey: "test-key-12345", accountId }); } beforeEach(() => { // no-op }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); interface EndpointCase { toolName: string; endpointPath: string; adapter: (args: { accountId: string } & Record) => Promise; sampleParams: Record; happyPayload: Record; happyAssertField: string; } const cases: EndpointCase[] = [ { toolName: "property-data-prices-per-sqf", endpointPath: "prices-per-sqf", adapter: pricesPerSqf, sampleParams: { postcode: "CM23 2UJ" }, happyPayload: { status: "success", data: { avg: 450 } }, happyAssertField: "avg", }, { toolName: "property-data-sold-prices-per-sqf", endpointPath: "sold-prices-per-sqf", adapter: soldPricesPerSqf, sampleParams: { postcode: "CM23 2UJ", max_age: 24 }, happyPayload: { status: "success", data: { avg: 425, sales: [] } }, happyAssertField: "avg", }, { toolName: "property-data-crime", endpointPath: "crime", adapter: crime, sampleParams: { postcode: "CM23" }, happyPayload: { status: "success", data: { rate_per_1000: 42 } }, happyAssertField: "rate_per_1000", }, { toolName: "property-data-flood-risk", endpointPath: "flood-risk", adapter: floodRisk, sampleParams: { postcode: "CM23 2UJ" }, happyPayload: { status: "success", data: { river: "very-low" } }, happyAssertField: "river", }, { toolName: "property-data-council-tax", endpointPath: "council-tax", adapter: councilTax, sampleParams: { postcode: "CM23 2UJ" }, happyPayload: { status: "success", data: { council: "East Herts", band_d: 2100 } }, happyAssertField: "council", }, { toolName: "property-data-planning-applications", endpointPath: "planning-applications", adapter: planningApplications, sampleParams: { postcode: "CM23", results: 10 }, happyPayload: { status: "success", data: { applications: [] } }, happyAssertField: "applications", }, { toolName: "property-data-address-match-uprn", endpointPath: "address-match-uprn", adapter: addressMatchUprn, sampleParams: { address: "1 The High Street, Bishop's Stortford, CM23 2UJ" }, happyPayload: { status: "success", data: { uprn: "10001234567" } }, happyAssertField: "uprn", }, { toolName: "property-data-growth-psf", endpointPath: "growth-psf", adapter: growthPsf, sampleParams: { postcode: "CM23" }, happyPayload: { status: "success", data: { series: [{ year: 2019, psf: 400 }] } }, happyAssertField: "series", }, { toolName: "property-data-demand-rent", endpointPath: "demand-rent", adapter: demandRent, sampleParams: { postcode: "CM23", bedrooms: 3 }, happyPayload: { status: "success", data: { for_rent: 12, lets_per_month: 4 } }, happyAssertField: "for_rent", }, { toolName: "property-data-property-types", endpointPath: "property-types", adapter: propertyTypes, sampleParams: { postcode: "CM23" }, happyPayload: { status: "success", data: { detached: 28, flat: 12 } }, happyAssertField: "detached", }, ]; describe("Task 144 endpoint adapters", () => { for (const c of cases) { describe(c.toolName, () => { const acct = `acct-${c.endpointPath}`; it("happy path returns the parsed JSON payload", async () => { await registerTestKey(acct); let capturedUrl = ""; mockFetch((url) => { capturedUrl = url; return new Response(JSON.stringify(c.happyPayload), { status: 200 }); }); const text = await c.adapter({ accountId: acct, ...c.sampleParams }); expect(capturedUrl).toContain(`/${c.endpointPath}?`); const parsed = JSON.parse(text); // payload has shape { status, data: { ... } } — assert the field // exists somewhere in the round-tripped output. expect(JSON.stringify(parsed)).toContain(c.happyAssertField); }); it("maps HTTP 4xx to PropertyDataError preserving the status", async () => { await registerTestKey(acct); mockFetch(() => new Response("bad request", { status: 400 })); try { await c.adapter({ accountId: acct, ...c.sampleParams }); throw new Error("expected PropertyDataError"); } catch (err) { expect(err).toBeInstanceOf(PropertyDataError); expect((err as PropertyDataError).status).toBe(400); } }); it("maps non-JSON body to BadResponseError", async () => { await registerTestKey(acct); mockFetch(() => new Response("maintenance", { status: 200 })); await expect( c.adapter({ accountId: acct, ...c.sampleParams }), ).rejects.toBeInstanceOf(BadResponseError); }); it("throws KeyNotRegisteredError when no key is stored", async () => { // brand-new account with nothing registered await expect( c.adapter({ accountId: `${acct}-ghost`, ...c.sampleParams }), ).rejects.toBeInstanceOf(KeyNotRegisteredError); }); }); } });