sdk.test.ts 40 KB

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