sdk.test.ts 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
  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. type EmbeddingProvider,
  24. } from "../src/index.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.skip("requires a provisioned commercial generation adapter", () => {
  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 createFakeCommercialProvider(): EmbeddingProvider & { embedBatchCalls: string[][] } {
  764. const embedBatchCalls: string[][] = [];
  765. return {
  766. kind: "openai",
  767. embedBatchCalls,
  768. getModelId: () => "commercial-test-embedding-v1",
  769. getDimensions: () => 3,
  770. healthcheck: async () => ({
  771. ok: true,
  772. model: "commercial-test-embedding-v1",
  773. dimensions: 3,
  774. }),
  775. async embed(_text: string) {
  776. return { embedding: [0.1, 0.2, 0.3], model: "commercial-test-embedding-v1" };
  777. },
  778. async embedBatch(texts: string[]) {
  779. embedBatchCalls.push([...texts]);
  780. return texts.map((_text, index) => ({
  781. embedding: [index + 1, index + 2, index + 3],
  782. model: "commercial-test-embedding-v1",
  783. }));
  784. },
  785. async dispose() {},
  786. };
  787. }
  788. test("store.embed forwards batch limit options", async () => {
  789. const fakeProvider = createFakeCommercialProvider();
  790. const store = await createStore({
  791. dbPath: freshDbPath(),
  792. config: {
  793. collections: {
  794. docs: { path: docsDir, pattern: "**/*.md" },
  795. },
  796. },
  797. embedProvider: fakeProvider,
  798. });
  799. try {
  800. await store.update();
  801. const result = await store.embed({
  802. maxDocsPerBatch: 1,
  803. maxBatchBytes: 1024 * 1024,
  804. });
  805. expect(fakeProvider.embedBatchCalls).toHaveLength(3);
  806. expect(fakeProvider.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
  807. expect(result.docsProcessed).toBe(3);
  808. expect(result.chunksEmbedded).toBe(3);
  809. } finally {
  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. await store.close();
  823. }
  824. });
  825. });
  826. // =============================================================================
  827. // Lifecycle Tests
  828. // =============================================================================
  829. describe("lifecycle", () => {
  830. test("close() is async and does not throw", async () => {
  831. const store = await createStore({
  832. dbPath: freshDbPath(),
  833. config: { collections: {} },
  834. });
  835. // close() should return a promise
  836. const result = store.close();
  837. expect(result).toBeInstanceOf(Promise);
  838. await result;
  839. });
  840. test("close() makes subsequent operations throw", async () => {
  841. const store = await createStore({
  842. dbPath: freshDbPath(),
  843. config: { collections: {} },
  844. });
  845. await store.close();
  846. // Database operations should fail after close
  847. await expect(store.getStatus()).rejects.toThrow();
  848. });
  849. test("multiple stores can coexist with different databases", async () => {
  850. const store1 = await createStore({
  851. dbPath: freshDbPath(),
  852. config: {
  853. collections: {
  854. docs: { path: docsDir, pattern: "**/*.md" },
  855. },
  856. },
  857. });
  858. // Note: since config source is module-level, we close store1 first
  859. await store1.close();
  860. const store2 = await createStore({
  861. dbPath: freshDbPath(),
  862. config: {
  863. collections: {
  864. notes: { path: notesDir, pattern: "**/*.md" },
  865. },
  866. },
  867. });
  868. const names = (await store2.listCollections()).map(c => c.name);
  869. expect(names).toContain("notes");
  870. expect(names).not.toContain("docs");
  871. await store2.close();
  872. });
  873. });
  874. // =============================================================================
  875. // Config Initialization Tests
  876. // =============================================================================
  877. describe("config initialization", () => {
  878. test("inline config with global_context is preserved", async () => {
  879. const store = await createStore({
  880. dbPath: freshDbPath(),
  881. config: {
  882. global_context: "System knowledge base",
  883. collections: {
  884. docs: { path: docsDir, pattern: "**/*.md" },
  885. },
  886. },
  887. });
  888. const global = await store.getGlobalContext();
  889. expect(global).toBe("System knowledge base");
  890. await store.close();
  891. });
  892. test("inline config with pre-existing contexts is preserved", async () => {
  893. const store = await createStore({
  894. dbPath: freshDbPath(),
  895. config: {
  896. collections: {
  897. docs: {
  898. path: docsDir,
  899. pattern: "**/*.md",
  900. context: { "/auth": "Authentication docs" },
  901. },
  902. },
  903. },
  904. });
  905. const contexts = await store.listContexts();
  906. expect(contexts).toContainEqual({
  907. collection: "docs",
  908. path: "/auth",
  909. context: "Authentication docs",
  910. });
  911. await store.close();
  912. });
  913. test("inline config with empty collections object works", async () => {
  914. const store = await createStore({
  915. dbPath: freshDbPath(),
  916. config: { collections: {} },
  917. });
  918. expect(await store.listCollections()).toEqual([]);
  919. expect(await store.listContexts()).toEqual([]);
  920. await store.close();
  921. });
  922. test("inline config with multiple collection options", async () => {
  923. const store = await createStore({
  924. dbPath: freshDbPath(),
  925. config: {
  926. collections: {
  927. docs: {
  928. path: docsDir,
  929. pattern: "**/*.md",
  930. ignore: ["drafts/**"],
  931. includeByDefault: true,
  932. },
  933. notes: {
  934. path: notesDir,
  935. pattern: "**/*.md",
  936. includeByDefault: false,
  937. },
  938. },
  939. },
  940. });
  941. const collections = await store.listCollections();
  942. expect(collections).toHaveLength(2);
  943. await store.close();
  944. });
  945. });
  946. // =============================================================================
  947. // Type Export Tests (compile-time checks, runtime verification)
  948. // =============================================================================
  949. describe("type exports", () => {
  950. test("StoreOptions type is usable", () => {
  951. const opts: StoreOptions = {
  952. dbPath: "/tmp/test.sqlite",
  953. config: { collections: {} },
  954. };
  955. expect(opts.dbPath).toBe("/tmp/test.sqlite");
  956. });
  957. test("CollectionConfig type is usable", () => {
  958. const config: CollectionConfig = {
  959. global_context: "test",
  960. collections: {
  961. test: { path: "/tmp", pattern: "**/*.md" },
  962. },
  963. };
  964. expect(config.collections).toHaveProperty("test");
  965. });
  966. test("QMDStore type exposes expected methods", async () => {
  967. const store = await createStore({
  968. dbPath: freshDbPath(),
  969. config: { collections: {} },
  970. });
  971. // Verify all methods exist
  972. expect(typeof store.search).toBe("function");
  973. expect(typeof store.searchLex).toBe("function");
  974. expect(typeof store.searchVector).toBe("function");
  975. expect(typeof store.expandQuery).toBe("function");
  976. expect(typeof store.get).toBe("function");
  977. expect(typeof store.multiGet).toBe("function");
  978. expect(typeof store.addCollection).toBe("function");
  979. expect(typeof store.removeCollection).toBe("function");
  980. expect(typeof store.renameCollection).toBe("function");
  981. expect(typeof store.listCollections).toBe("function");
  982. expect(typeof store.addContext).toBe("function");
  983. expect(typeof store.removeContext).toBe("function");
  984. expect(typeof store.setGlobalContext).toBe("function");
  985. expect(typeof store.getGlobalContext).toBe("function");
  986. expect(typeof store.listContexts).toBe("function");
  987. expect(typeof store.getStatus).toBe("function");
  988. expect(typeof store.getIndexHealth).toBe("function");
  989. expect(typeof store.update).toBe("function");
  990. expect(typeof store.embed).toBe("function");
  991. expect(typeof store.close).toBe("function");
  992. await store.close();
  993. });
  994. });
  995. // =============================================================================
  996. // DB-Only Mode Tests (self-contained store)
  997. // =============================================================================
  998. describe("DB-only mode", () => {
  999. test("reopen store with just dbPath after config+update session", async () => {
  1000. const dbPath = freshDbPath();
  1001. // Session 1: create store with config, update, close
  1002. const store1 = await createStore({
  1003. dbPath,
  1004. config: {
  1005. collections: {
  1006. docs: { path: docsDir, pattern: "**/*.md" },
  1007. notes: { path: notesDir, pattern: "**/*.md" },
  1008. },
  1009. global_context: "Test knowledge base",
  1010. },
  1011. });
  1012. await store1.update();
  1013. // Verify documents indexed
  1014. const status1 = await store1.getStatus();
  1015. expect(status1.totalDocuments).toBe(6);
  1016. await store1.close();
  1017. // Session 2: reopen with just dbPath — no config
  1018. const store2 = await createStore({ dbPath } as StoreOptions);
  1019. // Collections should still be available
  1020. const collections = await store2.listCollections();
  1021. expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);
  1022. // Search should still work
  1023. const results = await store2.searchLex("authentication");
  1024. expect(results.length).toBeGreaterThan(0);
  1025. // Global context should still be available
  1026. const globalCtx = await store2.getGlobalContext();
  1027. expect(globalCtx).toBe("Test knowledge base");
  1028. // Contexts from collections should persist
  1029. const status2 = await store2.getStatus();
  1030. expect(status2.totalDocuments).toBe(6);
  1031. await store2.close();
  1032. });
  1033. test("config sync populates store_collections table", async () => {
  1034. const dbPath = freshDbPath();
  1035. const store = await createStore({
  1036. dbPath,
  1037. config: {
  1038. collections: {
  1039. docs: {
  1040. path: docsDir,
  1041. pattern: "**/*.md",
  1042. context: { "/auth": "Auth documentation" },
  1043. },
  1044. },
  1045. },
  1046. });
  1047. // Verify collections are in the DB via listCollections
  1048. const collections = await store.listCollections();
  1049. expect(collections).toHaveLength(1);
  1050. expect(collections[0]!.name).toBe("docs");
  1051. expect(collections[0]!.pwd).toBe(docsDir);
  1052. // Verify contexts are accessible
  1053. const contexts = await store.listContexts();
  1054. expect(contexts).toContainEqual({
  1055. collection: "docs",
  1056. path: "/auth",
  1057. context: "Auth documentation",
  1058. });
  1059. await store.close();
  1060. });
  1061. test("config hash skip: second init with same config skips sync", async () => {
  1062. const dbPath = freshDbPath();
  1063. const config = {
  1064. collections: {
  1065. docs: { path: docsDir, pattern: "**/*.md" },
  1066. },
  1067. };
  1068. // First init — syncs config
  1069. const store1 = await createStore({ dbPath, config });
  1070. await store1.close();
  1071. // Second init with same config — should skip sync (no-op, but should not error)
  1072. const store2 = await createStore({ dbPath, config });
  1073. const collections = await store2.listCollections();
  1074. expect(collections).toHaveLength(1);
  1075. expect(collections[0]!.name).toBe("docs");
  1076. await store2.close();
  1077. });
  1078. test("DB-only mode supports collection mutations", async () => {
  1079. const dbPath = freshDbPath();
  1080. // Session 1: create with config
  1081. const store1 = await createStore({
  1082. dbPath,
  1083. config: {
  1084. collections: {
  1085. docs: { path: docsDir, pattern: "**/*.md" },
  1086. },
  1087. },
  1088. });
  1089. await store1.close();
  1090. // Session 2: reopen DB-only, add a collection
  1091. const store2 = await createStore({ dbPath } as StoreOptions);
  1092. await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  1093. const names = (await store2.listCollections()).map(c => c.name).sort();
  1094. expect(names).toEqual(["docs", "notes"]);
  1095. await store2.close();
  1096. // Session 3: reopen DB-only again, verify both collections persist
  1097. const store3 = await createStore({ dbPath } as StoreOptions);
  1098. const names3 = (await store3.listCollections()).map(c => c.name).sort();
  1099. expect(names3).toEqual(["docs", "notes"]);
  1100. await store3.close();
  1101. });
  1102. test("DB-only mode supports context mutations", async () => {
  1103. const dbPath = freshDbPath();
  1104. // Session 1: create with config
  1105. const store1 = await createStore({
  1106. dbPath,
  1107. config: {
  1108. collections: {
  1109. docs: { path: docsDir, pattern: "**/*.md" },
  1110. },
  1111. },
  1112. });
  1113. await store1.addContext("docs", "/api", "API docs");
  1114. await store1.setGlobalContext("Global context");
  1115. await store1.close();
  1116. // Session 2: reopen DB-only
  1117. const store2 = await createStore({ dbPath } as StoreOptions);
  1118. const contexts = await store2.listContexts();
  1119. expect(contexts).toContainEqual({
  1120. collection: "docs",
  1121. path: "/api",
  1122. context: "API docs",
  1123. });
  1124. expect(contexts).toContainEqual({
  1125. collection: "*",
  1126. path: "/",
  1127. context: "Global context",
  1128. });
  1129. await store2.close();
  1130. });
  1131. });