@86d-app/search 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +72 -0
  3. package/README.md +171 -28
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/embedding-provider.test.d.ts +2 -0
  7. package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
  8. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  9. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  10. package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
  11. package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
  12. package/dist/__tests__/service-impl.test.d.ts +2 -0
  13. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  14. package/dist/admin/components/index.d.ts +2 -0
  15. package/dist/admin/components/index.d.ts.map +1 -0
  16. package/dist/admin/components/search-analytics.d.ts +2 -0
  17. package/dist/admin/components/search-analytics.d.ts.map +1 -0
  18. package/dist/admin/endpoints/analytics.d.ts +15 -0
  19. package/dist/admin/endpoints/analytics.d.ts.map +1 -0
  20. package/dist/admin/endpoints/bulk-index.d.ts +20 -0
  21. package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
  22. package/dist/admin/endpoints/click-analytics.d.ts +7 -0
  23. package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
  24. package/dist/admin/endpoints/get-settings.d.ts +17 -0
  25. package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
  26. package/dist/admin/endpoints/index-manage.d.ts +26 -0
  27. package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
  28. package/dist/admin/endpoints/index.d.ts +125 -0
  29. package/dist/admin/endpoints/index.d.ts.map +1 -0
  30. package/dist/admin/endpoints/popular.d.ts +10 -0
  31. package/dist/admin/endpoints/popular.d.ts.map +1 -0
  32. package/dist/admin/endpoints/synonyms.d.ts +30 -0
  33. package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
  34. package/dist/admin/endpoints/zero-results.d.ts +10 -0
  35. package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
  36. package/dist/embedding-provider.d.ts +28 -0
  37. package/dist/embedding-provider.d.ts.map +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/meilisearch-provider.d.ts +104 -0
  41. package/dist/meilisearch-provider.d.ts.map +1 -0
  42. package/dist/schema.d.ts +133 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/service-impl.d.ts +6 -0
  45. package/dist/service-impl.d.ts.map +1 -0
  46. package/dist/service.d.ts +127 -0
  47. package/dist/service.d.ts.map +1 -0
  48. package/dist/store/components/_hooks.d.ts +6 -0
  49. package/dist/store/components/_hooks.d.ts.map +1 -0
  50. package/dist/store/components/index.d.ts +10 -0
  51. package/dist/store/components/index.d.ts.map +1 -0
  52. package/dist/store/components/search-bar.d.ts +7 -0
  53. package/dist/store/components/search-bar.d.ts.map +1 -0
  54. package/dist/store/components/search-page.d.ts +4 -0
  55. package/dist/store/components/search-page.d.ts.map +1 -0
  56. package/dist/store/components/search-results.d.ts +9 -0
  57. package/dist/store/components/search-results.d.ts.map +1 -0
  58. package/dist/store/endpoints/click.d.ts +14 -0
  59. package/dist/store/endpoints/click.d.ts.map +1 -0
  60. package/dist/store/endpoints/index.d.ts +85 -0
  61. package/dist/store/endpoints/index.d.ts.map +1 -0
  62. package/dist/store/endpoints/recent.d.ts +15 -0
  63. package/dist/store/endpoints/recent.d.ts.map +1 -0
  64. package/dist/store/endpoints/search.d.ts +36 -0
  65. package/dist/store/endpoints/search.d.ts.map +1 -0
  66. package/dist/store/endpoints/store-search.d.ts +16 -0
  67. package/dist/store/endpoints/store-search.d.ts.map +1 -0
  68. package/dist/store/endpoints/suggest.d.ts +11 -0
  69. package/dist/store/endpoints/suggest.d.ts.map +1 -0
  70. package/package.json +3 -3
  71. package/src/__tests__/controllers.test.ts +1026 -0
  72. package/src/__tests__/embedding-provider.test.ts +195 -0
  73. package/src/__tests__/endpoint-security.test.ts +300 -0
  74. package/src/__tests__/meilisearch-provider.test.ts +400 -0
  75. package/src/__tests__/service-impl.test.ts +341 -8
  76. package/src/admin/components/search-analytics.tsx +120 -0
  77. package/src/admin/endpoints/bulk-index.ts +34 -0
  78. package/src/admin/endpoints/click-analytics.ts +16 -0
  79. package/src/admin/endpoints/get-settings.ts +56 -0
  80. package/src/admin/endpoints/index-manage.ts +4 -1
  81. package/src/admin/endpoints/index.ts +6 -0
  82. package/src/admin/endpoints/synonyms.ts +1 -1
  83. package/src/embedding-provider.ts +99 -0
  84. package/src/index.ts +60 -4
  85. package/src/meilisearch-provider.ts +239 -0
  86. package/src/schema.ts +15 -0
  87. package/src/service-impl.ts +605 -34
  88. package/src/service.ts +60 -1
  89. package/src/store/endpoints/click.ts +21 -0
  90. package/src/store/endpoints/index.ts +2 -0
  91. package/src/store/endpoints/search.ts +38 -10
  92. package/src/store/endpoints/suggest.ts +2 -2
  93. package/vitest.config.ts +2 -0
