sdk.test.ts 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360
  1. /**
  2. * sdk.test.ts - Unit tests for the QMD SDK (library mode)
  3. *
  4. * Tests the public API exposed via `@tobilu/qmd` (src/index.ts).
  5. * Uses inline config (no YAML files) to verify the SDK works self-contained.
  6. */
  7. import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
  8. import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
  9. import { tmpdir } from "node:os";
  10. import { join } from "node:path";
  11. import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
  12. import YAML from "yaml";
  13. import {
  14. createStore,
  15. type QMDStore,
  16. type CollectionConfig,
  17. type StoreOptions,
  18. type UpdateProgress,
  19. type SearchOptions,
  20. type LexSearchOptions,
  21. type VectorSearchOptions,
  22. type ExpandQueryOptions,
  23. } from "../src/index.js";
  24. import { setDefaultLlamaCpp } from "../src/llm.js";
  25. // =============================================================================
  26. // Test Helpers
  27. // =============================================================================
  28. let testDir: string;
  29. let docsDir: string;
  30. let notesDir: string;
  31. beforeAll(async () => {
  32. testDir = await mkdtemp(join(tmpdir(), "qmd-sdk-test-"));
  33. docsDir = join(testDir, "docs");
  34. notesDir = join(testDir, "notes");
  35. // Create test directories with sample markdown files
  36. await mkdir(docsDir, { recursive: true });
  37. await mkdir(notesDir, { recursive: true });
  38. await writeFile(join(docsDir, "readme.md"), "# Getting Started\n\nThis is the getting started guide for the project.\n");
  39. await writeFile(join(docsDir, "auth.md"), "# Authentication\n\nAuthentication uses JWT tokens for session management.\nUsers log in with email and password.\n");
  40. 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");
  41. await writeFile(join(notesDir, "meeting-2025-01.md"), "# January Planning Meeting\n\nDiscussed Q1 roadmap and resource allocation.\n");
  42. await writeFile(join(notesDir, "meeting-2025-02.md"), "# February Standup\n\nReviewed sprint progress. Authentication feature is on track.\n");
  43. await writeFile(join(notesDir, "ideas.md"), "# Project Ideas\n\n- Build a search engine\n- Create a knowledge base\n- Implement vector search\n");
  44. });
  45. afterAll(async () => {
  46. try {
  47. await rm(testDir, { recursive: true, force: true });
  48. } catch {
  49. // Ignore cleanup errors
  50. }
  51. });
  52. function freshDbPath(): string {
  53. return join(testDir, `test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
  54. }
  55. // =============================================================================
  56. // Constructor Tests
  57. // =============================================================================
  58. describe("createStore", () => {
  59. test("creates store with inline config", async () => {
  60. const store = await createStore({
  61. dbPath: freshDbPath(),
  62. config: {
  63. collections: {
  64. docs: { path: docsDir, pattern: "**/*.md" },
  65. },
  66. },
  67. });
  68. expect(store).toBeDefined();
  69. expect(store.dbPath).toBeTruthy();
  70. expect(store.internal).toBeDefined();
  71. await store.close();
  72. });
  73. test("creates store with YAML config file", async () => {
  74. const configPath = join(testDir, "test-config.yml");
  75. const config: CollectionConfig = {
  76. collections: {
  77. docs: { path: docsDir, pattern: "**/*.md" },
  78. },
  79. };
  80. writeFileSync(configPath, YAML.stringify(config));
  81. const store = await createStore({
  82. dbPath: freshDbPath(),
  83. configPath,
  84. });
  85. expect(store).toBeDefined();
  86. await store.close();
  87. });
  88. test("throws if dbPath is missing", async () => {
  89. await expect(
  90. createStore({ dbPath: "", config: { collections: {} } })
  91. ).rejects.toThrow("dbPath is required");
  92. });
  93. test("opens with just dbPath (DB-only mode)", async () => {
  94. const store = await createStore({ dbPath: freshDbPath() } as StoreOptions);
  95. expect(store).toBeDefined();
  96. // No collections yet — fresh DB
  97. const collections = await store.listCollections();
  98. expect(collections).toEqual([]);
  99. await store.close();
  100. });
  101. test("throws if both configPath and config are provided", async () => {
  102. await expect(
  103. createStore({
  104. dbPath: freshDbPath(),
  105. configPath: "/some/path.yml",
  106. config: { collections: {} },
  107. })
  108. ).rejects.toThrow("Provide either configPath or config, not both");
  109. });
  110. test("creates database file on disk", async () => {
  111. const dbPath = freshDbPath();
  112. const store = await createStore({
  113. dbPath,
  114. config: { collections: {} },
  115. });
  116. expect(existsSync(dbPath)).toBe(true);
  117. await store.close();
  118. });
  119. test("store.dbPath matches the provided path", async () => {
  120. const dbPath = freshDbPath();
  121. const store = await createStore({
  122. dbPath,
  123. config: { collections: {} },
  124. });
  125. expect(store.dbPath).toBe(dbPath);
  126. await store.close();
  127. });
  128. });
  129. // =============================================================================
  130. // Collection Management Tests
  131. // =============================================================================
  132. describe("collection management", () => {
  133. let store: QMDStore;
  134. beforeEach(async () => {
  135. store = await createStore({
  136. dbPath: freshDbPath(),
  137. config: { collections: {} },
  138. });
  139. });
  140. afterEach(async () => {
  141. await store.close();
  142. });
  143. test("addCollection adds a collection to inline config", async () => {
  144. await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  145. const collections = await store.listCollections();
  146. const names = collections.map(c => c.name);
  147. expect(names).toContain("docs");
  148. });
  149. test("addCollection with default pattern", async () => {
  150. await store.addCollection("notes", { path: notesDir });
  151. const collections = await store.listCollections();
  152. expect(collections.find(c => c.name === "notes")).toBeDefined();
  153. });
  154. test("removeCollection removes existing collection", async () => {
  155. await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  156. const removed = await store.removeCollection("docs");
  157. expect(removed).toBe(true);
  158. const collections = await store.listCollections();
  159. expect(collections.map(c => c.name)).not.toContain("docs");
  160. });
  161. test("removeCollection returns false for non-existent collection", async () => {
  162. const removed = await store.removeCollection("nonexistent");
  163. expect(removed).toBe(false);
  164. });
  165. test("renameCollection renames a collection", async () => {
  166. await store.addCollection("old-name", { path: docsDir, pattern: "**/*.md" });
  167. const renamed = await store.renameCollection("old-name", "new-name");
  168. expect(renamed).toBe(true);
  169. const names = (await store.listCollections()).map(c => c.name);
  170. expect(names).toContain("new-name");
  171. expect(names).not.toContain("old-name");
  172. });
  173. test("renameCollection returns false for non-existent source", async () => {
  174. const renamed = await store.renameCollection("nonexistent", "new-name");
  175. expect(renamed).toBe(false);
  176. });
  177. test("renameCollection throws if target exists", async () => {
  178. await store.addCollection("a", { path: docsDir, pattern: "**/*.md" });
  179. await store.addCollection("b", { path: notesDir, pattern: "**/*.md" });
  180. await expect(store.renameCollection("a", "b")).rejects.toThrow("already exists");
  181. });
  182. test("listCollections returns empty array for empty config", async () => {
  183. const collections = await store.listCollections();
  184. expect(collections).toEqual([]);
  185. });
  186. test("multiple collections can be added", async () => {
  187. await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  188. await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  189. const names = (await store.listCollections()).map(c => c.name);
  190. expect(names).toContain("docs");
  191. expect(names).toContain("notes");
  192. expect(names).toHaveLength(2);
  193. });
  194. });
  195. // =============================================================================
  196. // Context Management Tests
  197. // =============================================================================
  198. describe("context management", () => {
  199. let store: QMDStore;
  200. beforeEach(async () => {
  201. store = await createStore({
  202. dbPath: freshDbPath(),
  203. config: {
  204. collections: {
  205. docs: { path: docsDir, pattern: "**/*.md" },
  206. notes: { path: notesDir, pattern: "**/*.md" },
  207. },
  208. },
  209. });
  210. });
  211. afterEach(async () => {
  212. await store.close();
  213. });
  214. test("addContext adds context to a collection path", async () => {
  215. const added = await store.addContext("docs", "/auth", "Authentication docs");
  216. expect(added).toBe(true);
  217. const contexts = await store.listContexts();
  218. expect(contexts).toContainEqual({
  219. collection: "docs",
  220. path: "/auth",
  221. context: "Authentication docs",
  222. });
  223. });
  224. test("addContext returns false for non-existent collection", async () => {
  225. const added = await store.addContext("nonexistent", "/path", "Some context");
  226. expect(added).toBe(false);
  227. });
  228. test("removeContext removes existing context", async () => {
  229. await store.addContext("docs", "/auth", "Authentication docs");
  230. const removed = await store.removeContext("docs", "/auth");
  231. expect(removed).toBe(true);
  232. const contexts = await store.listContexts();
  233. expect(contexts.find(c => c.path === "/auth")).toBeUndefined();
  234. });
  235. test("removeContext returns false for non-existent context", async () => {
  236. const removed = await store.removeContext("docs", "/nonexistent");
  237. expect(removed).toBe(false);
  238. });
  239. test("setGlobalContext sets and retrieves global context", async () => {
  240. await store.setGlobalContext("Global knowledge base");
  241. const global = await store.getGlobalContext();
  242. expect(global).toBe("Global knowledge base");
  243. });
  244. test("setGlobalContext with undefined clears it", async () => {
  245. await store.setGlobalContext("Some context");
  246. await store.setGlobalContext(undefined);
  247. const global = await store.getGlobalContext();
  248. expect(global).toBeUndefined();
  249. });
  250. test("listContexts includes global context", async () => {
  251. await store.setGlobalContext("Global context");
  252. const contexts = await store.listContexts();
  253. expect(contexts).toContainEqual({
  254. collection: "*",
  255. path: "/",
  256. context: "Global context",
  257. });
  258. });
  259. test("listContexts returns contexts across multiple collections", async () => {
  260. await store.addContext("docs", "/", "Documentation");
  261. await store.addContext("notes", "/", "Personal notes");
  262. const contexts = await store.listContexts();
  263. expect(contexts.filter(c => c.path === "/")).toHaveLength(2);
  264. });
  265. test("multiple contexts on same collection", async () => {
  266. await store.addContext("docs", "/auth", "Auth docs");
  267. await store.addContext("docs", "/api", "API docs");
  268. const contexts = (await store.listContexts()).filter(c => c.collection === "docs");
  269. expect(contexts).toHaveLength(2);
  270. expect(contexts.map(c => c.path).sort()).toEqual(["/api", "/auth"]);
  271. });
  272. test("addContext overwrites existing context for same path", async () => {
  273. await store.addContext("docs", "/auth", "Old context");
  274. await store.addContext("docs", "/auth", "New context");
  275. const contexts = (await store.listContexts()).filter(c => c.path === "/auth");
  276. expect(contexts).toHaveLength(1);
  277. expect(contexts[0]!.context).toBe("New context");
  278. });
  279. });
  280. // =============================================================================
  281. // Inline Config Isolation Tests
  282. // =============================================================================
  283. describe("inline config isolation", () => {
  284. test("inline config does not write any files to disk", async () => {
  285. const configDir = join(testDir, "should-not-exist");
  286. const store = await createStore({
  287. dbPath: freshDbPath(),
  288. config: {
  289. collections: {
  290. docs: { path: docsDir, pattern: "**/*.md" },
  291. },
  292. },
  293. });
  294. await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  295. await store.addContext("docs", "/", "Documentation");
  296. expect(existsSync(configDir)).toBe(false);
  297. await store.close();
  298. });
  299. test("inline config mutations persist within session", async () => {
  300. const store = await createStore({
  301. dbPath: freshDbPath(),
  302. config: { collections: {} },
  303. });
  304. await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  305. await store.addContext("docs", "/", "My docs");
  306. // Verify the mutations are visible
  307. const collections = await store.listCollections();
  308. expect(collections.map(c => c.name)).toContain("docs");
  309. const contexts = await store.listContexts();
  310. expect(contexts).toContainEqual({
  311. collection: "docs",
  312. path: "/",
  313. context: "My docs",
  314. });
  315. await store.close();
  316. });
  317. test("two stores with different inline configs are independent", async () => {
  318. const store1 = await createStore({
  319. dbPath: freshDbPath(),
  320. config: {
  321. collections: {
  322. docs: { path: docsDir, pattern: "**/*.md" },
  323. },
  324. },
  325. });
  326. // Close first store (resets config source)
  327. await store1.close();
  328. const store2 = await createStore({
  329. dbPath: freshDbPath(),
  330. config: {
  331. collections: {
  332. notes: { path: notesDir, pattern: "**/*.md" },
  333. },
  334. },
  335. });
  336. const names = (await store2.listCollections()).map(c => c.name);
  337. expect(names).toContain("notes");
  338. expect(names).not.toContain("docs");
  339. await store2.close();
  340. });
  341. });
  342. // =============================================================================
  343. // YAML Config File Tests
  344. // =============================================================================
  345. describe("YAML config file mode", () => {
  346. test("loads collections from YAML file", async () => {
  347. const configPath = join(testDir, `config-${Date.now()}.yml`);
  348. const config: CollectionConfig = {
  349. collections: {
  350. docs: { path: docsDir, pattern: "**/*.md" },
  351. notes: { path: notesDir, pattern: "**/*.md" },
  352. },
  353. };
  354. writeFileSync(configPath, YAML.stringify(config));
  355. const store = await createStore({ dbPath: freshDbPath(), configPath });
  356. const names = (await store.listCollections()).map(c => c.name);
  357. expect(names).toContain("docs");
  358. expect(names).toContain("notes");
  359. await store.close();
  360. });
  361. test("addCollection persists to YAML file", async () => {
  362. const configPath = join(testDir, `config-persist-${Date.now()}.yml`);
  363. writeFileSync(configPath, YAML.stringify({ collections: {} }));
  364. const store = await createStore({ dbPath: freshDbPath(), configPath });
  365. await store.addCollection("newcol", { path: docsDir, pattern: "**/*.md" });
  366. await store.close();
  367. // Read the YAML file directly and verify
  368. const raw = readFileSync(configPath, "utf-8");
  369. const parsed = YAML.parse(raw) as CollectionConfig;
  370. expect(parsed.collections).toHaveProperty("newcol");
  371. expect(parsed.collections.newcol!.path).toBe(docsDir);
  372. });
  373. test("context persists to YAML file", async () => {
  374. const configPath = join(testDir, `config-ctx-${Date.now()}.yml`);
  375. writeFileSync(configPath, YAML.stringify({
  376. collections: { docs: { path: docsDir, pattern: "**/*.md" } },
  377. }));
  378. const store = await createStore({ dbPath: freshDbPath(), configPath });
  379. await store.addContext("docs", "/api", "API documentation");
  380. await store.close();
  381. const raw = readFileSync(configPath, "utf-8");
  382. const parsed = YAML.parse(raw) as CollectionConfig;
  383. expect(parsed.collections.docs!.context).toEqual({ "/api": "API documentation" });
  384. });
  385. test("non-existent config file returns empty collections", async () => {
  386. const configPath = join(testDir, "nonexistent-config.yml");
  387. const store = await createStore({ dbPath: freshDbPath(), configPath });
  388. const collections = await store.listCollections();
  389. expect(collections).toEqual([]);
  390. await store.close();
  391. });
  392. });
  393. // =============================================================================
  394. // Search Tests (BM25 - no LLM needed)
  395. // =============================================================================
  396. describe("searchLex (BM25)", () => {
  397. let store: QMDStore;
  398. let dbPath: string;
  399. beforeAll(async () => {
  400. dbPath = join(testDir, "search-test.sqlite");
  401. store = await createStore({
  402. dbPath,
  403. config: {
  404. collections: {
  405. docs: { path: docsDir, pattern: "**/*.md" },
  406. notes: { path: notesDir, pattern: "**/*.md" },
  407. },
  408. },
  409. });
  410. // Index documents manually using internal store
  411. const now = new Date().toISOString();
  412. const { internal } = store;
  413. const fs = require("fs");
  414. // Index docs collection
  415. for (const file of ["readme.md", "auth.md", "api.md"]) {
  416. const fullPath = join(docsDir, file);
  417. const content = fs.readFileSync(fullPath, "utf-8");
  418. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  419. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  420. internal.insertContent(hash, content, now);
  421. internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
  422. }
  423. // Index notes collection
  424. for (const file of ["meeting-2025-01.md", "meeting-2025-02.md", "ideas.md"]) {
  425. const fullPath = join(notesDir, file);
  426. const content = fs.readFileSync(fullPath, "utf-8");
  427. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  428. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  429. internal.insertContent(hash, content, now);
  430. internal.insertDocument("notes", `qmd://notes/${file}`, title, hash, now, now);
  431. }
  432. });
  433. afterAll(async () => {
  434. await store.close();
  435. });
  436. test("searchLex returns results for matching query", async () => {
  437. const results = await store.searchLex("authentication");
  438. expect(results.length).toBeGreaterThan(0);
  439. });
  440. test("searchLex results have expected shape", async () => {
  441. const results = await store.searchLex("authentication");
  442. expect(results.length).toBeGreaterThan(0);
  443. const result = results[0]!;
  444. expect(result).toHaveProperty("filepath");
  445. expect(result).toHaveProperty("score");
  446. expect(result).toHaveProperty("title");
  447. expect(result).toHaveProperty("docid");
  448. expect(result).toHaveProperty("collectionName");
  449. expect(typeof result.score).toBe("number");
  450. expect(result.score).toBeGreaterThan(0);
  451. });
  452. test("searchLex respects limit option", async () => {
  453. const results = await store.searchLex("meeting", { limit: 1 });
  454. expect(results.length).toBeLessThanOrEqual(1);
  455. });
  456. test("searchLex with collection filter", async () => {
  457. const results = await store.searchLex("authentication", { collection: "notes" });
  458. for (const r of results) {
  459. expect(r.collectionName).toBe("notes");
  460. }
  461. });
  462. test("searchLex returns empty for non-matching query", async () => {
  463. const results = await store.searchLex("xyznonexistentterm123");
  464. expect(results).toHaveLength(0);
  465. });
  466. test("searchLex finds documents across collections", async () => {
  467. const results = await store.searchLex("authentication", { limit: 10 });
  468. const collections = new Set(results.map(r => r.collectionName));
  469. // Auth appears in both docs/auth.md and notes/meeting-2025-02.md
  470. expect(collections.size).toBeGreaterThanOrEqual(1);
  471. });
  472. });
  473. // =============================================================================
  474. // Unified search() API Tests
  475. // =============================================================================
  476. describe("search (unified API)", () => {
  477. let store: QMDStore;
  478. beforeAll(async () => {
  479. store = await createStore({
  480. dbPath: join(testDir, "unified-search-test.sqlite"),
  481. config: {
  482. collections: {
  483. docs: { path: docsDir, pattern: "**/*.md" },
  484. notes: { path: notesDir, pattern: "**/*.md" },
  485. },
  486. },
  487. });
  488. await store.update();
  489. });
  490. afterAll(async () => {
  491. await store.close();
  492. });
  493. test("search() requires query or queries", async () => {
  494. await expect(store.search({} as SearchOptions)).rejects.toThrow("requires either 'query' or 'queries'");
  495. });
  496. test("search() with pre-expanded queries and rerank:false", async () => {
  497. const results = await store.search({
  498. queries: [
  499. { type: "lex", query: "authentication JWT" },
  500. { type: "lex", query: "login session" },
  501. ],
  502. rerank: false,
  503. });
  504. expect(results.length).toBeGreaterThan(0);
  505. });
  506. // Tests below use search({ query: ... }) which triggers LLM query expansion
  507. describe.skipIf(!!process.env.CI)("with LLM query expansion", () => {
  508. test("search() with query and rerank:false returns results", async () => {
  509. const results = await store.search({ query: "authentication", rerank: false });
  510. expect(results.length).toBeGreaterThan(0);
  511. expect(results[0]).toHaveProperty("file");
  512. expect(results[0]).toHaveProperty("score");
  513. expect(results[0]).toHaveProperty("title");
  514. expect(results[0]).toHaveProperty("bestChunk");
  515. expect(results[0]).toHaveProperty("docid");
  516. });
  517. test("search() with intent and rerank:false returns results", async () => {
  518. const results = await store.search({
  519. query: "meeting",
  520. intent: "quarterly planning and roadmap",
  521. rerank: false,
  522. });
  523. expect(results.length).toBeGreaterThan(0);
  524. });
  525. test("search() with collection filter", async () => {
  526. const results = await store.search({
  527. query: "authentication",
  528. collection: "docs",
  529. rerank: false,
  530. });
  531. for (const r of results) {
  532. expect(r.file).toMatch(/^qmd:\/\/docs\//);
  533. }
  534. });
  535. test("search() with collections filter", async () => {
  536. const results = await store.search({
  537. query: "authentication",
  538. collections: ["docs"],
  539. rerank: false,
  540. });
  541. for (const r of results) {
  542. expect(r.file).toMatch(/^qmd:\/\/docs\//);
  543. }
  544. });
  545. test("search() with limit", async () => {
  546. const results = await store.search({ query: "meeting", limit: 1, rerank: false });
  547. expect(results.length).toBeLessThanOrEqual(1);
  548. });
  549. test("search() returns empty for non-matching query", async () => {
  550. const results = await store.search({ query: "xyznonexistentterm123", rerank: false });
  551. expect(results).toHaveLength(0);
  552. });
  553. });
  554. });
  555. // =============================================================================
  556. // Document Retrieval Tests
  557. // =============================================================================
  558. describe("get and multiGet", () => {
  559. let store: QMDStore;
  560. beforeAll(async () => {
  561. store = await createStore({
  562. dbPath: join(testDir, "get-test.sqlite"),
  563. config: {
  564. collections: {
  565. docs: { path: docsDir, pattern: "**/*.md" },
  566. },
  567. },
  568. });
  569. // Index documents
  570. const now = new Date().toISOString();
  571. const { internal } = store;
  572. const fs = require("fs");
  573. for (const file of ["readme.md", "auth.md", "api.md"]) {
  574. const fullPath = join(docsDir, file);
  575. const content = fs.readFileSync(fullPath, "utf-8");
  576. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  577. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  578. internal.insertContent(hash, content, now);
  579. internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
  580. }
  581. });
  582. afterAll(async () => {
  583. await store.close();
  584. });
  585. test("get retrieves a document by path", async () => {
  586. const result = await store.get("qmd://docs/auth.md");
  587. expect("error" in result).toBe(false);
  588. if (!("error" in result)) {
  589. expect(result.title).toBe("Authentication");
  590. expect(result.collectionName).toBe("docs");
  591. }
  592. });
  593. test("get with includeBody returns body content", async () => {
  594. const result = await store.get("qmd://docs/auth.md", { includeBody: true });
  595. if (!("error" in result)) {
  596. expect(result.body).toBeDefined();
  597. expect(result.body).toContain("JWT tokens");
  598. }
  599. });
  600. test("get returns not_found for missing document", async () => {
  601. const result = await store.get("qmd://docs/nonexistent.md");
  602. expect("error" in result).toBe(true);
  603. if ("error" in result) {
  604. expect(result.error).toBe("not_found");
  605. }
  606. });
  607. test("get by docid", async () => {
  608. // First get a document to find its docid
  609. const doc = await store.get("qmd://docs/readme.md");
  610. if (!("error" in doc)) {
  611. const byDocid = await store.get(`#${doc.docid}`);
  612. expect("error" in byDocid).toBe(false);
  613. if (!("error" in byDocid)) {
  614. expect(byDocid.docid).toBe(doc.docid);
  615. }
  616. }
  617. });
  618. test("multiGet retrieves multiple documents", async () => {
  619. const { docs, errors } = await store.multiGet("qmd://docs/*.md");
  620. expect(docs.length).toBeGreaterThan(0);
  621. });
  622. });
  623. // =============================================================================
  624. // Index Health Tests
  625. // =============================================================================
  626. describe("index health", () => {
  627. let store: QMDStore;
  628. beforeEach(async () => {
  629. store = await createStore({
  630. dbPath: freshDbPath(),
  631. config: {
  632. collections: {
  633. docs: { path: docsDir, pattern: "**/*.md" },
  634. },
  635. },
  636. });
  637. });
  638. afterEach(async () => {
  639. await store.close();
  640. });
  641. test("getStatus returns valid structure", async () => {
  642. const status = await store.getStatus();
  643. expect(status).toHaveProperty("totalDocuments");
  644. expect(status).toHaveProperty("needsEmbedding");
  645. expect(status).toHaveProperty("hasVectorIndex");
  646. expect(status).toHaveProperty("collections");
  647. expect(typeof status.totalDocuments).toBe("number");
  648. });
  649. test("getIndexHealth returns valid structure", async () => {
  650. const health = await store.getIndexHealth();
  651. expect(health).toHaveProperty("needsEmbedding");
  652. expect(health).toHaveProperty("totalDocs");
  653. expect(typeof health.needsEmbedding).toBe("number");
  654. expect(typeof health.totalDocs).toBe("number");
  655. });
  656. test("fresh store has zero documents", async () => {
  657. const status = await store.getStatus();
  658. expect(status.totalDocuments).toBe(0);
  659. });
  660. });
  661. // =============================================================================
  662. // Update Tests
  663. // =============================================================================
  664. describe("update", () => {
  665. test("indexes files and returns correct stats", async () => {
  666. const store = await createStore({
  667. dbPath: freshDbPath(),
  668. config: {
  669. collections: {
  670. docs: { path: docsDir, pattern: "**/*.md" },
  671. },
  672. },
  673. });
  674. const result = await store.update();
  675. expect(result.collections).toBe(1);
  676. expect(result.indexed).toBe(3); // readme.md, auth.md, api.md
  677. expect(result.updated).toBe(0);
  678. expect(result.unchanged).toBe(0);
  679. expect(result.removed).toBe(0);
  680. expect(typeof result.needsEmbedding).toBe("number");
  681. await store.close();
  682. });
  683. test("second update shows unchanged files", async () => {
  684. const store = await createStore({
  685. dbPath: freshDbPath(),
  686. config: {
  687. collections: {
  688. docs: { path: docsDir, pattern: "**/*.md" },
  689. },
  690. },
  691. });
  692. await store.update();
  693. const result = await store.update();
  694. expect(result.indexed).toBe(0);
  695. expect(result.unchanged).toBe(3);
  696. await store.close();
  697. });
  698. test("update with onProgress callback fires", async () => {
  699. const store = await createStore({
  700. dbPath: freshDbPath(),
  701. config: {
  702. collections: {
  703. docs: { path: docsDir, pattern: "**/*.md" },
  704. },
  705. },
  706. });
  707. const progress: UpdateProgress[] = [];
  708. await store.update({
  709. onProgress: (info) => progress.push(info),
  710. });
  711. expect(progress.length).toBeGreaterThan(0);
  712. expect(progress[0]!.collection).toBe("docs");
  713. expect(progress[0]!.current).toBeGreaterThanOrEqual(1);
  714. expect(progress[0]!.total).toBe(3);
  715. await store.close();
  716. });
  717. test("update with collection filter", async () => {
  718. const store = await createStore({
  719. dbPath: freshDbPath(),
  720. config: {
  721. collections: {
  722. docs: { path: docsDir, pattern: "**/*.md" },
  723. notes: { path: notesDir, pattern: "**/*.md" },
  724. },
  725. },
  726. });
  727. const result = await store.update({ collections: ["docs"] });
  728. expect(result.collections).toBe(1);
  729. expect(result.indexed).toBe(3); // Only docs
  730. await store.close();
  731. });
  732. test("update multiple collections", async () => {
  733. const store = await createStore({
  734. dbPath: freshDbPath(),
  735. config: {
  736. collections: {
  737. docs: { path: docsDir, pattern: "**/*.md" },
  738. notes: { path: notesDir, pattern: "**/*.md" },
  739. },
  740. },
  741. });
  742. const result = await store.update();
  743. expect(result.collections).toBe(2);
  744. expect(result.indexed).toBe(6); // 3 docs + 3 notes
  745. await store.close();
  746. });
  747. test("documents are searchable after update", async () => {
  748. const store = await createStore({
  749. dbPath: freshDbPath(),
  750. config: {
  751. collections: {
  752. docs: { path: docsDir, pattern: "**/*.md" },
  753. },
  754. },
  755. });
  756. await store.update();
  757. const results = await store.searchLex("authentication");
  758. expect(results.length).toBeGreaterThan(0);
  759. await store.close();
  760. });
  761. });
  762. describe("embed", () => {
  763. function createFakeTokenizer() {
  764. return {
  765. async tokenize(text: string) {
  766. return new Array(Math.max(1, Math.ceil(text.length / 16))).fill(1);
  767. },
  768. };
  769. }
  770. function createFakeEmbedLlm() {
  771. const embedBatchCalls: string[][] = [];
  772. return {
  773. embedBatchCalls,
  774. async embed(_text: string) {
  775. return { embedding: [0.1, 0.2, 0.3], model: "fake-embed" };
  776. },
  777. async embedBatch(texts: string[]) {
  778. embedBatchCalls.push([...texts]);
  779. return texts.map((_text, index) => ({
  780. embedding: [index + 1, index + 2, index + 3],
  781. model: "fake-embed",
  782. }));
  783. },
  784. };
  785. }
  786. test("store.embed forwards batch limit options", async () => {
  787. const store = await createStore({
  788. dbPath: freshDbPath(),
  789. config: {
  790. collections: {
  791. docs: { path: docsDir, pattern: "**/*.md" },
  792. },
  793. },
  794. });
  795. const fakeLlm = createFakeEmbedLlm();
  796. setDefaultLlamaCpp(createFakeTokenizer() as any);
  797. store.internal.llm = fakeLlm as any;
  798. try {
  799. await store.update();
  800. const result = await store.embed({
  801. maxDocsPerBatch: 1,
  802. maxBatchBytes: 1024 * 1024,
  803. });
  804. expect(fakeLlm.embedBatchCalls).toHaveLength(3);
  805. expect(fakeLlm.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
  806. expect(result.docsProcessed).toBe(3);
  807. expect(result.chunksEmbedded).toBe(3);
  808. } finally {
  809. setDefaultLlamaCpp(null);
  810. await store.close();
  811. }
  812. });
  813. test("store.embed rejects invalid batch limits", async () => {
  814. const store = await createStore({
  815. dbPath: freshDbPath(),
  816. config: { collections: {} },
  817. });
  818. try {
  819. await expect(store.embed({ maxDocsPerBatch: 0 })).rejects.toThrow("maxDocsPerBatch");
  820. await expect(store.embed({ maxBatchBytes: 0 })).rejects.toThrow("maxBatchBytes");
  821. } finally {
  822. setDefaultLlamaCpp(null);
  823. await store.close();
  824. }
  825. });
  826. });
  827. // =============================================================================
  828. // Lifecycle Tests
  829. // =============================================================================
  830. describe("lifecycle", () => {
  831. test("close() is async and does not throw", async () => {
  832. const store = await createStore({
  833. dbPath: freshDbPath(),
  834. config: { collections: {} },
  835. });
  836. // close() should return a promise
  837. const result = store.close();
  838. expect(result).toBeInstanceOf(Promise);
  839. await result;
  840. });
  841. test("close() makes subsequent operations throw", async () => {
  842. const store = await createStore({
  843. dbPath: freshDbPath(),
  844. config: { collections: {} },
  845. });
  846. await store.close();
  847. // Database operations should fail after close
  848. await expect(store.getStatus()).rejects.toThrow();
  849. });
  850. test("multiple stores can coexist with different databases", async () => {
  851. const store1 = await createStore({
  852. dbPath: freshDbPath(),
  853. config: {
  854. collections: {
  855. docs: { path: docsDir, pattern: "**/*.md" },
  856. },
  857. },
  858. });
  859. // Note: since config source is module-level, we close store1 first
  860. await store1.close();
  861. const store2 = await createStore({
  862. dbPath: freshDbPath(),
  863. config: {
  864. collections: {
  865. notes: { path: notesDir, pattern: "**/*.md" },
  866. },
  867. },
  868. });
  869. const names = (await store2.listCollections()).map(c => c.name);
  870. expect(names).toContain("notes");
  871. expect(names).not.toContain("docs");
  872. await store2.close();
  873. });
  874. });
  875. // =============================================================================
  876. // Config Initialization Tests
  877. // =============================================================================
  878. describe("config initialization", () => {
  879. test("inline config with global_context is preserved", async () => {
  880. const store = await createStore({
  881. dbPath: freshDbPath(),
  882. config: {
  883. global_context: "System knowledge base",
  884. collections: {
  885. docs: { path: docsDir, pattern: "**/*.md" },
  886. },
  887. },
  888. });
  889. const global = await store.getGlobalContext();
  890. expect(global).toBe("System knowledge base");
  891. await store.close();
  892. });
  893. test("inline config with pre-existing contexts is preserved", async () => {
  894. const store = await createStore({
  895. dbPath: freshDbPath(),
  896. config: {
  897. collections: {
  898. docs: {
  899. path: docsDir,
  900. pattern: "**/*.md",
  901. context: { "/auth": "Authentication docs" },
  902. },
  903. },
  904. },
  905. });
  906. const contexts = await store.listContexts();
  907. expect(contexts).toContainEqual({
  908. collection: "docs",
  909. path: "/auth",
  910. context: "Authentication docs",
  911. });
  912. await store.close();
  913. });
  914. test("inline config with empty collections object works", async () => {
  915. const store = await createStore({
  916. dbPath: freshDbPath(),
  917. config: { collections: {} },
  918. });
  919. expect(await store.listCollections()).toEqual([]);
  920. expect(await store.listContexts()).toEqual([]);
  921. await store.close();
  922. });
  923. test("inline config with multiple collection options", async () => {
  924. const store = await createStore({
  925. dbPath: freshDbPath(),
  926. config: {
  927. collections: {
  928. docs: {
  929. path: docsDir,
  930. pattern: "**/*.md",
  931. ignore: ["drafts/**"],
  932. includeByDefault: true,
  933. },
  934. notes: {
  935. path: notesDir,
  936. pattern: "**/*.md",
  937. includeByDefault: false,
  938. },
  939. },
  940. },
  941. });
  942. const collections = await store.listCollections();
  943. expect(collections).toHaveLength(2);
  944. await store.close();
  945. });
  946. });
  947. // =============================================================================
  948. // Type Export Tests (compile-time checks, runtime verification)
  949. // =============================================================================
  950. describe("type exports", () => {
  951. test("StoreOptions type is usable", () => {
  952. const opts: StoreOptions = {
  953. dbPath: "/tmp/test.sqlite",
  954. config: { collections: {} },
  955. };
  956. expect(opts.dbPath).toBe("/tmp/test.sqlite");
  957. });
  958. test("CollectionConfig type is usable", () => {
  959. const config: CollectionConfig = {
  960. global_context: "test",
  961. collections: {
  962. test: { path: "/tmp", pattern: "**/*.md" },
  963. },
  964. };
  965. expect(config.collections).toHaveProperty("test");
  966. });
  967. test("QMDStore type exposes expected methods", async () => {
  968. const store = await createStore({
  969. dbPath: freshDbPath(),
  970. config: { collections: {} },
  971. });
  972. // Verify all methods exist
  973. expect(typeof store.search).toBe("function");
  974. expect(typeof store.searchLex).toBe("function");
  975. expect(typeof store.searchVector).toBe("function");
  976. expect(typeof store.expandQuery).toBe("function");
  977. expect(typeof store.get).toBe("function");
  978. expect(typeof store.multiGet).toBe("function");
  979. expect(typeof store.addCollection).toBe("function");
  980. expect(typeof store.removeCollection).toBe("function");
  981. expect(typeof store.renameCollection).toBe("function");
  982. expect(typeof store.listCollections).toBe("function");
  983. expect(typeof store.addContext).toBe("function");
  984. expect(typeof store.removeContext).toBe("function");
  985. expect(typeof store.setGlobalContext).toBe("function");
  986. expect(typeof store.getGlobalContext).toBe("function");
  987. expect(typeof store.listContexts).toBe("function");
  988. expect(typeof store.getStatus).toBe("function");
  989. expect(typeof store.getIndexHealth).toBe("function");
  990. expect(typeof store.update).toBe("function");
  991. expect(typeof store.embed).toBe("function");
  992. expect(typeof store.close).toBe("function");
  993. await store.close();
  994. });
  995. });
  996. // =============================================================================
  997. // DB-Only Mode Tests (self-contained store)
  998. // =============================================================================
  999. describe("DB-only mode", () => {
  1000. test("reopen store with just dbPath after config+update session", async () => {
  1001. const dbPath = freshDbPath();
  1002. // Session 1: create store with config, update, close
  1003. const store1 = await createStore({
  1004. dbPath,
  1005. config: {
  1006. collections: {
  1007. docs: { path: docsDir, pattern: "**/*.md" },
  1008. notes: { path: notesDir, pattern: "**/*.md" },
  1009. },
  1010. global_context: "Test knowledge base",
  1011. },
  1012. });
  1013. await store1.update();
  1014. // Verify documents indexed
  1015. const status1 = await store1.getStatus();
  1016. expect(status1.totalDocuments).toBe(6);
  1017. await store1.close();
  1018. // Session 2: reopen with just dbPath — no config
  1019. const store2 = await createStore({ dbPath } as StoreOptions);
  1020. // Collections should still be available
  1021. const collections = await store2.listCollections();
  1022. expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);
  1023. // Search should still work
  1024. const results = await store2.searchLex("authentication");
  1025. expect(results.length).toBeGreaterThan(0);
  1026. // Global context should still be available
  1027. const globalCtx = await store2.getGlobalContext();
  1028. expect(globalCtx).toBe("Test knowledge base");
  1029. // Contexts from collections should persist
  1030. const status2 = await store2.getStatus();
  1031. expect(status2.totalDocuments).toBe(6);
  1032. await store2.close();
  1033. });
  1034. test("config sync populates store_collections table", async () => {
  1035. const dbPath = freshDbPath();
  1036. const store = await createStore({
  1037. dbPath,
  1038. config: {
  1039. collections: {
  1040. docs: {
  1041. path: docsDir,
  1042. pattern: "**/*.md",
  1043. context: { "/auth": "Auth documentation" },
  1044. },
  1045. },
  1046. },
  1047. });
  1048. // Verify collections are in the DB via listCollections
  1049. const collections = await store.listCollections();
  1050. expect(collections).toHaveLength(1);
  1051. expect(collections[0]!.name).toBe("docs");
  1052. expect(collections[0]!.pwd).toBe(docsDir);
  1053. // Verify contexts are accessible
  1054. const contexts = await store.listContexts();
  1055. expect(contexts).toContainEqual({
  1056. collection: "docs",
  1057. path: "/auth",
  1058. context: "Auth documentation",
  1059. });
  1060. await store.close();
  1061. });
  1062. test("config hash skip: second init with same config skips sync", async () => {
  1063. const dbPath = freshDbPath();
  1064. const config = {
  1065. collections: {
  1066. docs: { path: docsDir, pattern: "**/*.md" },
  1067. },
  1068. };
  1069. // First init — syncs config
  1070. const store1 = await createStore({ dbPath, config });
  1071. await store1.close();
  1072. // Second init with same config — should skip sync (no-op, but should not error)
  1073. const store2 = await createStore({ dbPath, config });
  1074. const collections = await store2.listCollections();
  1075. expect(collections).toHaveLength(1);
  1076. expect(collections[0]!.name).toBe("docs");
  1077. await store2.close();
  1078. });
  1079. test("DB-only mode supports collection mutations", async () => {
  1080. const dbPath = freshDbPath();
  1081. // Session 1: create with config
  1082. const store1 = await createStore({
  1083. dbPath,
  1084. config: {
  1085. collections: {
  1086. docs: { path: docsDir, pattern: "**/*.md" },
  1087. },
  1088. },
  1089. });
  1090. await store1.close();
  1091. // Session 2: reopen DB-only, add a collection
  1092. const store2 = await createStore({ dbPath } as StoreOptions);
  1093. await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  1094. const names = (await store2.listCollections()).map(c => c.name).sort();
  1095. expect(names).toEqual(["docs", "notes"]);
  1096. await store2.close();
  1097. // Session 3: reopen DB-only again, verify both collections persist
  1098. const store3 = await createStore({ dbPath } as StoreOptions);
  1099. const names3 = (await store3.listCollections()).map(c => c.name).sort();
  1100. expect(names3).toEqual(["docs", "notes"]);
  1101. await store3.close();
  1102. });
  1103. test("DB-only mode supports context mutations", async () => {
  1104. const dbPath = freshDbPath();
  1105. // Session 1: create with config
  1106. const store1 = await createStore({
  1107. dbPath,
  1108. config: {
  1109. collections: {
  1110. docs: { path: docsDir, pattern: "**/*.md" },
  1111. },
  1112. },
  1113. });
  1114. await store1.addContext("docs", "/api", "API docs");
  1115. await store1.setGlobalContext("Global context");
  1116. await store1.close();
  1117. // Session 2: reopen DB-only
  1118. const store2 = await createStore({ dbPath } as StoreOptions);
  1119. const contexts = await store2.listContexts();
  1120. expect(contexts).toContainEqual({
  1121. collection: "docs",
  1122. path: "/api",
  1123. context: "API docs",
  1124. });
  1125. expect(contexts).toContainEqual({
  1126. collection: "*",
  1127. path: "/",
  1128. context: "Global context",
  1129. });
  1130. await store2.close();
  1131. });
  1132. });