sdk.test.ts 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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 pre-expanded queries and rerank:false", async () => {
  496. const results = await store.search({
  497. queries: [
  498. { type: "lex", query: "authentication JWT" },
  499. { type: "lex", query: "login session" },
  500. ],
  501. rerank: false,
  502. });
  503. expect(results.length).toBeGreaterThan(0);
  504. });
  505. // Tests below use search({ query: ... }) which triggers LLM query expansion
  506. describe.skipIf(!!process.env.CI)("with LLM query expansion", () => {
  507. test("search() with query and rerank:false returns results", async () => {
  508. const results = await store.search({ query: "authentication", rerank: false });
  509. expect(results.length).toBeGreaterThan(0);
  510. expect(results[0]).toHaveProperty("file");
  511. expect(results[0]).toHaveProperty("score");
  512. expect(results[0]).toHaveProperty("title");
  513. expect(results[0]).toHaveProperty("bestChunk");
  514. expect(results[0]).toHaveProperty("docid");
  515. });
  516. test("search() with intent and rerank:false returns results", async () => {
  517. const results = await store.search({
  518. query: "meeting",
  519. intent: "quarterly planning and roadmap",
  520. rerank: false,
  521. });
  522. expect(results.length).toBeGreaterThan(0);
  523. });
  524. test("search() with collection filter", async () => {
  525. const results = await store.search({
  526. query: "authentication",
  527. collection: "docs",
  528. rerank: false,
  529. });
  530. for (const r of results) {
  531. expect(r.file).toMatch(/^qmd:\/\/docs\//);
  532. }
  533. });
  534. test("search() with collections filter", async () => {
  535. const results = await store.search({
  536. query: "authentication",
  537. collections: ["docs"],
  538. rerank: false,
  539. });
  540. for (const r of results) {
  541. expect(r.file).toMatch(/^qmd:\/\/docs\//);
  542. }
  543. });
  544. test("search() with limit", async () => {
  545. const results = await store.search({ query: "meeting", limit: 1, rerank: false });
  546. expect(results.length).toBeLessThanOrEqual(1);
  547. });
  548. test("search() returns empty for non-matching query", async () => {
  549. const results = await store.search({ query: "xyznonexistentterm123", rerank: false });
  550. expect(results).toHaveLength(0);
  551. });
  552. });
  553. });
  554. // =============================================================================
  555. // Document Retrieval Tests
  556. // =============================================================================
  557. describe("get and multiGet", () => {
  558. let store: QMDStore;
  559. beforeAll(async () => {
  560. store = await createStore({
  561. dbPath: join(testDir, "get-test.sqlite"),
  562. config: {
  563. collections: {
  564. docs: { path: docsDir, pattern: "**/*.md" },
  565. },
  566. },
  567. });
  568. // Index documents
  569. const now = new Date().toISOString();
  570. const { internal } = store;
  571. const fs = require("fs");
  572. for (const file of ["readme.md", "auth.md", "api.md"]) {
  573. const fullPath = join(docsDir, file);
  574. const content = fs.readFileSync(fullPath, "utf-8");
  575. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  576. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  577. internal.insertContent(hash, content, now);
  578. internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
  579. }
  580. });
  581. afterAll(async () => {
  582. await store.close();
  583. });
  584. test("get retrieves a document by path", async () => {
  585. const result = await store.get("qmd://docs/auth.md");
  586. expect("error" in result).toBe(false);
  587. if (!("error" in result)) {
  588. expect(result.title).toBe("Authentication");
  589. expect(result.collectionName).toBe("docs");
  590. }
  591. });
  592. test("get with includeBody returns body content", async () => {
  593. const result = await store.get("qmd://docs/auth.md", { includeBody: true });
  594. if (!("error" in result)) {
  595. expect(result.body).toBeDefined();
  596. expect(result.body).toContain("JWT tokens");
  597. }
  598. });
  599. test("get returns not_found for missing document", async () => {
  600. const result = await store.get("qmd://docs/nonexistent.md");
  601. expect("error" in result).toBe(true);
  602. if ("error" in result) {
  603. expect(result.error).toBe("not_found");
  604. }
  605. });
  606. test("get by docid", async () => {
  607. // First get a document to find its docid
  608. const doc = await store.get("qmd://docs/readme.md");
  609. if (!("error" in doc)) {
  610. const byDocid = await store.get(`#${doc.docid}`);
  611. expect("error" in byDocid).toBe(false);
  612. if (!("error" in byDocid)) {
  613. expect(byDocid.docid).toBe(doc.docid);
  614. }
  615. }
  616. });
  617. test("multiGet retrieves multiple documents", async () => {
  618. const { docs, errors } = await store.multiGet("qmd://docs/*.md");
  619. expect(docs.length).toBeGreaterThan(0);
  620. });
  621. });
  622. // =============================================================================
  623. // Index Health Tests
  624. // =============================================================================
  625. describe("index health", () => {
  626. let store: QMDStore;
  627. beforeEach(async () => {
  628. store = await createStore({
  629. dbPath: freshDbPath(),
  630. config: {
  631. collections: {
  632. docs: { path: docsDir, pattern: "**/*.md" },
  633. },
  634. },
  635. });
  636. });
  637. afterEach(async () => {
  638. await store.close();
  639. });
  640. test("getStatus returns valid structure", async () => {
  641. const status = await store.getStatus();
  642. expect(status).toHaveProperty("totalDocuments");
  643. expect(status).toHaveProperty("needsEmbedding");
  644. expect(status).toHaveProperty("hasVectorIndex");
  645. expect(status).toHaveProperty("collections");
  646. expect(typeof status.totalDocuments).toBe("number");
  647. });
  648. test("getIndexHealth returns valid structure", async () => {
  649. const health = await store.getIndexHealth();
  650. expect(health).toHaveProperty("needsEmbedding");
  651. expect(health).toHaveProperty("totalDocs");
  652. expect(typeof health.needsEmbedding).toBe("number");
  653. expect(typeof health.totalDocs).toBe("number");
  654. });
  655. test("fresh store has zero documents", async () => {
  656. const status = await store.getStatus();
  657. expect(status.totalDocuments).toBe(0);
  658. });
  659. });
  660. // =============================================================================
  661. // Update Tests
  662. // =============================================================================
  663. describe("update", () => {
  664. test("indexes files and returns correct stats", async () => {
  665. const store = await createStore({
  666. dbPath: freshDbPath(),
  667. config: {
  668. collections: {
  669. docs: { path: docsDir, pattern: "**/*.md" },
  670. },
  671. },
  672. });
  673. const result = await store.update();
  674. expect(result.collections).toBe(1);
  675. expect(result.indexed).toBe(3); // readme.md, auth.md, api.md
  676. expect(result.updated).toBe(0);
  677. expect(result.unchanged).toBe(0);
  678. expect(result.removed).toBe(0);
  679. expect(typeof result.needsEmbedding).toBe("number");
  680. await store.close();
  681. });
  682. test("second update shows unchanged files", async () => {
  683. const store = await createStore({
  684. dbPath: freshDbPath(),
  685. config: {
  686. collections: {
  687. docs: { path: docsDir, pattern: "**/*.md" },
  688. },
  689. },
  690. });
  691. await store.update();
  692. const result = await store.update();
  693. expect(result.indexed).toBe(0);
  694. expect(result.unchanged).toBe(3);
  695. await store.close();
  696. });
  697. test("update with onProgress callback fires", async () => {
  698. const store = await createStore({
  699. dbPath: freshDbPath(),
  700. config: {
  701. collections: {
  702. docs: { path: docsDir, pattern: "**/*.md" },
  703. },
  704. },
  705. });
  706. const progress: UpdateProgress[] = [];
  707. await store.update({
  708. onProgress: (info) => progress.push(info),
  709. });
  710. expect(progress.length).toBeGreaterThan(0);
  711. expect(progress[0]!.collection).toBe("docs");
  712. expect(progress[0]!.current).toBeGreaterThanOrEqual(1);
  713. expect(progress[0]!.total).toBe(3);
  714. await store.close();
  715. });
  716. test("update with collection filter", async () => {
  717. const store = await createStore({
  718. dbPath: freshDbPath(),
  719. config: {
  720. collections: {
  721. docs: { path: docsDir, pattern: "**/*.md" },
  722. notes: { path: notesDir, pattern: "**/*.md" },
  723. },
  724. },
  725. });
  726. const result = await store.update({ collections: ["docs"] });
  727. expect(result.collections).toBe(1);
  728. expect(result.indexed).toBe(3); // Only docs
  729. await store.close();
  730. });
  731. test("update multiple collections", async () => {
  732. const store = await createStore({
  733. dbPath: freshDbPath(),
  734. config: {
  735. collections: {
  736. docs: { path: docsDir, pattern: "**/*.md" },
  737. notes: { path: notesDir, pattern: "**/*.md" },
  738. },
  739. },
  740. });
  741. const result = await store.update();
  742. expect(result.collections).toBe(2);
  743. expect(result.indexed).toBe(6); // 3 docs + 3 notes
  744. await store.close();
  745. });
  746. test("documents are searchable after update", async () => {
  747. const store = await createStore({
  748. dbPath: freshDbPath(),
  749. config: {
  750. collections: {
  751. docs: { path: docsDir, pattern: "**/*.md" },
  752. },
  753. },
  754. });
  755. await store.update();
  756. const results = await store.searchLex("authentication");
  757. expect(results.length).toBeGreaterThan(0);
  758. await store.close();
  759. });
  760. });
  761. // =============================================================================
  762. // Lifecycle Tests
  763. // =============================================================================
  764. describe("lifecycle", () => {
  765. test("close() is async and does not throw", async () => {
  766. const store = await createStore({
  767. dbPath: freshDbPath(),
  768. config: { collections: {} },
  769. });
  770. // close() should return a promise
  771. const result = store.close();
  772. expect(result).toBeInstanceOf(Promise);
  773. await result;
  774. });
  775. test("close() makes subsequent operations throw", async () => {
  776. const store = await createStore({
  777. dbPath: freshDbPath(),
  778. config: { collections: {} },
  779. });
  780. await store.close();
  781. // Database operations should fail after close
  782. await expect(store.getStatus()).rejects.toThrow();
  783. });
  784. test("multiple stores can coexist with different databases", async () => {
  785. const store1 = await createStore({
  786. dbPath: freshDbPath(),
  787. config: {
  788. collections: {
  789. docs: { path: docsDir, pattern: "**/*.md" },
  790. },
  791. },
  792. });
  793. // Note: since config source is module-level, we close store1 first
  794. await store1.close();
  795. const store2 = await createStore({
  796. dbPath: freshDbPath(),
  797. config: {
  798. collections: {
  799. notes: { path: notesDir, pattern: "**/*.md" },
  800. },
  801. },
  802. });
  803. const names = (await store2.listCollections()).map(c => c.name);
  804. expect(names).toContain("notes");
  805. expect(names).not.toContain("docs");
  806. await store2.close();
  807. });
  808. });
  809. // =============================================================================
  810. // Config Initialization Tests
  811. // =============================================================================
  812. describe("config initialization", () => {
  813. test("inline config with global_context is preserved", async () => {
  814. const store = await createStore({
  815. dbPath: freshDbPath(),
  816. config: {
  817. global_context: "System knowledge base",
  818. collections: {
  819. docs: { path: docsDir, pattern: "**/*.md" },
  820. },
  821. },
  822. });
  823. const global = await store.getGlobalContext();
  824. expect(global).toBe("System knowledge base");
  825. await store.close();
  826. });
  827. test("inline config with pre-existing contexts is preserved", async () => {
  828. const store = await createStore({
  829. dbPath: freshDbPath(),
  830. config: {
  831. collections: {
  832. docs: {
  833. path: docsDir,
  834. pattern: "**/*.md",
  835. context: { "/auth": "Authentication docs" },
  836. },
  837. },
  838. },
  839. });
  840. const contexts = await store.listContexts();
  841. expect(contexts).toContainEqual({
  842. collection: "docs",
  843. path: "/auth",
  844. context: "Authentication docs",
  845. });
  846. await store.close();
  847. });
  848. test("inline config with empty collections object works", async () => {
  849. const store = await createStore({
  850. dbPath: freshDbPath(),
  851. config: { collections: {} },
  852. });
  853. expect(await store.listCollections()).toEqual([]);
  854. expect(await store.listContexts()).toEqual([]);
  855. await store.close();
  856. });
  857. test("inline config with multiple collection options", async () => {
  858. const store = await createStore({
  859. dbPath: freshDbPath(),
  860. config: {
  861. collections: {
  862. docs: {
  863. path: docsDir,
  864. pattern: "**/*.md",
  865. ignore: ["drafts/**"],
  866. includeByDefault: true,
  867. },
  868. notes: {
  869. path: notesDir,
  870. pattern: "**/*.md",
  871. includeByDefault: false,
  872. },
  873. },
  874. },
  875. });
  876. const collections = await store.listCollections();
  877. expect(collections).toHaveLength(2);
  878. await store.close();
  879. });
  880. });
  881. // =============================================================================
  882. // Type Export Tests (compile-time checks, runtime verification)
  883. // =============================================================================
  884. describe("type exports", () => {
  885. test("StoreOptions type is usable", () => {
  886. const opts: StoreOptions = {
  887. dbPath: "/tmp/test.sqlite",
  888. config: { collections: {} },
  889. };
  890. expect(opts.dbPath).toBe("/tmp/test.sqlite");
  891. });
  892. test("CollectionConfig type is usable", () => {
  893. const config: CollectionConfig = {
  894. global_context: "test",
  895. collections: {
  896. test: { path: "/tmp", pattern: "**/*.md" },
  897. },
  898. };
  899. expect(config.collections).toHaveProperty("test");
  900. });
  901. test("QMDStore type exposes expected methods", async () => {
  902. const store = await createStore({
  903. dbPath: freshDbPath(),
  904. config: { collections: {} },
  905. });
  906. // Verify all methods exist
  907. expect(typeof store.search).toBe("function");
  908. expect(typeof store.searchLex).toBe("function");
  909. expect(typeof store.searchVector).toBe("function");
  910. expect(typeof store.expandQuery).toBe("function");
  911. expect(typeof store.get).toBe("function");
  912. expect(typeof store.multiGet).toBe("function");
  913. expect(typeof store.addCollection).toBe("function");
  914. expect(typeof store.removeCollection).toBe("function");
  915. expect(typeof store.renameCollection).toBe("function");
  916. expect(typeof store.listCollections).toBe("function");
  917. expect(typeof store.addContext).toBe("function");
  918. expect(typeof store.removeContext).toBe("function");
  919. expect(typeof store.setGlobalContext).toBe("function");
  920. expect(typeof store.getGlobalContext).toBe("function");
  921. expect(typeof store.listContexts).toBe("function");
  922. expect(typeof store.getStatus).toBe("function");
  923. expect(typeof store.getIndexHealth).toBe("function");
  924. expect(typeof store.update).toBe("function");
  925. expect(typeof store.embed).toBe("function");
  926. expect(typeof store.close).toBe("function");
  927. await store.close();
  928. });
  929. });
  930. // =============================================================================
  931. // DB-Only Mode Tests (self-contained store)
  932. // =============================================================================
  933. describe("DB-only mode", () => {
  934. test("reopen store with just dbPath after config+update session", async () => {
  935. const dbPath = freshDbPath();
  936. // Session 1: create store with config, update, close
  937. const store1 = await createStore({
  938. dbPath,
  939. config: {
  940. collections: {
  941. docs: { path: docsDir, pattern: "**/*.md" },
  942. notes: { path: notesDir, pattern: "**/*.md" },
  943. },
  944. global_context: "Test knowledge base",
  945. },
  946. });
  947. await store1.update();
  948. // Verify documents indexed
  949. const status1 = await store1.getStatus();
  950. expect(status1.totalDocuments).toBe(6);
  951. await store1.close();
  952. // Session 2: reopen with just dbPath — no config
  953. const store2 = await createStore({ dbPath } as StoreOptions);
  954. // Collections should still be available
  955. const collections = await store2.listCollections();
  956. expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);
  957. // Search should still work
  958. const results = await store2.searchLex("authentication");
  959. expect(results.length).toBeGreaterThan(0);
  960. // Global context should still be available
  961. const globalCtx = await store2.getGlobalContext();
  962. expect(globalCtx).toBe("Test knowledge base");
  963. // Contexts from collections should persist
  964. const status2 = await store2.getStatus();
  965. expect(status2.totalDocuments).toBe(6);
  966. await store2.close();
  967. });
  968. test("config sync populates store_collections table", async () => {
  969. const dbPath = freshDbPath();
  970. const store = await createStore({
  971. dbPath,
  972. config: {
  973. collections: {
  974. docs: {
  975. path: docsDir,
  976. pattern: "**/*.md",
  977. context: { "/auth": "Auth documentation" },
  978. },
  979. },
  980. },
  981. });
  982. // Verify collections are in the DB via listCollections
  983. const collections = await store.listCollections();
  984. expect(collections).toHaveLength(1);
  985. expect(collections[0]!.name).toBe("docs");
  986. expect(collections[0]!.pwd).toBe(docsDir);
  987. // Verify contexts are accessible
  988. const contexts = await store.listContexts();
  989. expect(contexts).toContainEqual({
  990. collection: "docs",
  991. path: "/auth",
  992. context: "Auth documentation",
  993. });
  994. await store.close();
  995. });
  996. test("config hash skip: second init with same config skips sync", async () => {
  997. const dbPath = freshDbPath();
  998. const config = {
  999. collections: {
  1000. docs: { path: docsDir, pattern: "**/*.md" },
  1001. },
  1002. };
  1003. // First init — syncs config
  1004. const store1 = await createStore({ dbPath, config });
  1005. await store1.close();
  1006. // Second init with same config — should skip sync (no-op, but should not error)
  1007. const store2 = await createStore({ dbPath, config });
  1008. const collections = await store2.listCollections();
  1009. expect(collections).toHaveLength(1);
  1010. expect(collections[0]!.name).toBe("docs");
  1011. await store2.close();
  1012. });
  1013. test("DB-only mode supports collection mutations", async () => {
  1014. const dbPath = freshDbPath();
  1015. // Session 1: create with config
  1016. const store1 = await createStore({
  1017. dbPath,
  1018. config: {
  1019. collections: {
  1020. docs: { path: docsDir, pattern: "**/*.md" },
  1021. },
  1022. },
  1023. });
  1024. await store1.close();
  1025. // Session 2: reopen DB-only, add a collection
  1026. const store2 = await createStore({ dbPath } as StoreOptions);
  1027. await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  1028. const names = (await store2.listCollections()).map(c => c.name).sort();
  1029. expect(names).toEqual(["docs", "notes"]);
  1030. await store2.close();
  1031. // Session 3: reopen DB-only again, verify both collections persist
  1032. const store3 = await createStore({ dbPath } as StoreOptions);
  1033. const names3 = (await store3.listCollections()).map(c => c.name).sort();
  1034. expect(names3).toEqual(["docs", "notes"]);
  1035. await store3.close();
  1036. });
  1037. test("DB-only mode supports context mutations", async () => {
  1038. const dbPath = freshDbPath();
  1039. // Session 1: create with config
  1040. const store1 = await createStore({
  1041. dbPath,
  1042. config: {
  1043. collections: {
  1044. docs: { path: docsDir, pattern: "**/*.md" },
  1045. },
  1046. },
  1047. });
  1048. await store1.addContext("docs", "/api", "API docs");
  1049. await store1.setGlobalContext("Global context");
  1050. await store1.close();
  1051. // Session 2: reopen DB-only
  1052. const store2 = await createStore({ dbPath } as StoreOptions);
  1053. const contexts = await store2.listContexts();
  1054. expect(contexts).toContainEqual({
  1055. collection: "docs",
  1056. path: "/api",
  1057. context: "API docs",
  1058. });
  1059. expect(contexts).toContainEqual({
  1060. collection: "*",
  1061. path: "/",
  1062. context: "Global context",
  1063. });
  1064. await store2.close();
  1065. });
  1066. });