logicsrc/apps/logicsrc-web/contract/ontology-api.contract.test.ts
2026-08-10 14:05:10 -06:00

485 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GET as listOntologies } from "../src/app/api/ontologies/route";
import { GET as getManifest } from "../src/app/api/ontologies/[ontologyId]/manifest/route";
import { GET as getSchema } from "../src/app/api/ontologies/[ontologyId]/schema/route";
import { GET as listEntities } from "../src/app/api/ontologies/[ontologyId]/entities/route";
import { GET as getEntity } from "../src/app/api/ontologies/[ontologyId]/entities/[entityId]/route";
import { GET as listClaims } from "../src/app/api/ontologies/[ontologyId]/claims/route";
import { GET as getClaim } from "../src/app/api/ontologies/[ontologyId]/claims/[claimId]/route";
import { POST as runQuery } from "../src/app/api/ontologies/[ontologyId]/query/route";
import { POST as explain } from "../src/app/api/ontologies/[ontologyId]/explain/route";
import { POST as validate } from "../src/app/api/ontologies/[ontologyId]/validate/route";
import { GET as listChangeSets, POST as createChangeSet } from "../src/app/api/ontologies/[ontologyId]/changesets/route";
import { POST as approve } from "../src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/approve/route";
import { POST as apply } from "../src/app/api/ontologies/[ontologyId]/changesets/[changeSetId]/apply/route";
import { GET as events } from "../src/app/api/ontologies/[ontologyId]/events/route";
import { GET as openapi } from "../src/app/api/ontologies/openapi/route";
import { resetService } from "../src/lib/ontology-service";
/**
* Contract tests for the OpenOntology reference API.
*
* They call the route handlers directly, so they exercise the real request →
* policy → engine → response path without needing a listening server.
*/
const ONTOLOGY = "ethereum-ecosystem";
const CURATOR = "curator-token";
const AGENT = "agent-token";
const params = <T extends Record<string, string>>(value: T) => ({ params: Promise.resolve(value) });
function request(
path: string,
init: { method?: string; body?: unknown; token?: string; headers?: Record<string, string> } = {}
): Request {
return new Request(`https://logicsrc.com${path}`, {
method: init.method ?? "GET",
headers: {
"content-type": "application/json",
...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
...(init.headers ?? {})
},
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) })
});
}
async function body<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
beforeEach(() => {
process.env.OPENONTOLOGY_API_TOKEN = CURATOR;
process.env.OPENONTOLOGY_AGENT_TOKEN = AGENT;
resetService();
});
afterEach(() => {
delete process.env.OPENONTOLOGY_API_TOKEN;
delete process.env.OPENONTOLOGY_AGENT_TOKEN;
resetService();
});
describe("reads", () => {
it("lists the ontologies it serves", async () => {
const response = await listOntologies();
expect(response.status).toBe(200);
const payload = await body<{ ontologies: Array<{ id: string }>; persistence: string }>(response);
expect(payload.ontologies[0]?.id).toBe(ONTOLOGY);
expect(["memory", "turso"]).toContain(payload.persistence);
});
it("serves the manifest with a revision ETag", async () => {
const response = await getManifest(request(`/api/ontologies/${ONTOLOGY}/manifest`), params({ ontologyId: ONTOLOGY }));
expect(response.status).toBe(200);
expect(response.headers.get("etag")).toMatch(/data-\d+/);
expect((await body<{ id: string }>(response)).id).toBe(ONTOLOGY);
});
it("404s an unknown ontology and names one that exists", async () => {
const response = await getManifest(request("/api/ontologies/nope/manifest"), params({ ontologyId: "nope" }));
expect(response.status).toBe(404);
const payload = await body<{ error: { code: string; hint?: string } }>(response);
expect(payload.error.code).toBe("OO-A-NOT-FOUND");
expect(payload.error.hint).toContain(ONTOLOGY);
});
it("serves the schema layer", async () => {
const response = await getSchema(request(`/api/ontologies/${ONTOLOGY}/schema`), params({ ontologyId: ONTOLOGY }));
const payload = await body<{ entityTypes: unknown[]; relationships: unknown[] }>(response);
expect(payload.entityTypes.length).toBeGreaterThanOrEqual(10);
expect(payload.relationships.length).toBeGreaterThanOrEqual(12);
});
it("lists and paginates entities", async () => {
const response = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?type=Person&limit=3`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ total: number; entities: Array<{ type: string }> }>(response);
expect(payload.entities).toHaveLength(3);
expect(payload.entities.every((entity) => entity.type === "Person")).toBe(true);
expect(payload.total).toBeGreaterThan(3);
});
it("falls back safely for negative and malformed pagination values", async () => {
const entityResponse = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?limit=-1&offset=invalid`),
params({ ontologyId: ONTOLOGY })
);
const entityPayload = await body<{ limit: number; offset: number }>(entityResponse);
expect(entityPayload.limit).toBe(50);
expect(entityPayload.offset).toBe(0);
const claimResponse = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?limit=1.5`),
params({ ontologyId: ONTOLOGY })
);
expect((await body<{ limit: number }>(claimResponse)).limit).toBe(100);
const defaultEvents = await events(
request(`/api/ontologies/${ONTOLOGY}/events`),
params({ ontologyId: ONTOLOGY })
);
const invalidEvents = await events(
request(`/api/ontologies/${ONTOLOGY}/events?limit=-1`),
params({ ontologyId: ONTOLOGY })
);
expect(await body(invalidEvents)).toEqual(await body(defaultEvents));
});
it("returns ranked matches with evidence when searching", async () => {
const response = await listEntities(
request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ matches: Array<{ id: string; matchedOn: string; evidence: string }> }>(response);
expect(payload.matches[0]?.id).toContain("avery");
expect(payload.matches[0]?.matchedOn).toBeTruthy();
});
it("returns an entity with its claims", async () => {
const response = await getEntity(
request(`/api/ontologies/${ONTOLOGY}/entities/eth:person:avery-lindqvist`),
params({ ontologyId: ONTOLOGY, entityId: "eth:person:avery-lindqvist" })
);
const payload = await body<{ entity: { canonicalName: string }; claims: unknown[] }>(response);
expect(payload.entity.canonicalName).toBe("Avery Lindqvist");
expect(payload.claims.length).toBeGreaterThan(0);
});
it("filters claims by status", async () => {
const asserted = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?status=asserted&limit=500`),
params({ ontologyId: ONTOLOGY })
);
const proposed = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?status=proposed&limit=500`),
params({ ontologyId: ONTOLOGY })
);
const a = await body<{ total: number }>(asserted);
const p = await body<{ total: number }>(proposed);
expect(a.total).toBeGreaterThan(p.total);
expect(p.total).toBeGreaterThanOrEqual(1);
});
it("returns a claim with its history, sources, and evidence", async () => {
const list = await listClaims(
request(`/api/ontologies/${ONTOLOGY}/claims?limit=1`),
params({ ontologyId: ONTOLOGY })
);
const { claims } = await body<{ claims: Array<{ id: string }> }>(list);
const id = claims[0]!.id;
const response = await getClaim(
request(`/api/ontologies/${ONTOLOGY}/claims/${id}`),
params({ ontologyId: ONTOLOGY, claimId: id })
);
const payload = await body<{ claim: { id: string }; history: unknown[]; sources: unknown[] }>(response);
expect(payload.claim.id).toBe(id);
expect(payload.history.length).toBeGreaterThanOrEqual(1);
expect(payload.sources.length).toBeGreaterThanOrEqual(1);
});
});
describe("query and explain", () => {
it("runs a saved query and returns claim ids per row", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, {
method: "POST",
body: { savedQuery: "orgs-behind-a-network" }
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ rows: Array<{ claims: string[] }>; resultId: string }>(response);
expect(payload.rows.length).toBeGreaterThan(0);
expect(payload.rows[0]!.claims).toHaveLength(4);
});
it("runs an ad-hoc triple-pattern query", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, {
method: "POST",
body: {
query: {
match: [{ subject: "?person", predicate: "worksOn", object: "?project" }],
select: ["?person", "?project"],
include: { claimStatus: ["asserted"] },
limit: 5
}
}
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ rows: unknown[] }>(response);
expect(payload.rows).toHaveLength(5);
});
it("rejects a query with neither savedQuery nor query", async () => {
const response = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, { method: "POST", body: {} }),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(422);
});
it("explains a row down to its sources", async () => {
const queried = await runQuery(
request(`/api/ontologies/${ONTOLOGY}/query`, { method: "POST", body: { savedQuery: "funded-work" } }),
params({ ontologyId: ONTOLOGY })
);
const { resultId } = await body<{ resultId: string }>(queried);
const response = await explain(
request(`/api/ontologies/${ONTOLOGY}/explain`, { method: "POST", body: { resultId, row: 0 } }),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ claims: Array<{ sources: unknown[] }>; ontology: string }>(response);
expect(payload.ontology).toContain(ONTOLOGY);
expect(payload.claims[0]!.sources.length).toBeGreaterThan(0);
});
it("validates the package", async () => {
const response = await validate(
request(`/api/ontologies/${ONTOLOGY}/validate`, { method: "POST", body: { strict: true } }),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ ok: boolean; counts: { error: number } }>(response);
expect(payload.ok).toBe(true);
expect(payload.counts.error).toBe(0);
});
});
describe("governance and permissions", () => {
const operations = [
{
op: "assert-claim",
value: {
subject: "eth:person:avery-lindqvist",
predicate: "worksOn",
object: { entity: "eth:project:docs-portal" },
sources: ["eth:source:roadmap-2026"]
}
}
];
it("denies an anonymous proposal and names the missing scope", async () => {
const response = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, { method: "POST", body: { title: "x", operations } }),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(403);
const payload = await body<{ error: { code: string; message: string } }>(response);
expect(payload.error.code).toBe("OO-A-DENIED");
expect(payload.error.message).toContain("ontology:claim:propose");
});
it("lets an agent token propose but never apply", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: AGENT,
body: { title: "agent proposal", operations, runId: "run_api_1" }
}),
params({ ontologyId: ONTOLOGY })
);
expect(created.status).toBe(201);
const { changeSet } = await body<{ changeSet: { id: string; status: string } }>(created);
expect(changeSet.status).toBe("proposed");
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: AGENT }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(403);
expect((await body<{ error: { message: string } }>(applied)).error.message).toMatch(/never apply directly/);
});
it("returns the semantic diff alongside a proposal", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "with diff", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ diff: { summary: { claimsAdded: number } } }>(created);
expect(payload.diff.summary.claimsAdded).toBe(1);
});
it("runs the curator loop: propose → approve → apply", async () => {
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "curator loop", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const { changeSet } = await body<{ changeSet: { id: string } }>(created);
const approved = await approve(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/approve`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(approved.status).toBe(201);
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(200);
const payload = await body<{ revision: string; addedClaims: string[] }>(applied);
expect(payload.revision).toMatch(/^data-\d+$/);
expect(payload.addedClaims).toHaveLength(1);
});
it("requires approval for a merge, then allows it", async () => {
const mergeOps = [
{ op: "merge-entity", source: "eth:person:s-haddad", target: "eth:person:samir-haddad" }
];
const created = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "merge", operations: mergeOps }
}),
params({ ontologyId: ONTOLOGY })
);
const { changeSet } = await body<{ changeSet: { id: string } }>(created);
const tooSoon = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(tooSoon.status).toBe(409);
await approve(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/approve`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
const applied = await apply(
request(`/api/ontologies/${ONTOLOGY}/changesets/${changeSet.id}/apply`, { method: "POST", token: CURATOR }),
params({ ontologyId: ONTOLOGY, changeSetId: changeSet.id })
);
expect(applied.status).toBe(200);
});
it("replays a POST carrying the same Idempotency-Key", async () => {
const key = "idem-1";
const first = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
headers: { "idempotency-key": key },
body: { title: "idempotent", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const second = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
headers: { "idempotency-key": key },
body: { title: "idempotent", operations }
}),
params({ ontologyId: ONTOLOGY })
);
expect(second.headers.get("idempotency-replayed")).toBe("true");
const a = await body<{ changeSet: { id: string } }>(first);
const b = await body<{ changeSet: { id: string } }>(second);
expect(b.changeSet.id).toBe(a.changeSet.id);
});
it("rejects a change set with no operations", async () => {
const response = await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "empty", operations: [] }
}),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(422);
});
it("lists change sets with their approval counts", async () => {
await createChangeSet(
request(`/api/ontologies/${ONTOLOGY}/changesets`, {
method: "POST",
token: CURATOR,
body: { title: "listed", operations }
}),
params({ ontologyId: ONTOLOGY })
);
const response = await listChangeSets(
request(`/api/ontologies/${ONTOLOGY}/changesets`),
params({ ontologyId: ONTOLOGY })
);
const payload = await body<{ changeSets: Array<{ title: string; approvals: number }> }>(response);
expect(payload.changeSets.some((entry) => entry.title === "listed")).toBe(true);
});
});
describe("events", () => {
it("returns the event log as JSON", async () => {
const response = await events(
request(`/api/ontologies/${ONTOLOGY}/events?limit=10`),
params({ ontologyId: ONTOLOGY })
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
});
it("streams Server-Sent Events when asked", async () => {
const controller = new AbortController();
const response = await events(
new Request(`https://logicsrc.com/api/ontologies/${ONTOLOGY}/events`, {
headers: { accept: "text/event-stream" },
signal: controller.signal
}),
params({ ontologyId: ONTOLOGY })
);
expect(response.headers.get("content-type")).toBe("text/event-stream");
const reader = response.body!.getReader();
const { value } = await reader.read();
const text = new TextDecoder().decode(value);
expect(text).toContain("event: ");
controller.abort();
await reader.cancel();
});
});
describe("openapi", () => {
it("describes every implemented path and points at the published schemas", async () => {
const response = await openapi();
const document = await body<{ openapi: string; paths: Record<string, unknown> }>(response);
expect(document.openapi).toBe("3.1.0");
for (const path of [
"/ontologies",
"/ontologies/{ontologyId}/manifest",
"/ontologies/{ontologyId}/schema",
"/ontologies/{ontologyId}/entities",
"/ontologies/{ontologyId}/entities/{entityId}",
"/ontologies/{ontologyId}/claims",
"/ontologies/{ontologyId}/claims/{claimId}",
"/ontologies/{ontologyId}/query",
"/ontologies/{ontologyId}/explain",
"/ontologies/{ontologyId}/validate",
"/ontologies/{ontologyId}/changesets",
"/ontologies/{ontologyId}/changesets/{changeSetId}",
"/ontologies/{ontologyId}/changesets/{changeSetId}/review",
"/ontologies/{ontologyId}/changesets/{changeSetId}/approve",
"/ontologies/{ontologyId}/changesets/{changeSetId}/apply",
"/ontologies/{ontologyId}/events"
]) {
expect(document.paths, `missing ${path}`).toHaveProperty([path]);
}
expect(JSON.stringify(document)).toContain("https://logicsrc.com/schemas/openontology/claim.schema.json");
});
});