/** * TaxonomyService Tests * * Tests for SKOS taxonomy operations using TDD approach. */ import { describe, expect, test } from "bun:test"; import { Effect, Layer } from "effect"; import { TaxonomyService, TaxonomyServiceImpl } from "./TaxonomyService.js"; import { LibSQLDatabase } from "./LibSQLDatabase.js"; // Test layer - LibSQLDatabase.make initializes the schema (including taxonomy tables) // Then we use the same DB URL for TaxonomyService const makeTestLayer = () => { // Use file DB for tests to ensure schema persistence across layer creations const testDbPath = `file:${process.cwd()}/.test-db-${Date.now()}.db`; return { layer: Layer.mergeAll( LibSQLDatabase.make({ url: testDbPath }), TaxonomyServiceImpl.make({ url: testDbPath }) ), cleanup: () => { // Cleanup handled by Layer finalizers }, }; }; const runTest = (effect: Effect.Effect) => { const { layer } = makeTestLayer(); return Effect.provide(effect, layer).pipe(Effect.runPromise); }; describe("TaxonomyService - Concept CRUD", () => { test("addConcept creates a new concept", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "machine-learning", prefLabel: "Machine Learning", altLabels: ["ML", "statistical learning"], definition: "Algorithms that learn from data", }); const concept = yield* svc.getConcept("machine-learning"); expect(concept).not.toBeNull(); expect(concept?.prefLabel).toBe("Machine Learning"); expect(concept?.altLabels).toEqual(["ML", "statistical learning"]); }) ); }); test("addConcept with minimal fields", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "typescript", prefLabel: "TypeScript", }); const concept = yield* svc.getConcept("typescript"); expect(concept).not.toBeNull(); expect(concept?.prefLabel).toBe("TypeScript"); expect(concept?.altLabels).toEqual([]); expect(concept?.definition).toBeUndefined(); }) ); }); test("getConcept returns null for non-existent concept", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; const concept = yield* svc.getConcept("non-existent"); expect(concept).toBeNull(); }) ); }); test("listConcepts returns all concepts", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "js", prefLabel: "JavaScript" }); yield* svc.addConcept({ id: "ts", prefLabel: "TypeScript" }); yield* svc.addConcept({ id: "rust", prefLabel: "Rust" }); const concepts = yield* svc.listConcepts(); expect(concepts).toHaveLength(3); expect(concepts.map((c) => c.id)).toContain("js"); expect(concepts.map((c) => c.id)).toContain("ts"); expect(concepts.map((c) => c.id)).toContain("rust"); }) ); }); test("updateConcept modifies existing concept", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ai", prefLabel: "AI" }); yield* svc.updateConcept("ai", { prefLabel: "Artificial Intelligence", altLabels: ["AI", "machine intelligence"], definition: "Simulation of human intelligence", }); const concept = yield* svc.getConcept("ai"); expect(concept?.prefLabel).toBe("Artificial Intelligence"); expect(concept?.altLabels).toEqual(["AI", "machine intelligence"]); expect(concept?.definition).toBe("Simulation of human intelligence"); }) ); }); }); describe("TaxonomyService - Hierarchy (Polyhierarchy)", () => { test("addBroader creates parent relationship", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); yield* svc.addConcept({ id: "ai", prefLabel: "Artificial Intelligence", }); yield* svc.addBroader("ml", "ai"); const parents = yield* svc.getBroader("ml"); expect(parents).toHaveLength(1); expect(parents[0].id).toBe("ai"); }) ); }); test("getNarrower returns children", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); yield* svc.addConcept({ id: "dl", prefLabel: "Deep Learning" }); yield* svc.addConcept({ id: "ai", prefLabel: "Artificial Intelligence", }); yield* svc.addBroader("ml", "ai"); yield* svc.addBroader("dl", "ml"); const children = yield* svc.getNarrower("ml"); expect(children).toHaveLength(1); expect(children[0].id).toBe("dl"); }) ); }); test("polyhierarchy: concept can have multiple parents", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "nlp", prefLabel: "Natural Language Processing", }); yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); yield* svc.addConcept({ id: "linguistics", prefLabel: "Linguistics" }); // NLP is a child of both ML and Linguistics yield* svc.addBroader("nlp", "ml"); yield* svc.addBroader("nlp", "linguistics"); const parents = yield* svc.getBroader("nlp"); expect(parents).toHaveLength(2); expect(parents.map((p) => p.id).sort()).toEqual(["linguistics", "ml"]); }) ); }); test("removeBroader deletes parent relationship", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "nlp", prefLabel: "NLP" }); yield* svc.addConcept({ id: "ml", prefLabel: "ML" }); yield* svc.addConcept({ id: "linguistics", prefLabel: "Linguistics" }); yield* svc.addBroader("nlp", "ml"); yield* svc.addBroader("nlp", "linguistics"); yield* svc.removeBroader("nlp", "linguistics"); const parents = yield* svc.getBroader("nlp"); expect(parents).toHaveLength(1); expect(parents[0].id).toBe("ml"); }) ); }); test("getAncestors returns transitive broader concepts", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; // Create hierarchy: DL -> ML -> AI -> CS yield* svc.addConcept({ id: "dl", prefLabel: "Deep Learning" }); yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); yield* svc.addConcept({ id: "ai", prefLabel: "Artificial Intelligence", }); yield* svc.addConcept({ id: "cs", prefLabel: "Computer Science" }); yield* svc.addBroader("dl", "ml"); yield* svc.addBroader("ml", "ai"); yield* svc.addBroader("ai", "cs"); const ancestors = yield* svc.getAncestors("dl"); expect(ancestors).toHaveLength(3); expect(ancestors.map((a) => a.id).sort()).toEqual(["ai", "cs", "ml"]); }) ); }); test("getDescendants returns transitive narrower concepts", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; // Create hierarchy: CS -> AI -> ML -> DL yield* svc.addConcept({ id: "cs", prefLabel: "Computer Science" }); yield* svc.addConcept({ id: "ai", prefLabel: "Artificial Intelligence", }); yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); yield* svc.addConcept({ id: "dl", prefLabel: "Deep Learning" }); yield* svc.addBroader("dl", "ml"); yield* svc.addBroader("ml", "ai"); yield* svc.addBroader("ai", "cs"); const descendants = yield* svc.getDescendants("cs"); expect(descendants).toHaveLength(3); expect(descendants.map((d) => d.id).sort()).toEqual(["ai", "dl", "ml"]); }) ); }); }); describe("TaxonomyService - Relations (Associative)", () => { test("addRelated creates symmetric relationship", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "js", prefLabel: "JavaScript" }); yield* svc.addConcept({ id: "ts", prefLabel: "TypeScript" }); yield* svc.addRelated("js", "ts"); const jsRelated = yield* svc.getRelated("js"); const tsRelated = yield* svc.getRelated("ts"); expect(jsRelated).toHaveLength(1); expect(jsRelated[0].id).toBe("ts"); expect(tsRelated).toHaveLength(1); expect(tsRelated[0].id).toBe("js"); }) ); }); test("addRelated with custom relation type", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "react", prefLabel: "React" }); yield* svc.addConcept({ id: "vue", prefLabel: "Vue" }); yield* svc.addRelated("react", "vue", "alternative"); const related = yield* svc.getRelated("react"); expect(related).toHaveLength(1); expect(related[0].id).toBe("vue"); }) ); }); test("removeRelated deletes symmetric relationship", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "js", prefLabel: "JavaScript" }); yield* svc.addConcept({ id: "ts", prefLabel: "TypeScript" }); yield* svc.addRelated("js", "ts"); yield* svc.removeRelated("js", "ts"); const jsRelated = yield* svc.getRelated("js"); const tsRelated = yield* svc.getRelated("ts"); expect(jsRelated).toHaveLength(0); expect(tsRelated).toHaveLength(0); }) ); }); }); describe("TaxonomyService - Document Mappings", () => { test("assignToDocument links concept to document (without FK check)", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ml", prefLabel: "Machine Learning" }); // Note: In real usage, doc_id must exist in documents table (FK constraint) // This test verifies the assignment would fail with FK constraint // In integration tests with real DB, create actual documents const result = yield* svc .assignToDocument("doc-123", "ml", 0.95, "llm") .pipe(Effect.either); // Should fail due to FK constraint (no document exists) expect(result._tag).toBe("Left"); // Test that we can query (returns empty array) const concepts = yield* svc.getDocumentConcepts("doc-123"); expect(concepts).toBeInstanceOf(Array); expect(concepts).toHaveLength(0); }) ); }); test("assignToDocument with defaults", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ai", prefLabel: "AI" }); const result = yield* svc .assignToDocument("doc-456", "ai") .pipe(Effect.either); // Should fail due to FK constraint expect(result._tag).toBe("Left"); const concepts = yield* svc.getDocumentConcepts("doc-456"); expect(concepts).toBeInstanceOf(Array); expect(concepts).toHaveLength(0); }) ); }); test("getConceptDocuments returns documents for concept", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ml", prefLabel: "ML" }); // FK constraints prevent insertion without real documents // Test the query logic instead const assignments = yield* svc.getConceptDocuments("ml"); expect(assignments).toBeInstanceOf(Array); expect(assignments).toHaveLength(0); // No assignments without documents }) ); }); test("removeFromDocument unlinks concept", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; yield* svc.addConcept({ id: "ml", prefLabel: "ML" }); // Can't test without real document, but verify method works yield* svc.removeFromDocument("doc-1", "ml"); const concepts = yield* svc.getDocumentConcepts("doc-1"); expect(concepts).toHaveLength(0); }) ); }); }); describe("TaxonomyService - Bulk Operations", () => { test("seedFromJSON loads taxonomy", async () => { await runTest( Effect.gen(function* () { const svc = yield* TaxonomyService; const taxonomy = { concepts: [ { id: "cs", prefLabel: "Computer Science" }, { id: "ai", prefLabel: "Artificial Intelligence" }, { id: "ml", prefLabel: "Machine Learning" }, ], hierarchy: [ { conceptId: "ai", broaderId: "cs" }, { conceptId: "ml", broaderId: "ai" }, ], relations: [ { conceptId: "ml", relatedId: "ai", relationType: "related" }, ], }; yield* svc.seedFromJSON(taxonomy); const concepts = yield* svc.listConcepts(); expect(concepts).toHaveLength(3); const mlParents = yield* svc.getBroader("ml"); expect(mlParents).toHaveLength(1); expect(mlParents[0].id).toBe("ai"); }) ); }); });