@@ -0,0 +1,1026 @@
1
+ import { createMockDataService } from "@86d-app/core/test-utils";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { createSearchController } from "../service-impl";
4
+
5
+ describe("search controllers — edge cases", () => {
6
+ let mockData: ReturnType<typeof createMockDataService>;
7
+ let controller: ReturnType<typeof createSearchController>;
8
+
9
+ beforeEach(() => {
10
+ mockData = createMockDataService();
11
+ controller = createSearchController(mockData);
12
+ });
13
+
14
+ // ── indexItem — deduplication and metadata ───────────────────────
15
+
16
+ describe("indexItem — metadata and deduplication", () => {
17
+ it("stores custom metadata", async () => {
18
+ const item = await controller.indexItem({
19
+ entityType: "product",
20
+ entityId: "prod_m1",
21
+ title: "Widget",
22
+ url: "/products/widget",
23
+ metadata: { price: 1999, currency: "USD" },
24
+ });
25
+ expect(item.metadata).toEqual({ price: 1999, currency: "USD" });
26
+ });
27
+
28
+ it("re-indexing preserves same ID", async () => {
29
+ const first = await controller.indexItem({
30
+ entityType: "product",
31
+ entityId: "prod_dup",
32
+ title: "Original",
33
+ url: "/p/orig",
34
+ });
35
+ const second = await controller.indexItem({
36
+ entityType: "product",
37
+ entityId: "prod_dup",
38
+ title: "Updated",
39
+ url: "/p/updated",
40
+ });
41
+ expect(second.id).toBe(first.id);
42
+ expect(second.title).toBe("Updated");
43
+ expect(second.url).toBe("/p/updated");
44
+ });
45
+
46
+ it("different entity types with same entityId are separate", async () => {
47
+ await controller.indexItem({
48
+ entityType: "product",
49
+ entityId: "shared_id",
50
+ title: "Product",
51
+ url: "/p/shared",
52
+ });
53
+ await controller.indexItem({
54
+ entityType: "blog",
55
+ entityId: "shared_id",
56
+ title: "Blog Post",
57
+ url: "/b/shared",
58
+ });
59
+ expect(await controller.getIndexCount()).toBe(2);
60
+ });
61
+
62
+ it("stores image URL", async () => {
63
+ const item = await controller.indexItem({
64
+ entityType: "product",
65
+ entityId: "img_prod",
66
+ title: "Photo Widget",
67
+ url: "/products/photo",
68
+ image: "/images/photo.jpg",
69
+ });
70
+ expect(item.image).toBe("/images/photo.jpg");
71
+ });
72
+ });
73
+
74
+ // ── search — scoring edge cases ─────────────────────────────────
75
+
76
+ describe("search — scoring and ranking", () => {
77
+ beforeEach(async () => {
78
+ await controller.indexItem({
79
+ entityType: "product",
80
+ entityId: "exact",
81
+ title: "red",
82
+ body: "A plain item",
83
+ tags: ["simple"],
84
+ url: "/products/exact",
85
+ });
86
+ await controller.indexItem({
87
+ entityType: "product",
88
+ entityId: "prefix",
89
+ title: "red shoes",
90
+ body: "Comfortable shoes in red",
91
+ tags: ["footwear", "red"],
92
+ url: "/products/prefix",
93
+ });
94
+ await controller.indexItem({
95
+ entityType: "product",
96
+ entityId: "body_only",
97
+ title: "Blue Sneakers",
98
+ body: "Available in red and blue",
99
+ tags: ["footwear"],
100
+ url: "/products/body-only",
101
+ });
102
+ });
103
+
104
+ it("exact title match scores highest", async () => {
105
+ const { results } = await controller.search("red");
106
+ expect(results.length).toBeGreaterThanOrEqual(2);
107
+ // "red" (exact title match) should score higher than "red shoes" (prefix)
108
+ expect(results[0].item.entityId).toBe("exact");
109
+ });
110
+
111
+ it("multi-word queries match across fields", async () => {
112
+ const { results } = await controller.search("red footwear");
113
+ expect(results.length).toBeGreaterThan(0);
114
+ // "red shoes" has "red" in title and "footwear" in tags
115
+ const topIds = results.map((r) => r.item.entityId);
116
+ expect(topIds).toContain("prefix");
117
+ });
118
+
119
+ it("non-matching query returns empty", async () => {
120
+ const { results, total } = await controller.search("xyznonexistent123");
121
+ expect(results).toHaveLength(0);
122
+ expect(total).toBe(0);
123
+ });
124
+
125
+ it("whitespace-only query returns empty", async () => {
126
+ const { results } = await controller.search(" \t ");
127
+ expect(results).toHaveLength(0);
128
+ });
129
+
130
+ it("hyphenated terms are tokenized", async () => {
131
+ await controller.indexItem({
132
+ entityType: "product",
133
+ entityId: "hyphen",
134
+ title: "t-shirt",
135
+ url: "/products/t-shirt",
136
+ });
137
+ // Searching "shirt" should match the tokenized "shirt"
138
+ const { results } = await controller.search("shirt");
139
+ const ids = results.map((r) => r.item.entityId);
140
+ expect(ids).toContain("hyphen");
141
+ });
142
+
143
+ it("case-insensitive matching", async () => {
144
+ const { results } = await controller.search("RED SHOES");
145
+ expect(results.length).toBeGreaterThan(0);
146
+ const ids = results.map((r) => r.item.entityId);
147
+ expect(ids).toContain("prefix");
148
+ });
149
+ });
150
+
151
+ // ── search — entityType filtering ───────────────────────────────
152
+
153
+ describe("search — entity type filtering", () => {
154
+ beforeEach(async () => {
155
+ await controller.indexItem({
156
+ entityType: "product",
157
+ entityId: "prod_1",
158
+ title: "Red Widget",
159
+ url: "/p/red",
160
+ });
161
+ await controller.indexItem({
162
+ entityType: "blog",
163
+ entityId: "post_1",
164
+ title: "Red is the New Black",
165
+ url: "/b/red",
166
+ });
167
+ await controller.indexItem({
168
+ entityType: "page",
169
+ entityId: "page_1",
170
+ title: "About Our Red Collection",
171
+ url: "/about/red",
172
+ });
173
+ });
174
+
175
+ it("filters to products only", async () => {
176
+ const { results } = await controller.search("red", {
177
+ entityType: "product",
178
+ });
179
+ for (const r of results) {
180
+ expect(r.item.entityType).toBe("product");
181
+ }
182
+ expect(results.length).toBe(1);
183
+ });
184
+
185
+ it("filters to blog only", async () => {
186
+ const { results } = await controller.search("red", {
187
+ entityType: "blog",
188
+ });
189
+ expect(results).toHaveLength(1);
190
+ expect(results[0].item.entityType).toBe("blog");
191
+ });
192
+
193
+ it("returns all types when no filter", async () => {
194
+ const { results } = await controller.search("red");
195
+ const types = new Set(results.map((r) => r.item.entityType));
196
+ expect(types.size).toBe(3);
197
+ });
198
+ });
199
+
200
+ // ── suggest — edge cases ────────────────────────────────────────
201
+
202
+ describe("suggest — deduplication and ordering", () => {
203
+ it("deduplicates case variants in suggestions", async () => {
204
+ await controller.recordQuery("Red T-Shirt", 5);
205
+ await controller.recordQuery("red t-shirt", 3);
206
+ await controller.indexItem({
207
+ entityType: "product",
208
+ entityId: "p1",
209
+ title: "red t-shirt",
210
+ url: "/p/red",
211
+ });
212
+
213
+ const suggestions = await controller.suggest("red");
214
+ // Should not contain duplicates of the same normalized term
215
+ const normalized = suggestions.map((s) => s.toLowerCase().trim());
216
+ expect(new Set(normalized).size).toBe(normalized.length);
217
+ });
218
+
219
+ it("returns title suggestions when no queries match", async () => {
220
+ await controller.indexItem({
221
+ entityType: "product",
222
+ entityId: "p1",
223
+ title: "Green Hat",
224
+ url: "/p/green",
225
+ });
226
+
227
+ const suggestions = await controller.suggest("green");
228
+ expect(suggestions).toHaveLength(1);
229
+ expect(suggestions[0]).toBe("Green Hat");
230
+ });
231
+
232
+ it("excludes zero-result queries from suggestions", async () => {
233
+ await controller.recordQuery("red nothing", 0);
234
+ await controller.recordQuery("red shoes", 5);
235
+
236
+ const suggestions = await controller.suggest("red");
237
+ expect(suggestions).not.toContain("red nothing");
238
+ });
239
+ });
240
+
241
+ // ── getRecentQueries — ordering ─────────────────────────────────
242
+
243
+ describe("getRecentQueries — ordering", () => {
244
+ it("returns most recent first", async () => {
245
+ await controller.recordQuery("first", 10, "sess_order");
246
+ await new Promise((r) => setTimeout(r, 5));
247
+ await controller.recordQuery("second", 5, "sess_order");
248
+ await new Promise((r) => setTimeout(r, 5));
249
+ await controller.recordQuery("third", 3, "sess_order");
250
+
251
+ const recent = await controller.getRecentQueries("sess_order");
252
+ expect(recent[0].term).toBe("third");
253
+ expect(recent[recent.length - 1].term).toBe("first");
254
+ });
255
+
256
+ it("ignores queries from other sessions", async () => {
257
+ await controller.recordQuery("mine", 10, "sess_a");
258
+ await controller.recordQuery("not_mine", 5, "sess_b");
259
+
260
+ const recent = await controller.getRecentQueries("sess_a");
261
+ expect(recent).toHaveLength(1);
262
+ expect(recent[0].term).toBe("mine");
263
+ });
264
+ });
265
+
266
+ // ── getAnalytics — edge cases ───────────────────────────────────
267
+
268
+ describe("getAnalytics — edge cases", () => {
269
+ it("100% zero-result rate when all queries have zero results", async () => {
270
+ await controller.recordQuery("missing1", 0);
271
+ await controller.recordQuery("missing2", 0);
272
+ await controller.recordQuery("missing3", 0);
273
+
274
+ const analytics = await controller.getAnalytics();
275
+ expect(analytics.zeroResultRate).toBe(100);
276
+ expect(analytics.avgResultCount).toBe(0);
277
+ expect(analytics.totalQueries).toBe(3);
278
+ });
279
+
280
+ it("unique terms count is correct with repeated queries", async () => {
281
+ await controller.recordQuery("shoes", 10);
282
+ await controller.recordQuery("shoes", 8);
283
+ await controller.recordQuery("Shoes", 12); // same normalized term
284
+ await controller.recordQuery("hats", 5);
285
+
286
+ const analytics = await controller.getAnalytics();
287
+ expect(analytics.uniqueTerms).toBe(2);
288
+ expect(analytics.totalQueries).toBe(4);
289
+ });
290
+ });
291
+
292
+ // ── synonyms — search integration ───────────────────────────────
293
+
294
+ describe("synonyms — bidirectional expansion", () => {
295
+ it("searching a synonym finds items indexed with the original term", async () => {
296
+ await controller.indexItem({
297
+ entityType: "product",
298
+ entityId: "sneaker_1",
299
+ title: "Running Sneakers",
300
+ url: "/products/sneakers",
301
+ });
302
+ await controller.addSynonym("sneaker", ["shoe", "trainer", "footwear"]);
303
+
304
+ const { results } = await controller.search("shoe");
305
+ expect(results.length).toBeGreaterThan(0);
306
+ });
307
+
308
+ it("searching the original term finds items indexed with synonym text", async () => {
309
+ await controller.indexItem({
310
+ entityType: "product",
311
+ entityId: "shoe_1",
312
+ title: "Leather Shoe",
313
+ url: "/products/shoe",
314
+ });
315
+ await controller.addSynonym("sneaker", ["shoe", "trainer", "footwear"]);
316
+
317
+ const { results } = await controller.search("sneaker");
318
+ const ids = results.map((r) => r.item.entityId);
319
+ expect(ids).toContain("shoe_1");
320
+ });
321
+
322
+ it("removing a synonym stops expansion", async () => {
323
+ await controller.indexItem({
324
+ entityType: "product",
325
+ entityId: "hat_1",
326
+ title: "Fedora Hat",
327
+ url: "/products/fedora",
328
+ });
329
+ const syn = await controller.addSynonym("cap", ["hat", "beanie"]);
330
+
331
+ // Before removal — should find via synonym
332
+ const { results: before } = await controller.search("cap");
333
+ expect(before.length).toBeGreaterThan(0);
334
+
335
+ // Remove and re-search
336
+ await controller.removeSynonym(syn.id);
337
+ const { results: after } = await controller.search("cap");
338
+ // "cap" no longer expands to "hat"
339
+ expect(after).toHaveLength(0);
340
+ });
341
+ });
342
+
343
+ // ── getPopularTerms — tie-breaking ──────────────────────────────
344
+
345
+ describe("getPopularTerms — tie-breaking", () => {
346
+ it("terms with equal count are both returned", async () => {
347
+ await controller.recordQuery("shoes", 10);
348
+ await controller.recordQuery("shoes", 8);
349
+ await controller.recordQuery("hats", 5);
350
+ await controller.recordQuery("hats", 3);
351
+
352
+ const popular = await controller.getPopularTerms();
353
+ expect(popular).toHaveLength(2);
354
+ expect(popular[0].count).toBe(2);
355
+ expect(popular[1].count).toBe(2);
356
+ });
357
+
358
+ it("avgResultCount rounds correctly", async () => {
359
+ await controller.recordQuery("shoes", 10);
360
+ await controller.recordQuery("shoes", 11);
361
+
362
+ const popular = await controller.getPopularTerms();
363
+ expect(popular[0].avgResultCount).toBe(11); // Math.round(21/2) = 11
364
+ });
365
+ });
366
+
367
+ // ── removeFromIndex — multiple items ────────────────────────────
368
+
369
+ describe("removeFromIndex — comprehensive", () => {
370
+ it("removes only the targeted entity, not others of same type", async () => {
371
+ await controller.indexItem({
372
+ entityType: "product",
373
+ entityId: "keep_me",
374
+ title: "Keeper",
375
+ url: "/p/keep",
376
+ });
377
+ await controller.indexItem({
378
+ entityType: "product",
379
+ entityId: "delete_me",
380
+ title: "Doomed",
381
+ url: "/p/delete",
382
+ });
383
+
384
+ await controller.removeFromIndex("product", "delete_me");
385
+ expect(await controller.getIndexCount()).toBe(1);
386
+
387
+ const { results } = await controller.search("Keeper");
388
+ expect(results).toHaveLength(1);
389
+ });
390
+ });
391
+ });
392
+
393
+ describe("search controllers — additional coverage", () => {
394
+ let mockData: ReturnType<typeof createMockDataService>;
395
+ let controller: ReturnType<typeof createSearchController>;
396
+
397
+ beforeEach(() => {
398
+ mockData = createMockDataService();
399
+ controller = createSearchController(mockData);
400
+ });
401
+
402
+ // ── getZeroResultQueries ───────────────────────────────────────
403
+
404
+ describe("getZeroResultQueries", () => {
405
+ it("returns only queries that had zero results", async () => {
406
+ await controller.recordQuery("found shoes", 10);
407
+ await controller.recordQuery("missing widget", 0);
408
+ await controller.recordQuery("no results term", 0);
409
+ await controller.recordQuery("another hit", 5);
410
+
411
+ const zeroQueries = await controller.getZeroResultQueries();
412
+ expect(zeroQueries).toHaveLength(2);
413
+ const terms = zeroQueries.map((q) => q.term);
414
+ expect(terms).toContain("missing widget");
415
+ expect(terms).toContain("no results term");
416
+ });
417
+
418
+ it("aggregates repeated zero-result queries by normalized term", async () => {
419
+ await controller.recordQuery("missing widget", 0);
420
+ await controller.recordQuery("Missing Widget", 0);
421
+ await controller.recordQuery("MISSING WIDGET", 0);
422
+ await controller.recordQuery("other miss", 0);
423
+
424
+ const zeroQueries = await controller.getZeroResultQueries();
425
+ expect(zeroQueries).toHaveLength(2);
426
+ const widgetEntry = zeroQueries.find(
427
+ (q) => q.term.toLowerCase() === "missing widget",
428
+ );
429
+ expect(widgetEntry).toBeDefined();
430
+ expect(widgetEntry?.count).toBe(3);
431
+ });
432
+
433
+ it("sorts by count descending", async () => {
434
+ await controller.recordQuery("rare miss", 0);
435
+ await controller.recordQuery("common miss", 0);
436
+ await controller.recordQuery("common miss", 0);
437
+ await controller.recordQuery("common miss", 0);
438
+ await controller.recordQuery("medium miss", 0);
439
+ await controller.recordQuery("medium miss", 0);
440
+
441
+ const zeroQueries = await controller.getZeroResultQueries();
442
+ expect(zeroQueries[0].count).toBe(3);
443
+ expect(zeroQueries[1].count).toBe(2);
444
+ expect(zeroQueries[2].count).toBe(1);
445
+ });
446
+
447
+ it("respects custom limit", async () => {
448
+ await controller.recordQuery("miss_a", 0);
449
+ await controller.recordQuery("miss_b", 0);
450
+ await controller.recordQuery("miss_c", 0);
451
+ await controller.recordQuery("miss_d", 0);
452
+ await controller.recordQuery("miss_e", 0);
453
+
454
+ const limited = await controller.getZeroResultQueries(2);
455
+ expect(limited).toHaveLength(2);
456
+ });
457
+
458
+ it("uses default limit of 20", async () => {
459
+ for (let i = 0; i < 25; i++) {
460
+ await controller.recordQuery(`miss_${i}`, 0);
461
+ }
462
+ const result = await controller.getZeroResultQueries();
463
+ expect(result).toHaveLength(20);
464
+ });
465
+
466
+ it("returns empty array when no queries have zero results", async () => {
467
+ await controller.recordQuery("good query", 5);
468
+ await controller.recordQuery("another good one", 12);
469
+
470
+ const zeroQueries = await controller.getZeroResultQueries();
471
+ expect(zeroQueries).toHaveLength(0);
472
+ });
473
+
474
+ it("returns empty array when no queries exist at all", async () => {
475
+ const zeroQueries = await controller.getZeroResultQueries();
476
+ expect(zeroQueries).toHaveLength(0);
477
+ });
478
+
479
+ it("always reports avgResultCount as 0", async () => {
480
+ await controller.recordQuery("miss1", 0);
481
+ await controller.recordQuery("miss2", 0);
482
+
483
+ const zeroQueries = await controller.getZeroResultQueries();
484
+ for (const q of zeroQueries) {
485
+ expect(q.avgResultCount).toBe(0);
486
+ }
487
+ });
488
+ });
489
+
490
+ // ── listSynonyms ───────────────────────────────────────────────
491
+
492
+ describe("listSynonyms", () => {
493
+ it("returns empty array when no synonyms defined", async () => {
494
+ const synonyms = await controller.listSynonyms();
495
+ expect(synonyms).toHaveLength(0);
496
+ });
497
+
498
+ it("returns all added synonyms", async () => {
499
+ await controller.addSynonym("shoe", ["sneaker", "trainer"]);
500
+ await controller.addSynonym("hat", ["cap", "beanie"]);
501
+ await controller.addSynonym("pants", ["trousers", "jeans"]);
502
+
503
+ const synonyms = await controller.listSynonyms();
504
+ expect(synonyms).toHaveLength(3);
505
+ const terms = synonyms.map((s) => s.term);
506
+ expect(terms).toContain("shoe");
507
+ expect(terms).toContain("hat");
508
+ expect(terms).toContain("pants");
509
+ });
510
+
511
+ it("reflects removals", async () => {
512
+ const s1 = await controller.addSynonym("shoe", ["sneaker"]);
513
+ await controller.addSynonym("hat", ["cap"]);
514
+
515
+ await controller.removeSynonym(s1.id);
516
+
517
+ const synonyms = await controller.listSynonyms();
518
+ expect(synonyms).toHaveLength(1);
519
+ expect(synonyms[0].term).toBe("hat");
520
+ });
521
+
522
+ it("synonym entries have correct structure", async () => {
523
+ await controller.addSynonym("shoe", ["sneaker", "trainer"]);
524
+
525
+ const synonyms = await controller.listSynonyms();
526
+ expect(synonyms[0]).toHaveProperty("id");
527
+ expect(synonyms[0]).toHaveProperty("term", "shoe");
528
+ expect(synonyms[0]).toHaveProperty("synonyms");
529
+ expect(synonyms[0].synonyms).toEqual(["sneaker", "trainer"]);
530
+ expect(synonyms[0]).toHaveProperty("createdAt");
531
+ });
532
+ });
533
+
534
+ // ── search with skip/limit pagination ──────────────────────────
535
+
536
+ describe("search — skip/limit pagination", () => {
537
+ beforeEach(async () => {
538
+ for (let i = 0; i < 10; i++) {
539
+ await controller.indexItem({
540
+ entityType: "product",
541
+ entityId: `paginated_${i}`,
542
+ title: `Alpha Item ${i}`,
543
+ url: `/products/alpha-${i}`,
544
+ });
545
+ }
546
+ });
547
+
548
+ it("limits results to specified limit", async () => {
549
+ const { results, total } = await controller.search("alpha", {
550
+ limit: 3,
551
+ });
552
+ expect(results).toHaveLength(3);
553
+ expect(total).toBe(10);
554
+ });
555
+
556
+ it("skips the specified number of results", async () => {
557
+ const { results: allResults } = await controller.search("alpha");
558
+ const { results: skipped } = await controller.search("alpha", {
559
+ skip: 3,
560
+ });
561
+
562
+ // Skipped results should not include the first 3
563
+ expect(skipped[0].item.entityId).toBe(allResults[3].item.entityId);
564
+ });
565
+
566
+ it("combines skip and limit correctly", async () => {
567
+ const { results: allResults, total: allTotal } =
568
+ await controller.search("alpha");
569
+ const { results: page, total } = await controller.search("alpha", {
570
+ skip: 2,
571
+ limit: 3,
572
+ });
573
+
574
+ expect(page).toHaveLength(3);
575
+ expect(total).toBe(allTotal);
576
+ expect(page[0].item.entityId).toBe(allResults[2].item.entityId);
577
+ expect(page[2].item.entityId).toBe(allResults[4].item.entityId);
578
+ });
579
+
580
+ it("returns fewer results when skip + limit exceeds total", async () => {
581
+ const { results, total } = await controller.search("alpha", {
582
+ skip: 8,
583
+ limit: 5,
584
+ });
585
+ expect(results).toHaveLength(2);
586
+ expect(total).toBe(10);
587
+ });
588
+
589
+ it("returns empty results when skip exceeds total", async () => {
590
+ const { results, total } = await controller.search("alpha", {
591
+ skip: 100,
592
+ });
593
+ expect(results).toHaveLength(0);
594
+ expect(total).toBe(10);
595
+ });
596
+
597
+ it("defaults limit to 20 and skip to 0", async () => {
598
+ const { results } = await controller.search("alpha");
599
+ expect(results).toHaveLength(10); // Only 10 items exist, well under default 20
600
+ });
601
+
602
+ it("returns correct total when paginated with entityType filter", async () => {
603
+ await controller.indexItem({
604
+ entityType: "blog",
605
+ entityId: "blog_alpha",
606
+ title: "Alpha Blog Post",
607
+ url: "/blog/alpha",
608
+ });
609
+
610
+ const { results, total } = await controller.search("alpha", {
611
+ entityType: "product",
612
+ limit: 3,
613
+ });
614
+ expect(results).toHaveLength(3);
615
+ expect(total).toBe(10); // only products
616
+ });
617
+ });
618
+
619
+ // ── suggest with custom limit ──────────────────────────────────
620
+
621
+ describe("suggest — custom limit", () => {
622
+ beforeEach(async () => {
623
+ for (let i = 0; i < 15; i++) {
624
+ await controller.recordQuery(`test query ${i}`, i + 1);
625
+ }
626
+ });
627
+
628
+ it("defaults to limit of 10", async () => {
629
+ const suggestions = await controller.suggest("test");
630
+ expect(suggestions.length).toBeLessThanOrEqual(10);
631
+ });
632
+
633
+ it("respects custom limit of 3", async () => {
634
+ const suggestions = await controller.suggest("test", 3);
635
+ expect(suggestions.length).toBeLessThanOrEqual(3);
636
+ });
637
+
638
+ it("returns fewer than limit when not enough matches exist", async () => {
639
+ const suggestions = await controller.suggest("test query 1", 50);
640
+ // Only items starting with "test query 1" match: "test query 1", "test query 10"..."test query 14"
641
+ expect(suggestions.length).toBeLessThan(50);
642
+ expect(suggestions.length).toBeGreaterThan(0);
643
+ });
644
+
645
+ it("returns empty for no prefix match", async () => {
646
+ const suggestions = await controller.suggest("zzznotfound", 5);
647
+ expect(suggestions).toHaveLength(0);
648
+ });
649
+
650
+ it("limit of 1 returns only top suggestion", async () => {
651
+ const suggestions = await controller.suggest("test", 1);
652
+ expect(suggestions).toHaveLength(1);
653
+ });
654
+
655
+ it("includes title suggestions when limit allows", async () => {
656
+ await controller.indexItem({
657
+ entityType: "product",
658
+ entityId: "title_suggest",
659
+ title: "Test Product Supreme",
660
+ url: "/p/test-supreme",
661
+ });
662
+
663
+ const suggestions = await controller.suggest("test", 20);
664
+ expect(suggestions).toContain("Test Product Supreme");
665
+ });
666
+ });
667
+
668
+ // ── removeFromIndex — non-existent entity ──────────────────────
669
+
670
+ describe("removeFromIndex — non-existent entity", () => {
671
+ it("returns false when entity does not exist", async () => {
672
+ const result = await controller.removeFromIndex(
673
+ "product",
674
+ "nonexistent_id",
675
+ );
676
+ expect(result).toBe(false);
677
+ });
678
+
679
+ it("returns false for wrong entityType with existing entityId", async () => {
680
+ await controller.indexItem({
681
+ entityType: "product",
682
+ entityId: "real_id",
683
+ title: "Real Product",
684
+ url: "/p/real",
685
+ });
686
+
687
+ const result = await controller.removeFromIndex("blog", "real_id");
688
+ expect(result).toBe(false);
689
+ // Original item should still exist
690
+ expect(await controller.getIndexCount()).toBe(1);
691
+ });
692
+
693
+ it("returns true when entity exists and is removed", async () => {
694
+ await controller.indexItem({
695
+ entityType: "product",
696
+ entityId: "to_remove",
697
+ title: "Removable",
698
+ url: "/p/remove",
699
+ });
700
+
701
+ const result = await controller.removeFromIndex("product", "to_remove");
702
+ expect(result).toBe(true);
703
+ expect(await controller.getIndexCount()).toBe(0);
704
+ });
705
+
706
+ it("returns false on second removal of same entity", async () => {
707
+ await controller.indexItem({
708
+ entityType: "product",
709
+ entityId: "once",
710
+ title: "Once",
711
+ url: "/p/once",
712
+ });
713
+
714
+ await controller.removeFromIndex("product", "once");
715
+ const secondRemoval = await controller.removeFromIndex("product", "once");
716
+ expect(secondRemoval).toBe(false);
717
+ });
718
+ });
719
+
720
+ // ── addSynonym — update existing ───────────────────────────────
721
+
722
+ describe("addSynonym — updating existing synonym", () => {
723
+ it("updates synonyms for an existing normalized term", async () => {
724
+ const first = await controller.addSynonym("shoe", ["sneaker", "trainer"]);
725
+ const updated = await controller.addSynonym("shoe", [
726
+ "boot",
727
+ "sandal",
728
+ "slipper",
729
+ ]);
730
+
731
+ expect(updated.id).toBe(first.id);
732
+ expect(updated.synonyms).toEqual(["boot", "sandal", "slipper"]);
733
+ });
734
+
735
+ it("preserves createdAt when updating", async () => {
736
+ const first = await controller.addSynonym("shoe", ["sneaker"]);
737
+ await new Promise((r) => setTimeout(r, 5));
738
+ const updated = await controller.addSynonym("shoe", ["boot"]);
739
+
740
+ expect(updated.createdAt).toEqual(first.createdAt);
741
+ });
742
+
743
+ it("does not create duplicate synonym entries", async () => {
744
+ await controller.addSynonym("shoe", ["sneaker"]);
745
+ await controller.addSynonym("shoe", ["boot"]);
746
+ await controller.addSynonym("shoe", ["sandal"]);
747
+
748
+ const synonyms = await controller.listSynonyms();
749
+ const shoeEntries = synonyms.filter((s) => s.term === "shoe");
750
+ expect(shoeEntries).toHaveLength(1);
751
+ });
752
+
753
+ it("treats term case-insensitively for dedup", async () => {
754
+ const first = await controller.addSynonym("Shoe", ["sneaker"]);
755
+ const second = await controller.addSynonym("shoe", ["boot"]);
756
+
757
+ // Both normalize to "shoe" so they should share the same ID
758
+ expect(second.id).toBe(first.id);
759
+ });
760
+
761
+ it("trims whitespace from synonym values", async () => {
762
+ const syn = await controller.addSynonym("shoe", [
763
+ " sneaker ",
764
+ "trainer ",
765
+ " boot",
766
+ ]);
767
+ expect(syn.synonyms).toEqual(["sneaker", "trainer", "boot"]);
768
+ });
769
+ });
770
+
771
+ // ── recordQuery — normalized term storage ──────────────────────
772
+
773
+ describe("recordQuery — normalized term storage", () => {
774
+ it("stores lowercase normalized term", async () => {
775
+ const query = await controller.recordQuery("Red SHOES", 5);
776
+ expect(query.normalizedTerm).toBe("red shoes");
777
+ });
778
+
779
+ it("trims and collapses whitespace in normalized term", async () => {
780
+ const query = await controller.recordQuery(" red shoes ", 5);
781
+ expect(query.normalizedTerm).toBe("red shoes");
782
+ });
783
+
784
+ it("preserves original term as-is", async () => {
785
+ const query = await controller.recordQuery(" Red SHOES ", 5);
786
+ expect(query.term).toBe(" Red SHOES ");
787
+ });
788
+
789
+ it("stores resultCount correctly", async () => {
790
+ const q = await controller.recordQuery("test", 42);
791
+ expect(q.resultCount).toBe(42);
792
+ });
793
+
794
+ it("stores zero resultCount", async () => {
795
+ const q = await controller.recordQuery("nothing", 0);
796
+ expect(q.resultCount).toBe(0);
797
+ });
798
+
799
+ it("stores sessionId when provided", async () => {
800
+ const q = await controller.recordQuery("test", 5, "sess_123");
801
+ expect(q.sessionId).toBe("sess_123");
802
+ });
803
+
804
+ it("sessionId is undefined when not provided", async () => {
805
+ const q = await controller.recordQuery("test", 5);
806
+ expect(q.sessionId).toBeUndefined();
807
+ });
808
+
809
+ it("generates unique IDs for each recorded query", async () => {
810
+ const q1 = await controller.recordQuery("test", 5);
811
+ const q2 = await controller.recordQuery("test", 5);
812
+ expect(q1.id).not.toBe(q2.id);
813
+ });
814
+
815
+ it("sets searchedAt timestamp", async () => {
816
+ const before = new Date();
817
+ const q = await controller.recordQuery("test", 5);
818
+ const after = new Date();
819
+
820
+ expect(new Date(q.searchedAt).getTime()).toBeGreaterThanOrEqual(
821
+ before.getTime(),
822
+ );
823
+ expect(new Date(q.searchedAt).getTime()).toBeLessThanOrEqual(
824
+ after.getTime(),
825
+ );
826
+ });
827
+ });
828
+
829
+ // ── getRecentQueries — custom limit ────────────────────────────
830
+
831
+ describe("getRecentQueries — custom limit", () => {
832
+ beforeEach(async () => {
833
+ for (let i = 0; i < 15; i++) {
834
+ await controller.recordQuery(`query_${i}`, i, "sess_limit");
835
+ await new Promise((r) => setTimeout(r, 2));
836
+ }
837
+ });
838
+
839
+ it("defaults to limit of 10", async () => {
840
+ const recent = await controller.getRecentQueries("sess_limit");
841
+ expect(recent).toHaveLength(10);
842
+ });
843
+
844
+ it("respects custom limit of 5", async () => {
845
+ const recent = await controller.getRecentQueries("sess_limit", 5);
846
+ expect(recent).toHaveLength(5);
847
+ });
848
+
849
+ it("returns all when limit exceeds available", async () => {
850
+ const recent = await controller.getRecentQueries("sess_limit", 50);
851
+ expect(recent).toHaveLength(15);
852
+ });
853
+
854
+ it("limit of 1 returns only most recent", async () => {
855
+ const recent = await controller.getRecentQueries("sess_limit", 1);
856
+ expect(recent).toHaveLength(1);
857
+ expect(recent[0].term).toBe("query_14");
858
+ });
859
+
860
+ it("deduplicates by normalized term keeping most recent", async () => {
861
+ await controller.recordQuery("Shoes", 5, "sess_dedup");
862
+ await new Promise((r) => setTimeout(r, 2));
863
+ await controller.recordQuery("shoes", 8, "sess_dedup");
864
+ await new Promise((r) => setTimeout(r, 2));
865
+ await controller.recordQuery("SHOES", 3, "sess_dedup");
866
+
867
+ const recent = await controller.getRecentQueries("sess_dedup");
868
+ const shoeEntries = recent.filter((q) => q.normalizedTerm === "shoes");
869
+ expect(shoeEntries).toHaveLength(1);
870
+ expect(shoeEntries[0].term).toBe("SHOES"); // most recent
871
+ });
872
+ });
873
+
874
+ // ── getAnalytics — mixed results ───────────────────────────────
875
+
876
+ describe("getAnalytics — mixed results", () => {
877
+ it("calculates correct stats with mixed zero and non-zero results", async () => {
878
+ await controller.recordQuery("found_a", 10);
879
+ await controller.recordQuery("found_b", 20);
880
+ await controller.recordQuery("miss_a", 0);
881
+ await controller.recordQuery("miss_b", 0);
882
+
883
+ const analytics = await controller.getAnalytics();
884
+ expect(analytics.totalQueries).toBe(4);
885
+ expect(analytics.uniqueTerms).toBe(4);
886
+ expect(analytics.zeroResultCount).toBe(2);
887
+ expect(analytics.zeroResultRate).toBe(50);
888
+ expect(analytics.avgResultCount).toBe(8); // Math.round(30/4) = 8
889
+ });
890
+
891
+ it("calculates zeroResultRate with uneven splits", async () => {
892
+ await controller.recordQuery("hit1", 5);
893
+ await controller.recordQuery("hit2", 10);
894
+ await controller.recordQuery("miss1", 0);
895
+
896
+ const analytics = await controller.getAnalytics();
897
+ expect(analytics.zeroResultRate).toBe(33); // Math.round(1/3 * 100) = 33
898
+ });
899
+
900
+ it("returns all zeros when no queries recorded", async () => {
901
+ const analytics = await controller.getAnalytics();
902
+ expect(analytics.totalQueries).toBe(0);
903
+ expect(analytics.uniqueTerms).toBe(0);
904
+ expect(analytics.avgResultCount).toBe(0);
905
+ expect(analytics.zeroResultCount).toBe(0);
906
+ expect(analytics.zeroResultRate).toBe(0);
907
+ });
908
+
909
+ it("0% zero-result rate when all queries have results", async () => {
910
+ await controller.recordQuery("good1", 5);
911
+ await controller.recordQuery("good2", 10);
912
+ await controller.recordQuery("good3", 1);
913
+
914
+ const analytics = await controller.getAnalytics();
915
+ expect(analytics.zeroResultRate).toBe(0);
916
+ expect(analytics.zeroResultCount).toBe(0);
917
+ });
918
+
919
+ it("counts repeated normalized terms as one unique term", async () => {
920
+ await controller.recordQuery("shoes", 10);
921
+ await controller.recordQuery("shoes", 0);
922
+ await controller.recordQuery("SHOES", 5);
923
+ await controller.recordQuery("hats", 3);
924
+ await controller.recordQuery("hats", 0);
925
+
926
+ const analytics = await controller.getAnalytics();
927
+ expect(analytics.totalQueries).toBe(5);
928
+ expect(analytics.uniqueTerms).toBe(2);
929
+ expect(analytics.zeroResultCount).toBe(2);
930
+ expect(analytics.zeroResultRate).toBe(40); // 2/5 * 100 = 40
931
+ expect(analytics.avgResultCount).toBe(4); // Math.round(18/5) = 4
932
+ });
933
+
934
+ it("handles single query with zero results", async () => {
935
+ await controller.recordQuery("lonely miss", 0);
936
+
937
+ const analytics = await controller.getAnalytics();
938
+ expect(analytics.totalQueries).toBe(1);
939
+ expect(analytics.uniqueTerms).toBe(1);
940
+ expect(analytics.avgResultCount).toBe(0);
941
+ expect(analytics.zeroResultCount).toBe(1);
942
+ expect(analytics.zeroResultRate).toBe(100);
943
+ });
944
+
945
+ it("handles single query with results", async () => {
946
+ await controller.recordQuery("lonely hit", 7);
947
+
948
+ const analytics = await controller.getAnalytics();
949
+ expect(analytics.totalQueries).toBe(1);
950
+ expect(analytics.uniqueTerms).toBe(1);
951
+ expect(analytics.avgResultCount).toBe(7);
952
+ expect(analytics.zeroResultCount).toBe(0);
953
+ expect(analytics.zeroResultRate).toBe(0);
954
+ });
955
+ });
956
+
957
+ // ── search — total accuracy when paginated ─────────────────────
958
+
959
+ describe("search — total reflects all matches regardless of pagination", () => {
960
+ beforeEach(async () => {
961
+ for (let i = 0; i < 8; i++) {
962
+ await controller.indexItem({
963
+ entityType: "product",
964
+ entityId: `beta_${i}`,
965
+ title: `Beta Widget ${i}`,
966
+ url: `/products/beta-${i}`,
967
+ });
968
+ }
969
+ });
970
+
971
+ it("total is consistent across different skip values", async () => {
972
+ const { total: t1 } = await controller.search("beta", { skip: 0 });
973
+ const { total: t2 } = await controller.search("beta", { skip: 3 });
974
+ const { total: t3 } = await controller.search("beta", { skip: 7 });
975
+ const { total: t4 } = await controller.search("beta", { skip: 100 });
976
+
977
+ expect(t1).toBe(8);
978
+ expect(t2).toBe(8);
979
+ expect(t3).toBe(8);
980
+ expect(t4).toBe(8);
981
+ });
982
+
983
+ it("total is consistent across different limit values", async () => {
984
+ const { total: t1 } = await controller.search("beta", { limit: 1 });
985
+ const { total: t2 } = await controller.search("beta", { limit: 5 });
986
+ const { total: t3 } = await controller.search("beta", { limit: 100 });
987
+
988
+ expect(t1).toBe(8);
989
+ expect(t2).toBe(8);
990
+ expect(t3).toBe(8);
991
+ });
992
+
993
+ it("paginating through all results yields complete set", async () => {
994
+ const allIds = new Set<string>();
995
+ let skip = 0;
996
+ const limit = 3;
997
+
998
+ while (true) {
999
+ const { results, total } = await controller.search("beta", {
1000
+ skip,
1001
+ limit,
1002
+ });
1003
+ expect(total).toBe(8);
1004
+ if (results.length === 0) break;
1005
+ for (const r of results) {
1006
+ allIds.add(r.item.entityId);
1007
+ }
1008
+ skip += limit;
1009
+ }
1010
+
1011
+ expect(allIds.size).toBe(8);
1012
+ });
1013
+
1014
+ it("no duplicate items across pages", async () => {
1015
+ const page1 = await controller.search("beta", { skip: 0, limit: 4 });
1016
+ const page2 = await controller.search("beta", { skip: 4, limit: 4 });
1017
+
1018
+ const page1Ids = page1.results.map((r) => r.item.entityId);
1019
+ const page2Ids = page2.results.map((r) => r.item.entityId);
1020
+
1021
+ for (const id of page1Ids) {
1022
+ expect(page2Ids).not.toContain(id);
1023
+ }
1024
+ });
1025
+ });
1026
+ });