Security Scan (SIAX Cloud) / security (push) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / integration (push) Successful in 29s
CI (SIAX Cloud) / quality (push) Successful in 48s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 15s
CI (SIAX Cloud) / security (pull_request) Successful in 28s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
CI (SIAX Cloud) / integration (pull_request) Successful in 55s
Python Engine CI (SIAX Cloud) / python-engine (push) Failing after 4m52s
Python Engine CI (SIAX Cloud) / python-engine (pull_request) Failing after 4m48s
Slutgate inför merge (per fortsättningsplan): Kontrakt: CreateDatasetVersionInput.artifact_refs → readonly string[] (anropare får skicka 'as const'; servicen muterar aldrig); postgres.js tx.array() får muterbar kopia vid INSERT. Testkorrigeringar (stale mot starkare implementation — aldrig svagare): - entity-resolution-http: request(body, undefined) triggade default-param och skickade GILTIGT token → 200. Tomt token = 'utan token' → 401 fail-closed. - entity-resolution: /two records/ → /between 2 and 5000/ (bounds-aware validering) - search-service: omformat purpose-filter-test + NY negativ kontroll — rebuild avvisar dokument utan purpose_tags (fail closed, generation discarded) - dataset-postgres.realinfra: sätter DATABASE_URL i setup så den durabla AUD0-outboxen aktiveras (testet ljög annars om durabilitet) — 5/5 mot live Postgres STATUS.md: AUD0-klient + lineage-rader (outbox nu real-infra testad), gap-listan (persistence/routes/outbox stängda, exakt vad som återstår), P065 STÄNGD med closure-record (thin adapter, v3-semantik, hard-negative transitivitet, fail-closed 401 — 4/4 planpunkter verifierade). Verifierat: typecheck EXIT 0 · 945/945 unit · 5/5 real-Postgres · next build EXIT 0
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
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<string, string> = { "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<string, unknown> }>) {
|
|
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 () => {
|
|
// OBS: request(body, undefined) triggar default-parametern (skickar GILTIGT
|
|
// token) — skicka tom string för "utan token" (fail-closed → 401).
|
|
const res = await candidatesPOST(
|
|
request(
|
|
baseBody([
|
|
{ record_id: "a", fields: { name: "Acme AB", city: "Stockholm" } },
|
|
{ record_id: "b", fields: { name: "Acme AB", city: "Stockholm" } },
|
|
]),
|
|
"",
|
|
),
|
|
);
|
|
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 a same-country registration number to hard-link despite punctuation differences", async () => {
|
|
const res = await candidatesPOST(
|
|
request(
|
|
baseBody([
|
|
{
|
|
record_id: "a",
|
|
fields: {
|
|
name: "Acme Sweden AB",
|
|
city: "Stockholm",
|
|
registration_country: "SE",
|
|
org_nr: "556000-0001",
|
|
},
|
|
},
|
|
{
|
|
record_id: "b",
|
|
fields: {
|
|
name: "Acme Sverige",
|
|
city: "Stockholm",
|
|
registration_country: "se",
|
|
org_nr: "5560000001",
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
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.v3");
|
|
expect(body.review_queue).toEqual([]);
|
|
});
|
|
|
|
it("does not treat the same national registration number in different countries as global identity", async () => {
|
|
const res = await candidatesPOST(
|
|
request(
|
|
baseBody([
|
|
{
|
|
record_id: "a",
|
|
fields: {
|
|
name: "Acme Consulting AB",
|
|
city: "Stockholm",
|
|
registration_country: "SE",
|
|
org_nr: "123456789",
|
|
},
|
|
},
|
|
{
|
|
record_id: "b",
|
|
fields: {
|
|
name: "Acme Consulting A B",
|
|
city: "Stockholm",
|
|
registration_country: "NO",
|
|
org_nr: "123456789",
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.pairs).toHaveLength(1);
|
|
expect(body.pairs[0].decided_by).toBe("score");
|
|
expect(body.pairs[0].status).toBe("pending_review");
|
|
});
|
|
|
|
it("lets conflicting stable identity evidence dominate a matching global LEI", async () => {
|
|
const res = await candidatesPOST(
|
|
request(
|
|
baseBody([
|
|
{
|
|
record_id: "a",
|
|
fields: {
|
|
name: "Acme Sweden AB",
|
|
city: "Stockholm",
|
|
registration_country: "SE",
|
|
lei: "549300EXAMPLE123",
|
|
org_nr: "556000-0001",
|
|
},
|
|
},
|
|
{
|
|
record_id: "b",
|
|
fields: {
|
|
name: "Acme Sweden AB",
|
|
city: "Stockholm",
|
|
registration_country: "SE",
|
|
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.v3");
|
|
});
|
|
|
|
it("can hard-link a globally matching LEI when no scoped identifier contradicts it", async () => {
|
|
const res = await candidatesPOST(
|
|
request(
|
|
baseBody([
|
|
{
|
|
record_id: "a",
|
|
fields: { name: "Acme Holding", city: "Stockholm", lei: "549300-EXAMPLE-123" },
|
|
},
|
|
{
|
|
record_id: "b",
|
|
fields: { name: "ACME Holdings Group", city: "Stockholm", lei: "549300EXAMPLE123" },
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
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.v3");
|
|
});
|
|
});
|