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 = >(value: T) => ({ params: Promise.resolve(value) }); function request( path: string, init: { method?: string; body?: unknown; token?: string; headers?: Record } = {} ): 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(response: Response): Promise { 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 }>(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"); }); });