sdk.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  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. } from "../src/index.js";
  19. // =============================================================================
  20. // Test Helpers
  21. // =============================================================================
  22. let testDir: string;
  23. let docsDir: string;
  24. let notesDir: string;
  25. beforeAll(async () => {
  26. testDir = await mkdtemp(join(tmpdir(), "qmd-sdk-test-"));
  27. docsDir = join(testDir, "docs");
  28. notesDir = join(testDir, "notes");
  29. // Create test directories with sample markdown files
  30. await mkdir(docsDir, { recursive: true });
  31. await mkdir(notesDir, { recursive: true });
  32. await writeFile(join(docsDir, "readme.md"), "# Getting Started\n\nThis is the getting started guide for the project.\n");
  33. await writeFile(join(docsDir, "auth.md"), "# Authentication\n\nAuthentication uses JWT tokens for session management.\nUsers log in with email and password.\n");
  34. 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");
  35. await writeFile(join(notesDir, "meeting-2025-01.md"), "# January Planning Meeting\n\nDiscussed Q1 roadmap and resource allocation.\n");
  36. await writeFile(join(notesDir, "meeting-2025-02.md"), "# February Standup\n\nReviewed sprint progress. Authentication feature is on track.\n");
  37. await writeFile(join(notesDir, "ideas.md"), "# Project Ideas\n\n- Build a search engine\n- Create a knowledge base\n- Implement vector search\n");
  38. });
  39. afterAll(async () => {
  40. try {
  41. await rm(testDir, { recursive: true, force: true });
  42. } catch {
  43. // Ignore cleanup errors
  44. }
  45. });
  46. function freshDbPath(): string {
  47. return join(testDir, `test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
  48. }
  49. // =============================================================================
  50. // Constructor Tests
  51. // =============================================================================
  52. describe("createStore", () => {
  53. test("creates store with inline config", () => {
  54. const store = createStore({
  55. dbPath: freshDbPath(),
  56. config: {
  57. collections: {
  58. docs: { path: docsDir, pattern: "**/*.md" },
  59. },
  60. },
  61. });
  62. expect(store).toBeDefined();
  63. expect(store.dbPath).toBeTruthy();
  64. expect(store.internal).toBeDefined();
  65. store.close();
  66. });
  67. test("creates store with YAML config file", () => {
  68. const configPath = join(testDir, "test-config.yml");
  69. const config: CollectionConfig = {
  70. collections: {
  71. docs: { path: docsDir, pattern: "**/*.md" },
  72. },
  73. };
  74. writeFileSync(configPath, YAML.stringify(config));
  75. const store = createStore({
  76. dbPath: freshDbPath(),
  77. configPath,
  78. });
  79. expect(store).toBeDefined();
  80. store.close();
  81. });
  82. test("throws if dbPath is missing", () => {
  83. expect(() =>
  84. createStore({ dbPath: "", config: { collections: {} } })
  85. ).toThrow("dbPath is required");
  86. });
  87. test("throws if neither configPath nor config is provided", () => {
  88. expect(() =>
  89. createStore({ dbPath: freshDbPath() } as StoreOptions)
  90. ).toThrow("Either configPath or config is required");
  91. });
  92. test("throws if both configPath and config are provided", () => {
  93. expect(() =>
  94. createStore({
  95. dbPath: freshDbPath(),
  96. configPath: "/some/path.yml",
  97. config: { collections: {} },
  98. })
  99. ).toThrow("Provide either configPath or config, not both");
  100. });
  101. test("creates database file on disk", () => {
  102. const dbPath = freshDbPath();
  103. const store = createStore({
  104. dbPath,
  105. config: { collections: {} },
  106. });
  107. expect(existsSync(dbPath)).toBe(true);
  108. store.close();
  109. });
  110. test("store.dbPath matches the provided path", () => {
  111. const dbPath = freshDbPath();
  112. const store = createStore({
  113. dbPath,
  114. config: { collections: {} },
  115. });
  116. expect(store.dbPath).toBe(dbPath);
  117. store.close();
  118. });
  119. });
  120. // =============================================================================
  121. // Collection Management Tests
  122. // =============================================================================
  123. describe("collection management", () => {
  124. let store: QMDStore;
  125. beforeEach(() => {
  126. store = createStore({
  127. dbPath: freshDbPath(),
  128. config: { collections: {} },
  129. });
  130. });
  131. afterEach(() => {
  132. store.close();
  133. });
  134. test("addCollection adds a collection to inline config", () => {
  135. store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  136. const collections = store.listCollections();
  137. const names = collections.map(c => c.name);
  138. expect(names).toContain("docs");
  139. });
  140. test("addCollection with default pattern", () => {
  141. store.addCollection("notes", { path: notesDir });
  142. const collections = store.listCollections();
  143. expect(collections.find(c => c.name === "notes")).toBeDefined();
  144. });
  145. test("removeCollection removes existing collection", () => {
  146. store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  147. const removed = store.removeCollection("docs");
  148. expect(removed).toBe(true);
  149. const collections = store.listCollections();
  150. expect(collections.map(c => c.name)).not.toContain("docs");
  151. });
  152. test("removeCollection returns false for non-existent collection", () => {
  153. const removed = store.removeCollection("nonexistent");
  154. expect(removed).toBe(false);
  155. });
  156. test("renameCollection renames a collection", () => {
  157. store.addCollection("old-name", { path: docsDir, pattern: "**/*.md" });
  158. const renamed = store.renameCollection("old-name", "new-name");
  159. expect(renamed).toBe(true);
  160. const names = store.listCollections().map(c => c.name);
  161. expect(names).toContain("new-name");
  162. expect(names).not.toContain("old-name");
  163. });
  164. test("renameCollection returns false for non-existent source", () => {
  165. const renamed = store.renameCollection("nonexistent", "new-name");
  166. expect(renamed).toBe(false);
  167. });
  168. test("renameCollection throws if target exists", () => {
  169. store.addCollection("a", { path: docsDir, pattern: "**/*.md" });
  170. store.addCollection("b", { path: notesDir, pattern: "**/*.md" });
  171. expect(() => store.renameCollection("a", "b")).toThrow("already exists");
  172. });
  173. test("listCollections returns empty array for empty config", () => {
  174. const collections = store.listCollections();
  175. expect(collections).toEqual([]);
  176. });
  177. test("multiple collections can be added", () => {
  178. store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  179. store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  180. const names = store.listCollections().map(c => c.name);
  181. expect(names).toContain("docs");
  182. expect(names).toContain("notes");
  183. expect(names).toHaveLength(2);
  184. });
  185. });
  186. // =============================================================================
  187. // Context Management Tests
  188. // =============================================================================
  189. describe("context management", () => {
  190. let store: QMDStore;
  191. beforeEach(() => {
  192. store = createStore({
  193. dbPath: freshDbPath(),
  194. config: {
  195. collections: {
  196. docs: { path: docsDir, pattern: "**/*.md" },
  197. notes: { path: notesDir, pattern: "**/*.md" },
  198. },
  199. },
  200. });
  201. });
  202. afterEach(() => {
  203. store.close();
  204. });
  205. test("addContext adds context to a collection path", () => {
  206. const added = store.addContext("docs", "/auth", "Authentication docs");
  207. expect(added).toBe(true);
  208. const contexts = store.listContexts();
  209. expect(contexts).toContainEqual({
  210. collection: "docs",
  211. path: "/auth",
  212. context: "Authentication docs",
  213. });
  214. });
  215. test("addContext returns false for non-existent collection", () => {
  216. const added = store.addContext("nonexistent", "/path", "Some context");
  217. expect(added).toBe(false);
  218. });
  219. test("removeContext removes existing context", () => {
  220. store.addContext("docs", "/auth", "Authentication docs");
  221. const removed = store.removeContext("docs", "/auth");
  222. expect(removed).toBe(true);
  223. const contexts = store.listContexts();
  224. expect(contexts.find(c => c.path === "/auth")).toBeUndefined();
  225. });
  226. test("removeContext returns false for non-existent context", () => {
  227. const removed = store.removeContext("docs", "/nonexistent");
  228. expect(removed).toBe(false);
  229. });
  230. test("setGlobalContext sets and retrieves global context", () => {
  231. store.setGlobalContext("Global knowledge base");
  232. const global = store.getGlobalContext();
  233. expect(global).toBe("Global knowledge base");
  234. });
  235. test("setGlobalContext with undefined clears it", () => {
  236. store.setGlobalContext("Some context");
  237. store.setGlobalContext(undefined);
  238. const global = store.getGlobalContext();
  239. expect(global).toBeUndefined();
  240. });
  241. test("listContexts includes global context", () => {
  242. store.setGlobalContext("Global context");
  243. const contexts = store.listContexts();
  244. expect(contexts).toContainEqual({
  245. collection: "*",
  246. path: "/",
  247. context: "Global context",
  248. });
  249. });
  250. test("listContexts returns contexts across multiple collections", () => {
  251. store.addContext("docs", "/", "Documentation");
  252. store.addContext("notes", "/", "Personal notes");
  253. const contexts = store.listContexts();
  254. expect(contexts.filter(c => c.path === "/")).toHaveLength(2);
  255. });
  256. test("multiple contexts on same collection", () => {
  257. store.addContext("docs", "/auth", "Auth docs");
  258. store.addContext("docs", "/api", "API docs");
  259. const contexts = store.listContexts().filter(c => c.collection === "docs");
  260. expect(contexts).toHaveLength(2);
  261. expect(contexts.map(c => c.path).sort()).toEqual(["/api", "/auth"]);
  262. });
  263. test("addContext overwrites existing context for same path", () => {
  264. store.addContext("docs", "/auth", "Old context");
  265. store.addContext("docs", "/auth", "New context");
  266. const contexts = store.listContexts().filter(c => c.path === "/auth");
  267. expect(contexts).toHaveLength(1);
  268. expect(contexts[0]!.context).toBe("New context");
  269. });
  270. });
  271. // =============================================================================
  272. // Inline Config Isolation Tests
  273. // =============================================================================
  274. describe("inline config isolation", () => {
  275. test("inline config does not write any files to disk", () => {
  276. const configDir = join(testDir, "should-not-exist");
  277. const store = createStore({
  278. dbPath: freshDbPath(),
  279. config: {
  280. collections: {
  281. docs: { path: docsDir, pattern: "**/*.md" },
  282. },
  283. },
  284. });
  285. store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
  286. store.addContext("docs", "/", "Documentation");
  287. expect(existsSync(configDir)).toBe(false);
  288. store.close();
  289. });
  290. test("inline config mutations persist within session", () => {
  291. const store = createStore({
  292. dbPath: freshDbPath(),
  293. config: { collections: {} },
  294. });
  295. store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
  296. store.addContext("docs", "/", "My docs");
  297. // Verify the mutations are visible
  298. const collections = store.listCollections();
  299. expect(collections.map(c => c.name)).toContain("docs");
  300. const contexts = store.listContexts();
  301. expect(contexts).toContainEqual({
  302. collection: "docs",
  303. path: "/",
  304. context: "My docs",
  305. });
  306. store.close();
  307. });
  308. test("two stores with different inline configs are independent", () => {
  309. const store1 = createStore({
  310. dbPath: freshDbPath(),
  311. config: {
  312. collections: {
  313. docs: { path: docsDir, pattern: "**/*.md" },
  314. },
  315. },
  316. });
  317. // Close first store (resets config source)
  318. store1.close();
  319. const store2 = createStore({
  320. dbPath: freshDbPath(),
  321. config: {
  322. collections: {
  323. notes: { path: notesDir, pattern: "**/*.md" },
  324. },
  325. },
  326. });
  327. const names = store2.listCollections().map(c => c.name);
  328. expect(names).toContain("notes");
  329. expect(names).not.toContain("docs");
  330. store2.close();
  331. });
  332. });
  333. // =============================================================================
  334. // YAML Config File Tests
  335. // =============================================================================
  336. describe("YAML config file mode", () => {
  337. test("loads collections from YAML file", () => {
  338. const configPath = join(testDir, `config-${Date.now()}.yml`);
  339. const config: CollectionConfig = {
  340. collections: {
  341. docs: { path: docsDir, pattern: "**/*.md" },
  342. notes: { path: notesDir, pattern: "**/*.md" },
  343. },
  344. };
  345. writeFileSync(configPath, YAML.stringify(config));
  346. const store = createStore({ dbPath: freshDbPath(), configPath });
  347. const names = store.listCollections().map(c => c.name);
  348. expect(names).toContain("docs");
  349. expect(names).toContain("notes");
  350. store.close();
  351. });
  352. test("addCollection persists to YAML file", () => {
  353. const configPath = join(testDir, `config-persist-${Date.now()}.yml`);
  354. writeFileSync(configPath, YAML.stringify({ collections: {} }));
  355. const store = createStore({ dbPath: freshDbPath(), configPath });
  356. store.addCollection("newcol", { path: docsDir, pattern: "**/*.md" });
  357. store.close();
  358. // Read the YAML file directly and verify
  359. const raw = readFileSync(configPath, "utf-8");
  360. const parsed = YAML.parse(raw) as CollectionConfig;
  361. expect(parsed.collections).toHaveProperty("newcol");
  362. expect(parsed.collections.newcol!.path).toBe(docsDir);
  363. });
  364. test("context persists to YAML file", () => {
  365. const configPath = join(testDir, `config-ctx-${Date.now()}.yml`);
  366. writeFileSync(configPath, YAML.stringify({
  367. collections: { docs: { path: docsDir, pattern: "**/*.md" } },
  368. }));
  369. const store = createStore({ dbPath: freshDbPath(), configPath });
  370. store.addContext("docs", "/api", "API documentation");
  371. store.close();
  372. const raw = readFileSync(configPath, "utf-8");
  373. const parsed = YAML.parse(raw) as CollectionConfig;
  374. expect(parsed.collections.docs!.context).toEqual({ "/api": "API documentation" });
  375. });
  376. test("non-existent config file returns empty collections", () => {
  377. const configPath = join(testDir, "nonexistent-config.yml");
  378. const store = createStore({ dbPath: freshDbPath(), configPath });
  379. const collections = store.listCollections();
  380. expect(collections).toEqual([]);
  381. store.close();
  382. });
  383. });
  384. // =============================================================================
  385. // Search Tests (BM25 - no LLM needed)
  386. // =============================================================================
  387. describe("search (BM25)", () => {
  388. let store: QMDStore;
  389. let dbPath: string;
  390. beforeAll(() => {
  391. dbPath = join(testDir, "search-test.sqlite");
  392. store = createStore({
  393. dbPath,
  394. config: {
  395. collections: {
  396. docs: { path: docsDir, pattern: "**/*.md" },
  397. notes: { path: notesDir, pattern: "**/*.md" },
  398. },
  399. },
  400. });
  401. // Index documents manually using internal store
  402. const now = new Date().toISOString();
  403. const { internal } = store;
  404. const fs = require("fs");
  405. // Index docs collection
  406. for (const file of ["readme.md", "auth.md", "api.md"]) {
  407. const fullPath = join(docsDir, file);
  408. const content = fs.readFileSync(fullPath, "utf-8");
  409. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  410. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  411. internal.insertContent(hash, content, now);
  412. internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
  413. }
  414. // Index notes collection
  415. for (const file of ["meeting-2025-01.md", "meeting-2025-02.md", "ideas.md"]) {
  416. const fullPath = join(notesDir, file);
  417. const content = fs.readFileSync(fullPath, "utf-8");
  418. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  419. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  420. internal.insertContent(hash, content, now);
  421. internal.insertDocument("notes", `qmd://notes/${file}`, title, hash, now, now);
  422. }
  423. });
  424. afterAll(() => {
  425. store.close();
  426. });
  427. test("search returns results for matching query", () => {
  428. const results = store.search("authentication");
  429. expect(results.length).toBeGreaterThan(0);
  430. });
  431. test("search results have expected shape", () => {
  432. const results = store.search("authentication");
  433. expect(results.length).toBeGreaterThan(0);
  434. const result = results[0]!;
  435. expect(result).toHaveProperty("filepath");
  436. expect(result).toHaveProperty("score");
  437. expect(result).toHaveProperty("title");
  438. expect(result).toHaveProperty("docid");
  439. expect(result).toHaveProperty("collectionName");
  440. expect(typeof result.score).toBe("number");
  441. expect(result.score).toBeGreaterThan(0);
  442. });
  443. test("search respects limit option", () => {
  444. const results = store.search("meeting", { limit: 1 });
  445. expect(results.length).toBeLessThanOrEqual(1);
  446. });
  447. test("search with collection filter", () => {
  448. const results = store.search("authentication", { collection: "notes" });
  449. for (const r of results) {
  450. expect(r.collectionName).toBe("notes");
  451. }
  452. });
  453. test("search returns empty for non-matching query", () => {
  454. const results = store.search("xyznonexistentterm123");
  455. expect(results).toHaveLength(0);
  456. });
  457. test("search finds documents across collections", () => {
  458. const results = store.search("authentication", { limit: 10 });
  459. const collections = new Set(results.map(r => r.collectionName));
  460. // Auth appears in both docs/auth.md and notes/meeting-2025-02.md
  461. expect(collections.size).toBeGreaterThanOrEqual(1);
  462. });
  463. });
  464. // =============================================================================
  465. // Document Retrieval Tests
  466. // =============================================================================
  467. describe("get and multiGet", () => {
  468. let store: QMDStore;
  469. beforeAll(() => {
  470. store = createStore({
  471. dbPath: join(testDir, "get-test.sqlite"),
  472. config: {
  473. collections: {
  474. docs: { path: docsDir, pattern: "**/*.md" },
  475. },
  476. },
  477. });
  478. // Index documents
  479. const now = new Date().toISOString();
  480. const { internal } = store;
  481. const fs = require("fs");
  482. for (const file of ["readme.md", "auth.md", "api.md"]) {
  483. const fullPath = join(docsDir, file);
  484. const content = fs.readFileSync(fullPath, "utf-8");
  485. const hash = require("crypto").createHash("sha256").update(content).digest("hex");
  486. const title = content.match(/^#\s+(.+)/m)?.[1] || file;
  487. internal.insertContent(hash, content, now);
  488. internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
  489. }
  490. });
  491. afterAll(() => {
  492. store.close();
  493. });
  494. test("get retrieves a document by path", () => {
  495. const result = store.get("qmd://docs/auth.md");
  496. expect("error" in result).toBe(false);
  497. if (!("error" in result)) {
  498. expect(result.title).toBe("Authentication");
  499. expect(result.collectionName).toBe("docs");
  500. }
  501. });
  502. test("get with includeBody returns body content", () => {
  503. const result = store.get("qmd://docs/auth.md", { includeBody: true });
  504. if (!("error" in result)) {
  505. expect(result.body).toBeDefined();
  506. expect(result.body).toContain("JWT tokens");
  507. }
  508. });
  509. test("get returns not_found for missing document", () => {
  510. const result = store.get("qmd://docs/nonexistent.md");
  511. expect("error" in result).toBe(true);
  512. if ("error" in result) {
  513. expect(result.error).toBe("not_found");
  514. }
  515. });
  516. test("get by docid", () => {
  517. // First get a document to find its docid
  518. const doc = store.get("qmd://docs/readme.md");
  519. if (!("error" in doc)) {
  520. const byDocid = store.get(`#${doc.docid}`);
  521. expect("error" in byDocid).toBe(false);
  522. if (!("error" in byDocid)) {
  523. expect(byDocid.docid).toBe(doc.docid);
  524. }
  525. }
  526. });
  527. test("multiGet retrieves multiple documents", () => {
  528. const { docs, errors } = store.multiGet("qmd://docs/*.md");
  529. expect(docs.length).toBeGreaterThan(0);
  530. });
  531. });
  532. // =============================================================================
  533. // Index Health Tests
  534. // =============================================================================
  535. describe("index health", () => {
  536. let store: QMDStore;
  537. beforeEach(() => {
  538. store = createStore({
  539. dbPath: freshDbPath(),
  540. config: {
  541. collections: {
  542. docs: { path: docsDir, pattern: "**/*.md" },
  543. },
  544. },
  545. });
  546. });
  547. afterEach(() => {
  548. store.close();
  549. });
  550. test("getStatus returns valid structure", () => {
  551. const status = store.getStatus();
  552. expect(status).toHaveProperty("totalDocuments");
  553. expect(status).toHaveProperty("needsEmbedding");
  554. expect(status).toHaveProperty("hasVectorIndex");
  555. expect(status).toHaveProperty("collections");
  556. expect(typeof status.totalDocuments).toBe("number");
  557. });
  558. test("getIndexHealth returns valid structure", () => {
  559. const health = store.getIndexHealth();
  560. expect(health).toHaveProperty("needsEmbedding");
  561. expect(health).toHaveProperty("totalDocs");
  562. expect(typeof health.needsEmbedding).toBe("number");
  563. expect(typeof health.totalDocs).toBe("number");
  564. });
  565. test("fresh store has zero documents", () => {
  566. const status = store.getStatus();
  567. expect(status.totalDocuments).toBe(0);
  568. });
  569. });
  570. // =============================================================================
  571. // Lifecycle Tests
  572. // =============================================================================
  573. describe("lifecycle", () => {
  574. test("close() makes subsequent operations throw", () => {
  575. const store = createStore({
  576. dbPath: freshDbPath(),
  577. config: { collections: {} },
  578. });
  579. store.close();
  580. // Database operations should fail after close
  581. expect(() => store.getStatus()).toThrow();
  582. });
  583. test("multiple stores can coexist with different databases", () => {
  584. const store1 = createStore({
  585. dbPath: freshDbPath(),
  586. config: {
  587. collections: {
  588. docs: { path: docsDir, pattern: "**/*.md" },
  589. },
  590. },
  591. });
  592. // Note: since config source is module-level, we close store1 first
  593. store1.close();
  594. const store2 = createStore({
  595. dbPath: freshDbPath(),
  596. config: {
  597. collections: {
  598. notes: { path: notesDir, pattern: "**/*.md" },
  599. },
  600. },
  601. });
  602. const names = store2.listCollections().map(c => c.name);
  603. expect(names).toContain("notes");
  604. expect(names).not.toContain("docs");
  605. store2.close();
  606. });
  607. });
  608. // =============================================================================
  609. // Config Initialization Tests
  610. // =============================================================================
  611. describe("config initialization", () => {
  612. test("inline config with global_context is preserved", () => {
  613. const store = createStore({
  614. dbPath: freshDbPath(),
  615. config: {
  616. global_context: "System knowledge base",
  617. collections: {
  618. docs: { path: docsDir, pattern: "**/*.md" },
  619. },
  620. },
  621. });
  622. const global = store.getGlobalContext();
  623. expect(global).toBe("System knowledge base");
  624. store.close();
  625. });
  626. test("inline config with pre-existing contexts is preserved", () => {
  627. const store = createStore({
  628. dbPath: freshDbPath(),
  629. config: {
  630. collections: {
  631. docs: {
  632. path: docsDir,
  633. pattern: "**/*.md",
  634. context: { "/auth": "Authentication docs" },
  635. },
  636. },
  637. },
  638. });
  639. const contexts = store.listContexts();
  640. expect(contexts).toContainEqual({
  641. collection: "docs",
  642. path: "/auth",
  643. context: "Authentication docs",
  644. });
  645. store.close();
  646. });
  647. test("inline config with empty collections object works", () => {
  648. const store = createStore({
  649. dbPath: freshDbPath(),
  650. config: { collections: {} },
  651. });
  652. expect(store.listCollections()).toEqual([]);
  653. expect(store.listContexts()).toEqual([]);
  654. store.close();
  655. });
  656. test("inline config with multiple collection options", () => {
  657. const store = createStore({
  658. dbPath: freshDbPath(),
  659. config: {
  660. collections: {
  661. docs: {
  662. path: docsDir,
  663. pattern: "**/*.md",
  664. ignore: ["drafts/**"],
  665. includeByDefault: true,
  666. },
  667. notes: {
  668. path: notesDir,
  669. pattern: "**/*.md",
  670. includeByDefault: false,
  671. },
  672. },
  673. },
  674. });
  675. const collections = store.listCollections();
  676. expect(collections).toHaveLength(2);
  677. store.close();
  678. });
  679. });
  680. // =============================================================================
  681. // Type Export Tests (compile-time checks, runtime verification)
  682. // =============================================================================
  683. describe("type exports", () => {
  684. test("StoreOptions type is usable", () => {
  685. const opts: StoreOptions = {
  686. dbPath: "/tmp/test.sqlite",
  687. config: { collections: {} },
  688. };
  689. expect(opts.dbPath).toBe("/tmp/test.sqlite");
  690. });
  691. test("CollectionConfig type is usable", () => {
  692. const config: CollectionConfig = {
  693. global_context: "test",
  694. collections: {
  695. test: { path: "/tmp", pattern: "**/*.md" },
  696. },
  697. };
  698. expect(config.collections).toHaveProperty("test");
  699. });
  700. test("QMDStore type exposes expected methods", () => {
  701. const store = createStore({
  702. dbPath: freshDbPath(),
  703. config: { collections: {} },
  704. });
  705. // Verify all methods exist
  706. expect(typeof store.query).toBe("function");
  707. expect(typeof store.search).toBe("function");
  708. expect(typeof store.structuredSearch).toBe("function");
  709. expect(typeof store.get).toBe("function");
  710. expect(typeof store.multiGet).toBe("function");
  711. expect(typeof store.addCollection).toBe("function");
  712. expect(typeof store.removeCollection).toBe("function");
  713. expect(typeof store.renameCollection).toBe("function");
  714. expect(typeof store.listCollections).toBe("function");
  715. expect(typeof store.addContext).toBe("function");
  716. expect(typeof store.removeContext).toBe("function");
  717. expect(typeof store.setGlobalContext).toBe("function");
  718. expect(typeof store.getGlobalContext).toBe("function");
  719. expect(typeof store.listContexts).toBe("function");
  720. expect(typeof store.getStatus).toBe("function");
  721. expect(typeof store.getIndexHealth).toBe("function");
  722. expect(typeof store.close).toBe("function");
  723. store.close();
  724. });
  725. });