store.helpers.unit.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. /**
  2. * Store helper-level unit tests (pure logic, no model/runtime dependency).
  3. */
  4. import { describe, test, expect } from "vitest";
  5. import {
  6. homedir,
  7. resolve,
  8. getDefaultDbPath,
  9. _resetProductionModeForTesting,
  10. getPwd,
  11. getRealPath,
  12. isVirtualPath,
  13. parseVirtualPath,
  14. normalizeVirtualPath,
  15. normalizeDocid,
  16. isDocid,
  17. handelize,
  18. cleanupOrphanedVectors,
  19. sanitizeFTS5Term,
  20. } from "../src/store";
  21. // =============================================================================
  22. // Path Utilities
  23. // =============================================================================
  24. describe("Path Utilities", () => {
  25. test("homedir returns HOME environment variable", () => {
  26. expect(homedir()).toBe(process.env.HOME || "/tmp");
  27. });
  28. test("resolve handles absolute paths", () => {
  29. expect(resolve("/foo/bar")).toBe("/foo/bar");
  30. expect(resolve("/foo", "/bar")).toBe("/bar");
  31. });
  32. test("resolve handles relative paths", () => {
  33. const pwd = process.env.PWD || process.cwd();
  34. expect(resolve("foo")).toBe(`${pwd}/foo`);
  35. expect(resolve("foo", "bar")).toBe(`${pwd}/foo/bar`);
  36. });
  37. test("resolve normalizes . and ..", () => {
  38. expect(resolve("/foo/bar/./baz")).toBe("/foo/bar/baz");
  39. expect(resolve("/foo/bar/../baz")).toBe("/foo/baz");
  40. expect(resolve("/foo/bar/../../baz")).toBe("/baz");
  41. });
  42. test("getDefaultDbPath throws in test mode without INDEX_PATH", () => {
  43. const originalIndexPath = process.env.INDEX_PATH;
  44. delete process.env.INDEX_PATH;
  45. // Reset production mode in case another test file set it (bun runs all
  46. // files in a single process, so module state leaks between files).
  47. _resetProductionModeForTesting();
  48. expect(() => getDefaultDbPath()).toThrow("Database path not set");
  49. if (originalIndexPath) {
  50. process.env.INDEX_PATH = originalIndexPath;
  51. }
  52. });
  53. test("getDefaultDbPath uses INDEX_PATH when set", () => {
  54. const originalIndexPath = process.env.INDEX_PATH;
  55. process.env.INDEX_PATH = "/tmp/test-index.sqlite";
  56. expect(getDefaultDbPath()).toBe("/tmp/test-index.sqlite");
  57. expect(getDefaultDbPath("custom")).toBe("/tmp/test-index.sqlite");
  58. if (originalIndexPath) {
  59. process.env.INDEX_PATH = originalIndexPath;
  60. } else {
  61. delete process.env.INDEX_PATH;
  62. }
  63. });
  64. test("getPwd returns current working directory", () => {
  65. const pwd = getPwd();
  66. expect(pwd).toBeTruthy();
  67. expect(typeof pwd).toBe("string");
  68. });
  69. test("getRealPath resolves symlinks", () => {
  70. const result = getRealPath("/tmp");
  71. expect(result).toBeTruthy();
  72. expect(result === "/tmp" || result === "/private/tmp").toBe(true);
  73. });
  74. });
  75. // =============================================================================
  76. // Handelize Tests
  77. // =============================================================================
  78. describe("cleanupOrphanedVectors", () => {
  79. test("returns 0 when vec table exists in schema but sqlite-vec is unavailable", () => {
  80. const prepare = (sql: string) => {
  81. if (sql.includes("sqlite_master") && sql.includes("vectors_vec")) {
  82. return { get: () => ({ name: "vectors_vec" }) };
  83. }
  84. if (sql.includes("SELECT 1 FROM vectors_vec LIMIT 0")) {
  85. return { get: () => { throw new Error("no such module: vec0"); } };
  86. }
  87. throw new Error(`Unexpected SQL in test: ${sql}`);
  88. };
  89. const db = {
  90. prepare,
  91. exec: () => {
  92. throw new Error("cleanup should not execute vector deletes when sqlite-vec is unavailable");
  93. },
  94. } as any;
  95. expect(cleanupOrphanedVectors(db)).toBe(0);
  96. });
  97. });
  98. // =============================================================================
  99. // Handelize Tests
  100. // =============================================================================
  101. describe("handelize", () => {
  102. test("converts to lowercase", () => {
  103. expect(handelize("README.md")).toBe("readme.md");
  104. expect(handelize("MyFile.MD")).toBe("myfile.md");
  105. });
  106. test("preserves folder structure", () => {
  107. expect(handelize("a/b/c/d.md")).toBe("a/b/c/d.md");
  108. expect(handelize("docs/api/README.md")).toBe("docs/api/readme.md");
  109. });
  110. test("replaces non-word characters with dash", () => {
  111. expect(handelize("hello world.md")).toBe("hello-world.md");
  112. expect(handelize("file (1).md")).toBe("file-1.md");
  113. expect(handelize("foo@bar#baz.md")).toBe("foo-bar-baz.md");
  114. });
  115. test("collapses multiple special chars into single dash", () => {
  116. expect(handelize("hello world.md")).toBe("hello-world.md");
  117. expect(handelize("foo---bar.md")).toBe("foo-bar.md");
  118. expect(handelize("a - b.md")).toBe("a-b.md");
  119. });
  120. test("removes leading and trailing dashes from segments", () => {
  121. expect(handelize("-hello-.md")).toBe("hello.md");
  122. expect(handelize("--test--.md")).toBe("test.md");
  123. expect(handelize("a/-b-/c.md")).toBe("a/b/c.md");
  124. });
  125. test("converts triple underscore to folder separator", () => {
  126. expect(handelize("foo___bar.md")).toBe("foo/bar.md");
  127. expect(handelize("notes___2025___january.md")).toBe("notes/2025/january.md");
  128. expect(handelize("a/b___c/d.md")).toBe("a/b/c/d.md");
  129. });
  130. test("handles complex real-world meeting notes", () => {
  131. const complexName = "Money Movement Licensing Review - 2025/11/19 10:25 EST - Notes by Gemini.md";
  132. const result = handelize(complexName);
  133. expect(result).toBe("money-movement-licensing-review-2025-11-19-10-25-est-notes-by-gemini.md");
  134. expect(result).not.toContain(" ");
  135. expect(result).not.toContain("/");
  136. expect(result).not.toContain(":");
  137. });
  138. test("handles unicode characters", () => {
  139. expect(handelize("日本語.md")).toBe("日本語.md");
  140. expect(handelize("Зоны и проекты.md")).toBe("зоны-и-проекты.md");
  141. expect(handelize("café-notes.md")).toBe("café-notes.md");
  142. expect(handelize("naïve.md")).toBe("naïve.md");
  143. expect(handelize("日本語-notes.md")).toBe("日本語-notes.md");
  144. });
  145. test("handles emoji filenames (issue #302)", () => {
  146. // Emoji-only filenames should convert to hex codepoints
  147. expect(handelize("🐘.md")).toBe("1f418.md");
  148. expect(handelize("🎉.md")).toBe("1f389.md");
  149. // Emoji mixed with text
  150. expect(handelize("notes 🐘.md")).toBe("notes-1f418.md");
  151. expect(handelize("🐘 elephant.md")).toBe("1f418-elephant.md");
  152. // Multiple emojis
  153. expect(handelize("🐘🎉.md")).toBe("1f418-1f389.md");
  154. // Emoji in directory names
  155. expect(handelize("🐘/notes.md")).toBe("1f418/notes.md");
  156. });
  157. test("handles dates and times in filenames", () => {
  158. expect(handelize("meeting-2025-01-15.md")).toBe("meeting-2025-01-15.md");
  159. expect(handelize("notes 2025/01/15.md")).toBe("notes-2025/01/15.md");
  160. expect(handelize("call_10:30_AM.md")).toBe("call-10-30-am.md");
  161. });
  162. test("handles special project naming patterns", () => {
  163. expect(handelize("PROJECT_ABC_v2.0.md")).toBe("project-abc-v2-0.md");
  164. expect(handelize("[WIP] Feature Request.md")).toBe("wip-feature-request.md");
  165. expect(handelize("(DRAFT) Proposal v1.md")).toBe("draft-proposal-v1.md");
  166. });
  167. test("handles symbol-only route filenames", () => {
  168. expect(handelize("routes/api/auth/$.ts")).toBe("routes/api/auth/$.ts");
  169. expect(handelize("app/routes/$id.tsx")).toBe("app/routes/$id.tsx");
  170. });
  171. test("filters out empty segments", () => {
  172. expect(handelize("a//b/c.md")).toBe("a/b/c.md");
  173. expect(handelize("/a/b/")).toBe("a/b");
  174. expect(handelize("///test///")).toBe("test");
  175. });
  176. test("throws error for invalid inputs", () => {
  177. expect(() => handelize("" )).toThrow("path cannot be empty");
  178. expect(() => handelize(" ")).toThrow("path cannot be empty");
  179. expect(() => handelize(".md")).toThrow("no valid filename content");
  180. expect(() => handelize("...")).toThrow("no valid filename content");
  181. expect(() => handelize("___")).toThrow("no valid filename content");
  182. });
  183. test("handles minimal valid inputs", () => {
  184. expect(handelize("a")).toBe("a");
  185. expect(handelize("1")).toBe("1");
  186. expect(handelize("a.md")).toBe("a.md");
  187. });
  188. test("normalizes virtual paths", () => {
  189. expect(normalizeVirtualPath("qmd://docs/readme.md")).toBe("qmd://docs/readme.md");
  190. expect(normalizeVirtualPath("docs/readme.md")).toBe("docs/readme.md");
  191. });
  192. test("detects virtual paths", () => {
  193. expect(isVirtualPath("qmd://docs/readme.md")).toBe(true);
  194. expect(isVirtualPath("/tmp/file.md")).toBe(false);
  195. });
  196. test("parses virtual paths", () => {
  197. expect(parseVirtualPath("qmd://docs/readme.md")).toEqual({
  198. collectionName: "docs",
  199. path: "readme.md",
  200. });
  201. });
  202. test("normalizes docids", () => {
  203. expect(normalizeDocid("123456")).toBe("123456");
  204. expect(normalizeDocid("#123456")).toBe("123456");
  205. });
  206. test("checks docid validity", () => {
  207. expect(isDocid("123456")).toBe(true);
  208. expect(isDocid("#123456")).toBe(true);
  209. expect(isDocid("bad-id")).toBe(false);
  210. expect(isDocid("12345")).toBe(false);
  211. });
  212. });
  213. // =============================================================================
  214. // sanitizeFTS5Term Tests
  215. // =============================================================================
  216. describe("sanitizeFTS5Term", () => {
  217. test("preserves underscores in snake_case identifiers", () => {
  218. expect(sanitizeFTS5Term("my_variable")).toBe("my_variable");
  219. expect(sanitizeFTS5Term("MAX_RETRIES")).toBe("max_retries");
  220. expect(sanitizeFTS5Term("__init__")).toBe("__init__");
  221. });
  222. test("preserves alphanumeric characters", () => {
  223. expect(sanitizeFTS5Term("hello123")).toBe("hello123");
  224. expect(sanitizeFTS5Term("test")).toBe("test");
  225. });
  226. test("preserves apostrophes for contractions", () => {
  227. expect(sanitizeFTS5Term("don't")).toBe("don't");
  228. expect(sanitizeFTS5Term("it's")).toBe("it's");
  229. });
  230. test("strips other punctuation", () => {
  231. expect(sanitizeFTS5Term("hello!")).toBe("hello");
  232. expect(sanitizeFTS5Term("test@value")).toBe("testvalue");
  233. expect(sanitizeFTS5Term("a.b")).toBe("ab");
  234. });
  235. test("lowercases output", () => {
  236. expect(sanitizeFTS5Term("Hello")).toBe("hello");
  237. expect(sanitizeFTS5Term("MY_VAR")).toBe("my_var");
  238. });
  239. test("handles unicode letters and numbers", () => {
  240. expect(sanitizeFTS5Term("café")).toBe("café");
  241. expect(sanitizeFTS5Term("日本語")).toBe("日本語");
  242. });
  243. });