| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283 |
- /**
- * sdk.test.ts - Unit tests for the QMD SDK (library mode)
- *
- * Tests the public API exposed via `@tobilu/qmd` (src/index.ts).
- * Uses inline config (no YAML files) to verify the SDK works self-contained.
- */
- import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
- import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
- import YAML from "yaml";
- import {
- createStore,
- type QMDStore,
- type CollectionConfig,
- type StoreOptions,
- type UpdateProgress,
- type SearchOptions,
- type LexSearchOptions,
- type VectorSearchOptions,
- type ExpandQueryOptions,
- } from "../src/index.js";
- // =============================================================================
- // Test Helpers
- // =============================================================================
- let testDir: string;
- let docsDir: string;
- let notesDir: string;
- beforeAll(async () => {
- testDir = await mkdtemp(join(tmpdir(), "qmd-sdk-test-"));
- docsDir = join(testDir, "docs");
- notesDir = join(testDir, "notes");
- // Create test directories with sample markdown files
- await mkdir(docsDir, { recursive: true });
- await mkdir(notesDir, { recursive: true });
- await writeFile(join(docsDir, "readme.md"), "# Getting Started\n\nThis is the getting started guide for the project.\n");
- await writeFile(join(docsDir, "auth.md"), "# Authentication\n\nAuthentication uses JWT tokens for session management.\nUsers log in with email and password.\n");
- await writeFile(join(docsDir, "api.md"), "# API Reference\n\n## Endpoints\n\n### POST /login\nAuthenticate a user.\n\n### GET /users\nList all users.\n");
- await writeFile(join(notesDir, "meeting-2025-01.md"), "# January Planning Meeting\n\nDiscussed Q1 roadmap and resource allocation.\n");
- await writeFile(join(notesDir, "meeting-2025-02.md"), "# February Standup\n\nReviewed sprint progress. Authentication feature is on track.\n");
- await writeFile(join(notesDir, "ideas.md"), "# Project Ideas\n\n- Build a search engine\n- Create a knowledge base\n- Implement vector search\n");
- });
- afterAll(async () => {
- try {
- await rm(testDir, { recursive: true, force: true });
- } catch {
- // Ignore cleanup errors
- }
- });
- function freshDbPath(): string {
- return join(testDir, `test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
- }
- // =============================================================================
- // Constructor Tests
- // =============================================================================
- describe("createStore", () => {
- test("creates store with inline config", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- expect(store).toBeDefined();
- expect(store.dbPath).toBeTruthy();
- expect(store.internal).toBeDefined();
- await store.close();
- });
- test("creates store with YAML config file", async () => {
- const configPath = join(testDir, "test-config.yml");
- const config: CollectionConfig = {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- };
- writeFileSync(configPath, YAML.stringify(config));
- const store = await createStore({
- dbPath: freshDbPath(),
- configPath,
- });
- expect(store).toBeDefined();
- await store.close();
- });
- test("throws if dbPath is missing", async () => {
- await expect(
- createStore({ dbPath: "", config: { collections: {} } })
- ).rejects.toThrow("dbPath is required");
- });
- test("opens with just dbPath (DB-only mode)", async () => {
- const store = await createStore({ dbPath: freshDbPath() } as StoreOptions);
- expect(store).toBeDefined();
- // No collections yet — fresh DB
- const collections = await store.listCollections();
- expect(collections).toEqual([]);
- await store.close();
- });
- test("throws if both configPath and config are provided", async () => {
- await expect(
- createStore({
- dbPath: freshDbPath(),
- configPath: "/some/path.yml",
- config: { collections: {} },
- })
- ).rejects.toThrow("Provide either configPath or config, not both");
- });
- test("creates database file on disk", async () => {
- const dbPath = freshDbPath();
- const store = await createStore({
- dbPath,
- config: { collections: {} },
- });
- expect(existsSync(dbPath)).toBe(true);
- await store.close();
- });
- test("store.dbPath matches the provided path", async () => {
- const dbPath = freshDbPath();
- const store = await createStore({
- dbPath,
- config: { collections: {} },
- });
- expect(store.dbPath).toBe(dbPath);
- await store.close();
- });
- });
- // =============================================================================
- // Collection Management Tests
- // =============================================================================
- describe("collection management", () => {
- let store: QMDStore;
- beforeEach(async () => {
- store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- });
- afterEach(async () => {
- await store.close();
- });
- test("addCollection adds a collection to inline config", async () => {
- await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
- const collections = await store.listCollections();
- const names = collections.map(c => c.name);
- expect(names).toContain("docs");
- });
- test("addCollection with default pattern", async () => {
- await store.addCollection("notes", { path: notesDir });
- const collections = await store.listCollections();
- expect(collections.find(c => c.name === "notes")).toBeDefined();
- });
- test("removeCollection removes existing collection", async () => {
- await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
- const removed = await store.removeCollection("docs");
- expect(removed).toBe(true);
- const collections = await store.listCollections();
- expect(collections.map(c => c.name)).not.toContain("docs");
- });
- test("removeCollection returns false for non-existent collection", async () => {
- const removed = await store.removeCollection("nonexistent");
- expect(removed).toBe(false);
- });
- test("renameCollection renames a collection", async () => {
- await store.addCollection("old-name", { path: docsDir, pattern: "**/*.md" });
- const renamed = await store.renameCollection("old-name", "new-name");
- expect(renamed).toBe(true);
- const names = (await store.listCollections()).map(c => c.name);
- expect(names).toContain("new-name");
- expect(names).not.toContain("old-name");
- });
- test("renameCollection returns false for non-existent source", async () => {
- const renamed = await store.renameCollection("nonexistent", "new-name");
- expect(renamed).toBe(false);
- });
- test("renameCollection throws if target exists", async () => {
- await store.addCollection("a", { path: docsDir, pattern: "**/*.md" });
- await store.addCollection("b", { path: notesDir, pattern: "**/*.md" });
- await expect(store.renameCollection("a", "b")).rejects.toThrow("already exists");
- });
- test("listCollections returns empty array for empty config", async () => {
- const collections = await store.listCollections();
- expect(collections).toEqual([]);
- });
- test("multiple collections can be added", async () => {
- await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
- await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
- const names = (await store.listCollections()).map(c => c.name);
- expect(names).toContain("docs");
- expect(names).toContain("notes");
- expect(names).toHaveLength(2);
- });
- });
- // =============================================================================
- // Context Management Tests
- // =============================================================================
- describe("context management", () => {
- let store: QMDStore;
- beforeEach(async () => {
- store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- });
- afterEach(async () => {
- await store.close();
- });
- test("addContext adds context to a collection path", async () => {
- const added = await store.addContext("docs", "/auth", "Authentication docs");
- expect(added).toBe(true);
- const contexts = await store.listContexts();
- expect(contexts).toContainEqual({
- collection: "docs",
- path: "/auth",
- context: "Authentication docs",
- });
- });
- test("addContext returns false for non-existent collection", async () => {
- const added = await store.addContext("nonexistent", "/path", "Some context");
- expect(added).toBe(false);
- });
- test("removeContext removes existing context", async () => {
- await store.addContext("docs", "/auth", "Authentication docs");
- const removed = await store.removeContext("docs", "/auth");
- expect(removed).toBe(true);
- const contexts = await store.listContexts();
- expect(contexts.find(c => c.path === "/auth")).toBeUndefined();
- });
- test("removeContext returns false for non-existent context", async () => {
- const removed = await store.removeContext("docs", "/nonexistent");
- expect(removed).toBe(false);
- });
- test("setGlobalContext sets and retrieves global context", async () => {
- await store.setGlobalContext("Global knowledge base");
- const global = await store.getGlobalContext();
- expect(global).toBe("Global knowledge base");
- });
- test("setGlobalContext with undefined clears it", async () => {
- await store.setGlobalContext("Some context");
- await store.setGlobalContext(undefined);
- const global = await store.getGlobalContext();
- expect(global).toBeUndefined();
- });
- test("listContexts includes global context", async () => {
- await store.setGlobalContext("Global context");
- const contexts = await store.listContexts();
- expect(contexts).toContainEqual({
- collection: "*",
- path: "/",
- context: "Global context",
- });
- });
- test("listContexts returns contexts across multiple collections", async () => {
- await store.addContext("docs", "/", "Documentation");
- await store.addContext("notes", "/", "Personal notes");
- const contexts = await store.listContexts();
- expect(contexts.filter(c => c.path === "/")).toHaveLength(2);
- });
- test("multiple contexts on same collection", async () => {
- await store.addContext("docs", "/auth", "Auth docs");
- await store.addContext("docs", "/api", "API docs");
- const contexts = (await store.listContexts()).filter(c => c.collection === "docs");
- expect(contexts).toHaveLength(2);
- expect(contexts.map(c => c.path).sort()).toEqual(["/api", "/auth"]);
- });
- test("addContext overwrites existing context for same path", async () => {
- await store.addContext("docs", "/auth", "Old context");
- await store.addContext("docs", "/auth", "New context");
- const contexts = (await store.listContexts()).filter(c => c.path === "/auth");
- expect(contexts).toHaveLength(1);
- expect(contexts[0]!.context).toBe("New context");
- });
- });
- // =============================================================================
- // Inline Config Isolation Tests
- // =============================================================================
- describe("inline config isolation", () => {
- test("inline config does not write any files to disk", async () => {
- const configDir = join(testDir, "should-not-exist");
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
- await store.addContext("docs", "/", "Documentation");
- expect(existsSync(configDir)).toBe(false);
- await store.close();
- });
- test("inline config mutations persist within session", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
- await store.addContext("docs", "/", "My docs");
- // Verify the mutations are visible
- const collections = await store.listCollections();
- expect(collections.map(c => c.name)).toContain("docs");
- const contexts = await store.listContexts();
- expect(contexts).toContainEqual({
- collection: "docs",
- path: "/",
- context: "My docs",
- });
- await store.close();
- });
- test("two stores with different inline configs are independent", async () => {
- const store1 = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- // Close first store (resets config source)
- await store1.close();
- const store2 = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- const names = (await store2.listCollections()).map(c => c.name);
- expect(names).toContain("notes");
- expect(names).not.toContain("docs");
- await store2.close();
- });
- });
- // =============================================================================
- // YAML Config File Tests
- // =============================================================================
- describe("YAML config file mode", () => {
- test("loads collections from YAML file", async () => {
- const configPath = join(testDir, `config-${Date.now()}.yml`);
- const config: CollectionConfig = {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- };
- writeFileSync(configPath, YAML.stringify(config));
- const store = await createStore({ dbPath: freshDbPath(), configPath });
- const names = (await store.listCollections()).map(c => c.name);
- expect(names).toContain("docs");
- expect(names).toContain("notes");
- await store.close();
- });
- test("addCollection persists to YAML file", async () => {
- const configPath = join(testDir, `config-persist-${Date.now()}.yml`);
- writeFileSync(configPath, YAML.stringify({ collections: {} }));
- const store = await createStore({ dbPath: freshDbPath(), configPath });
- await store.addCollection("newcol", { path: docsDir, pattern: "**/*.md" });
- await store.close();
- // Read the YAML file directly and verify
- const raw = readFileSync(configPath, "utf-8");
- const parsed = YAML.parse(raw) as CollectionConfig;
- expect(parsed.collections).toHaveProperty("newcol");
- expect(parsed.collections.newcol!.path).toBe(docsDir);
- });
- test("context persists to YAML file", async () => {
- const configPath = join(testDir, `config-ctx-${Date.now()}.yml`);
- writeFileSync(configPath, YAML.stringify({
- collections: { docs: { path: docsDir, pattern: "**/*.md" } },
- }));
- const store = await createStore({ dbPath: freshDbPath(), configPath });
- await store.addContext("docs", "/api", "API documentation");
- await store.close();
- const raw = readFileSync(configPath, "utf-8");
- const parsed = YAML.parse(raw) as CollectionConfig;
- expect(parsed.collections.docs!.context).toEqual({ "/api": "API documentation" });
- });
- test("non-existent config file returns empty collections", async () => {
- const configPath = join(testDir, "nonexistent-config.yml");
- const store = await createStore({ dbPath: freshDbPath(), configPath });
- const collections = await store.listCollections();
- expect(collections).toEqual([]);
- await store.close();
- });
- });
- // =============================================================================
- // Search Tests (BM25 - no LLM needed)
- // =============================================================================
- describe("searchLex (BM25)", () => {
- let store: QMDStore;
- let dbPath: string;
- beforeAll(async () => {
- dbPath = join(testDir, "search-test.sqlite");
- store = await createStore({
- dbPath,
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- // Index documents manually using internal store
- const now = new Date().toISOString();
- const { internal } = store;
- const fs = require("fs");
- // Index docs collection
- for (const file of ["readme.md", "auth.md", "api.md"]) {
- const fullPath = join(docsDir, file);
- const content = fs.readFileSync(fullPath, "utf-8");
- const hash = require("crypto").createHash("sha256").update(content).digest("hex");
- const title = content.match(/^#\s+(.+)/m)?.[1] || file;
- internal.insertContent(hash, content, now);
- internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
- }
- // Index notes collection
- for (const file of ["meeting-2025-01.md", "meeting-2025-02.md", "ideas.md"]) {
- const fullPath = join(notesDir, file);
- const content = fs.readFileSync(fullPath, "utf-8");
- const hash = require("crypto").createHash("sha256").update(content).digest("hex");
- const title = content.match(/^#\s+(.+)/m)?.[1] || file;
- internal.insertContent(hash, content, now);
- internal.insertDocument("notes", `qmd://notes/${file}`, title, hash, now, now);
- }
- });
- afterAll(async () => {
- await store.close();
- });
- test("searchLex returns results for matching query", async () => {
- const results = await store.searchLex("authentication");
- expect(results.length).toBeGreaterThan(0);
- });
- test("searchLex results have expected shape", async () => {
- const results = await store.searchLex("authentication");
- expect(results.length).toBeGreaterThan(0);
- const result = results[0]!;
- expect(result).toHaveProperty("filepath");
- expect(result).toHaveProperty("score");
- expect(result).toHaveProperty("title");
- expect(result).toHaveProperty("docid");
- expect(result).toHaveProperty("collectionName");
- expect(typeof result.score).toBe("number");
- expect(result.score).toBeGreaterThan(0);
- });
- test("searchLex respects limit option", async () => {
- const results = await store.searchLex("meeting", { limit: 1 });
- expect(results.length).toBeLessThanOrEqual(1);
- });
- test("searchLex with collection filter", async () => {
- const results = await store.searchLex("authentication", { collection: "notes" });
- for (const r of results) {
- expect(r.collectionName).toBe("notes");
- }
- });
- test("searchLex returns empty for non-matching query", async () => {
- const results = await store.searchLex("xyznonexistentterm123");
- expect(results).toHaveLength(0);
- });
- test("searchLex finds documents across collections", async () => {
- const results = await store.searchLex("authentication", { limit: 10 });
- const collections = new Set(results.map(r => r.collectionName));
- // Auth appears in both docs/auth.md and notes/meeting-2025-02.md
- expect(collections.size).toBeGreaterThanOrEqual(1);
- });
- });
- // =============================================================================
- // Unified search() API Tests
- // =============================================================================
- describe("search (unified API)", () => {
- let store: QMDStore;
- beforeAll(async () => {
- store = await createStore({
- dbPath: join(testDir, "unified-search-test.sqlite"),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- await store.update();
- });
- afterAll(async () => {
- await store.close();
- });
- test("search() requires query or queries", async () => {
- await expect(store.search({} as SearchOptions)).rejects.toThrow("requires either 'query' or 'queries'");
- });
- test("search() with query and rerank:false returns results", async () => {
- const results = await store.search({ query: "authentication", rerank: false });
- expect(results.length).toBeGreaterThan(0);
- expect(results[0]).toHaveProperty("file");
- expect(results[0]).toHaveProperty("score");
- expect(results[0]).toHaveProperty("title");
- expect(results[0]).toHaveProperty("bestChunk");
- expect(results[0]).toHaveProperty("docid");
- });
- test("search() with intent and rerank:false returns results", async () => {
- const results = await store.search({
- query: "meeting",
- intent: "quarterly planning and roadmap",
- rerank: false,
- });
- expect(results.length).toBeGreaterThan(0);
- });
- test("search() with collection filter", async () => {
- const results = await store.search({
- query: "authentication",
- collection: "docs",
- rerank: false,
- });
- for (const r of results) {
- expect(r.file).toMatch(/^qmd:\/\/docs\//);
- }
- });
- test("search() with collections filter", async () => {
- const results = await store.search({
- query: "authentication",
- collections: ["docs"],
- rerank: false,
- });
- for (const r of results) {
- expect(r.file).toMatch(/^qmd:\/\/docs\//);
- }
- });
- test("search() with limit", async () => {
- const results = await store.search({ query: "meeting", limit: 1, rerank: false });
- expect(results.length).toBeLessThanOrEqual(1);
- });
- test("search() with pre-expanded queries and rerank:false", async () => {
- const results = await store.search({
- queries: [
- { type: "lex", query: "authentication JWT" },
- { type: "lex", query: "login session" },
- ],
- rerank: false,
- });
- expect(results.length).toBeGreaterThan(0);
- });
- test("search() returns empty for non-matching query", async () => {
- const results = await store.search({ query: "xyznonexistentterm123", rerank: false });
- expect(results).toHaveLength(0);
- });
- });
- // =============================================================================
- // Document Retrieval Tests
- // =============================================================================
- describe("get and multiGet", () => {
- let store: QMDStore;
- beforeAll(async () => {
- store = await createStore({
- dbPath: join(testDir, "get-test.sqlite"),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- // Index documents
- const now = new Date().toISOString();
- const { internal } = store;
- const fs = require("fs");
- for (const file of ["readme.md", "auth.md", "api.md"]) {
- const fullPath = join(docsDir, file);
- const content = fs.readFileSync(fullPath, "utf-8");
- const hash = require("crypto").createHash("sha256").update(content).digest("hex");
- const title = content.match(/^#\s+(.+)/m)?.[1] || file;
- internal.insertContent(hash, content, now);
- internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
- }
- });
- afterAll(async () => {
- await store.close();
- });
- test("get retrieves a document by path", async () => {
- const result = await store.get("qmd://docs/auth.md");
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- expect(result.title).toBe("Authentication");
- expect(result.collectionName).toBe("docs");
- }
- });
- test("get with includeBody returns body content", async () => {
- const result = await store.get("qmd://docs/auth.md", { includeBody: true });
- if (!("error" in result)) {
- expect(result.body).toBeDefined();
- expect(result.body).toContain("JWT tokens");
- }
- });
- test("get returns not_found for missing document", async () => {
- const result = await store.get("qmd://docs/nonexistent.md");
- expect("error" in result).toBe(true);
- if ("error" in result) {
- expect(result.error).toBe("not_found");
- }
- });
- test("get by docid", async () => {
- // First get a document to find its docid
- const doc = await store.get("qmd://docs/readme.md");
- if (!("error" in doc)) {
- const byDocid = await store.get(`#${doc.docid}`);
- expect("error" in byDocid).toBe(false);
- if (!("error" in byDocid)) {
- expect(byDocid.docid).toBe(doc.docid);
- }
- }
- });
- test("multiGet retrieves multiple documents", async () => {
- const { docs, errors } = await store.multiGet("qmd://docs/*.md");
- expect(docs.length).toBeGreaterThan(0);
- });
- });
- // =============================================================================
- // Index Health Tests
- // =============================================================================
- describe("index health", () => {
- let store: QMDStore;
- beforeEach(async () => {
- store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- });
- afterEach(async () => {
- await store.close();
- });
- test("getStatus returns valid structure", async () => {
- const status = await store.getStatus();
- expect(status).toHaveProperty("totalDocuments");
- expect(status).toHaveProperty("needsEmbedding");
- expect(status).toHaveProperty("hasVectorIndex");
- expect(status).toHaveProperty("collections");
- expect(typeof status.totalDocuments).toBe("number");
- });
- test("getIndexHealth returns valid structure", async () => {
- const health = await store.getIndexHealth();
- expect(health).toHaveProperty("needsEmbedding");
- expect(health).toHaveProperty("totalDocs");
- expect(typeof health.needsEmbedding).toBe("number");
- expect(typeof health.totalDocs).toBe("number");
- });
- test("fresh store has zero documents", async () => {
- const status = await store.getStatus();
- expect(status.totalDocuments).toBe(0);
- });
- });
- // =============================================================================
- // Update Tests
- // =============================================================================
- describe("update", () => {
- test("indexes files and returns correct stats", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- const result = await store.update();
- expect(result.collections).toBe(1);
- expect(result.indexed).toBe(3); // readme.md, auth.md, api.md
- expect(result.updated).toBe(0);
- expect(result.unchanged).toBe(0);
- expect(result.removed).toBe(0);
- expect(typeof result.needsEmbedding).toBe("number");
- await store.close();
- });
- test("second update shows unchanged files", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- await store.update();
- const result = await store.update();
- expect(result.indexed).toBe(0);
- expect(result.unchanged).toBe(3);
- await store.close();
- });
- test("update with onProgress callback fires", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- const progress: UpdateProgress[] = [];
- await store.update({
- onProgress: (info) => progress.push(info),
- });
- expect(progress.length).toBeGreaterThan(0);
- expect(progress[0]!.collection).toBe("docs");
- expect(progress[0]!.current).toBeGreaterThanOrEqual(1);
- expect(progress[0]!.total).toBe(3);
- await store.close();
- });
- test("update with collection filter", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- const result = await store.update({ collections: ["docs"] });
- expect(result.collections).toBe(1);
- expect(result.indexed).toBe(3); // Only docs
- await store.close();
- });
- test("update multiple collections", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- const result = await store.update();
- expect(result.collections).toBe(2);
- expect(result.indexed).toBe(6); // 3 docs + 3 notes
- await store.close();
- });
- test("documents are searchable after update", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- await store.update();
- const results = await store.searchLex("authentication");
- expect(results.length).toBeGreaterThan(0);
- await store.close();
- });
- });
- // =============================================================================
- // Lifecycle Tests
- // =============================================================================
- describe("lifecycle", () => {
- test("close() is async and does not throw", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- // close() should return a promise
- const result = store.close();
- expect(result).toBeInstanceOf(Promise);
- await result;
- });
- test("close() makes subsequent operations throw", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- await store.close();
- // Database operations should fail after close
- await expect(store.getStatus()).rejects.toThrow();
- });
- test("multiple stores can coexist with different databases", async () => {
- const store1 = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- // Note: since config source is module-level, we close store1 first
- await store1.close();
- const store2 = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- },
- });
- const names = (await store2.listCollections()).map(c => c.name);
- expect(names).toContain("notes");
- expect(names).not.toContain("docs");
- await store2.close();
- });
- });
- // =============================================================================
- // Config Initialization Tests
- // =============================================================================
- describe("config initialization", () => {
- test("inline config with global_context is preserved", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- global_context: "System knowledge base",
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- const global = await store.getGlobalContext();
- expect(global).toBe("System knowledge base");
- await store.close();
- });
- test("inline config with pre-existing contexts is preserved", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: {
- path: docsDir,
- pattern: "**/*.md",
- context: { "/auth": "Authentication docs" },
- },
- },
- },
- });
- const contexts = await store.listContexts();
- expect(contexts).toContainEqual({
- collection: "docs",
- path: "/auth",
- context: "Authentication docs",
- });
- await store.close();
- });
- test("inline config with empty collections object works", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- expect(await store.listCollections()).toEqual([]);
- expect(await store.listContexts()).toEqual([]);
- await store.close();
- });
- test("inline config with multiple collection options", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: {
- collections: {
- docs: {
- path: docsDir,
- pattern: "**/*.md",
- ignore: ["drafts/**"],
- includeByDefault: true,
- },
- notes: {
- path: notesDir,
- pattern: "**/*.md",
- includeByDefault: false,
- },
- },
- },
- });
- const collections = await store.listCollections();
- expect(collections).toHaveLength(2);
- await store.close();
- });
- });
- // =============================================================================
- // Type Export Tests (compile-time checks, runtime verification)
- // =============================================================================
- describe("type exports", () => {
- test("StoreOptions type is usable", () => {
- const opts: StoreOptions = {
- dbPath: "/tmp/test.sqlite",
- config: { collections: {} },
- };
- expect(opts.dbPath).toBe("/tmp/test.sqlite");
- });
- test("CollectionConfig type is usable", () => {
- const config: CollectionConfig = {
- global_context: "test",
- collections: {
- test: { path: "/tmp", pattern: "**/*.md" },
- },
- };
- expect(config.collections).toHaveProperty("test");
- });
- test("QMDStore type exposes expected methods", async () => {
- const store = await createStore({
- dbPath: freshDbPath(),
- config: { collections: {} },
- });
- // Verify all methods exist
- expect(typeof store.search).toBe("function");
- expect(typeof store.searchLex).toBe("function");
- expect(typeof store.searchVector).toBe("function");
- expect(typeof store.expandQuery).toBe("function");
- expect(typeof store.get).toBe("function");
- expect(typeof store.multiGet).toBe("function");
- expect(typeof store.addCollection).toBe("function");
- expect(typeof store.removeCollection).toBe("function");
- expect(typeof store.renameCollection).toBe("function");
- expect(typeof store.listCollections).toBe("function");
- expect(typeof store.addContext).toBe("function");
- expect(typeof store.removeContext).toBe("function");
- expect(typeof store.setGlobalContext).toBe("function");
- expect(typeof store.getGlobalContext).toBe("function");
- expect(typeof store.listContexts).toBe("function");
- expect(typeof store.getStatus).toBe("function");
- expect(typeof store.getIndexHealth).toBe("function");
- expect(typeof store.update).toBe("function");
- expect(typeof store.embed).toBe("function");
- expect(typeof store.close).toBe("function");
- await store.close();
- });
- });
- // =============================================================================
- // DB-Only Mode Tests (self-contained store)
- // =============================================================================
- describe("DB-only mode", () => {
- test("reopen store with just dbPath after config+update session", async () => {
- const dbPath = freshDbPath();
- // Session 1: create store with config, update, close
- const store1 = await createStore({
- dbPath,
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- notes: { path: notesDir, pattern: "**/*.md" },
- },
- global_context: "Test knowledge base",
- },
- });
- await store1.update();
- // Verify documents indexed
- const status1 = await store1.getStatus();
- expect(status1.totalDocuments).toBe(6);
- await store1.close();
- // Session 2: reopen with just dbPath — no config
- const store2 = await createStore({ dbPath } as StoreOptions);
- // Collections should still be available
- const collections = await store2.listCollections();
- expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);
- // Search should still work
- const results = await store2.searchLex("authentication");
- expect(results.length).toBeGreaterThan(0);
- // Global context should still be available
- const globalCtx = await store2.getGlobalContext();
- expect(globalCtx).toBe("Test knowledge base");
- // Contexts from collections should persist
- const status2 = await store2.getStatus();
- expect(status2.totalDocuments).toBe(6);
- await store2.close();
- });
- test("config sync populates store_collections table", async () => {
- const dbPath = freshDbPath();
- const store = await createStore({
- dbPath,
- config: {
- collections: {
- docs: {
- path: docsDir,
- pattern: "**/*.md",
- context: { "/auth": "Auth documentation" },
- },
- },
- },
- });
- // Verify collections are in the DB via listCollections
- const collections = await store.listCollections();
- expect(collections).toHaveLength(1);
- expect(collections[0]!.name).toBe("docs");
- expect(collections[0]!.pwd).toBe(docsDir);
- // Verify contexts are accessible
- const contexts = await store.listContexts();
- expect(contexts).toContainEqual({
- collection: "docs",
- path: "/auth",
- context: "Auth documentation",
- });
- await store.close();
- });
- test("config hash skip: second init with same config skips sync", async () => {
- const dbPath = freshDbPath();
- const config = {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- };
- // First init — syncs config
- const store1 = await createStore({ dbPath, config });
- await store1.close();
- // Second init with same config — should skip sync (no-op, but should not error)
- const store2 = await createStore({ dbPath, config });
- const collections = await store2.listCollections();
- expect(collections).toHaveLength(1);
- expect(collections[0]!.name).toBe("docs");
- await store2.close();
- });
- test("DB-only mode supports collection mutations", async () => {
- const dbPath = freshDbPath();
- // Session 1: create with config
- const store1 = await createStore({
- dbPath,
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- await store1.close();
- // Session 2: reopen DB-only, add a collection
- const store2 = await createStore({ dbPath } as StoreOptions);
- await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
- const names = (await store2.listCollections()).map(c => c.name).sort();
- expect(names).toEqual(["docs", "notes"]);
- await store2.close();
- // Session 3: reopen DB-only again, verify both collections persist
- const store3 = await createStore({ dbPath } as StoreOptions);
- const names3 = (await store3.listCollections()).map(c => c.name).sort();
- expect(names3).toEqual(["docs", "notes"]);
- await store3.close();
- });
- test("DB-only mode supports context mutations", async () => {
- const dbPath = freshDbPath();
- // Session 1: create with config
- const store1 = await createStore({
- dbPath,
- config: {
- collections: {
- docs: { path: docsDir, pattern: "**/*.md" },
- },
- },
- });
- await store1.addContext("docs", "/api", "API docs");
- await store1.setGlobalContext("Global context");
- await store1.close();
- // Session 2: reopen DB-only
- const store2 = await createStore({ dbPath } as StoreOptions);
- const contexts = await store2.listContexts();
- expect(contexts).toContainEqual({
- collection: "docs",
- path: "/api",
- context: "API docs",
- });
- expect(contexts).toContainEqual({
- collection: "*",
- path: "/",
- context: "Global context",
- });
- await store2.close();
- });
- });
|