diff --git a/tests/p0/entity-resolution-http.test.ts b/tests/p0/entity-resolution-http.test.ts new file mode 100644 index 0000000..def5725 --- /dev/null +++ b/tests/p0/entity-resolution-http.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { POST as candidatesPOST } from "@/app/api/0pt/internal/er/candidates/route"; + +const TOKEN = "test-er-internal-token"; +let previousToken: string | undefined; + +function request(body: unknown, token: string | undefined = TOKEN): NextRequest { + const headers: Record = { "content-type": "application/json" }; + if (token !== undefined) headers["x-internal-token"] = token; + return new NextRequest("http://localhost/api/0pt/internal/er/candidates", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +function baseBody(records: Array<{ record_id: string; fields: Record }>) { + return { + tenant_id: "tenant_a", + project_id: "project_a", + records, + blocking_fields: ["city"], + compare_fields: [{ field: "name", weight: 1 }], + auto_match_threshold: 0.9, + review_threshold: 0.6, + }; +} + +beforeEach(() => { + previousToken = process.env.INTERNAL_TOKEN; + process.env.INTERNAL_TOKEN = TOKEN; +}); + +afterEach(() => { + if (previousToken === undefined) delete process.env.INTERNAL_TOKEN; + else process.env.INTERNAL_TOKEN = previousToken; +}); + +describe("POST /api/0pt/internal/er/candidates", () => { + it("fails closed without the internal service token", async () => { + const res = await candidatesPOST( + request( + baseBody([ + { record_id: "a", fields: { name: "Acme AB", city: "Stockholm" } }, + { record_id: "b", fields: { name: "Acme AB", city: "Stockholm" } }, + ]), + undefined, + ), + ); + expect(res.status).toBe(401); + }); + + it("never emits a score-only fuzzy company match even above auto_match_threshold", async () => { + const res = await candidatesPOST( + request( + baseBody([ + { record_id: "a", fields: { name: "Acme Consulting AB", city: "Stockholm" } }, + { record_id: "b", fields: { name: "Acme Consulting A B", city: "Stockholm" } }, + ]), + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.pairs).toHaveLength(1); + expect(body.pairs[0].score).toBeGreaterThanOrEqual(0.9); + expect(body.pairs[0].decided_by).toBe("score"); + expect(body.pairs[0].status).toBe("pending_review"); + expect(body.review_queue.map((pair: { candidate_id: string }) => pair.candidate_id)).toEqual([ + body.pairs[0].candidate_id, + ]); + }); + + it("allows an exact stable company identifier hard rule to emit match", async () => { + const res = await candidatesPOST( + request( + baseBody([ + { + record_id: "a", + fields: { name: "Acme Sweden AB", city: "Stockholm", org_nr: "556000-0001" }, + }, + { + record_id: "b", + fields: { name: "Acme Sverige", city: "Stockholm", org_nr: "556000-0001" }, + }, + ]), + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.pairs).toHaveLength(1); + expect(body.pairs[0].status).toBe("match"); + expect(body.pairs[0].decided_by).toBe("0pt.company.stable-identifiers.v2"); + expect(body.review_queue).toEqual([]); + }); + + it("lets conflicting stable identity evidence dominate a matching identifier", async () => { + const res = await candidatesPOST( + request( + baseBody([ + { + record_id: "a", + fields: { + name: "Acme Sweden AB", + city: "Stockholm", + lei: "549300EXAMPLE123", + org_nr: "556000-0001", + }, + }, + { + record_id: "b", + fields: { + name: "Acme Sweden AB", + city: "Stockholm", + lei: "549300EXAMPLE123", + org_nr: "556000-9999", + }, + }, + ]), + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.pairs).toHaveLength(1); + expect(body.pairs[0].status).toBe("non_match"); + expect(body.pairs[0].decided_by).toBe("0pt.company.stable-identifiers.v2"); + }); +});