@86d-app/search 0.0.23 → 0.0.25
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.
- package/dist/modules/search/src/__tests__/admin-settings.test.js +262 -0
- package/dist/modules/search/src/__tests__/controllers.test.js +853 -0
- package/dist/modules/search/src/__tests__/embedding-provider.test.js +150 -0
- package/dist/modules/search/src/__tests__/endpoint-security.test.js +250 -0
- package/dist/modules/search/src/__tests__/meilisearch-provider.test.js +318 -0
- package/dist/modules/search/src/__tests__/service-impl.test.js +703 -0
- package/dist/modules/search/src/__tests__/store-endpoints.test.js +295 -0
- package/dist/{admin/components/index.d.ts → modules/search/src/admin/components/index.jsx} +0 -1
- package/dist/modules/search/src/admin/components/search-analytics.jsx +230 -0
- package/dist/modules/search/src/admin/endpoints/analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/bulk-index.js +26 -0
- package/dist/modules/search/src/admin/endpoints/click-analytics.js +9 -0
- package/dist/modules/search/src/admin/endpoints/get-settings.js +97 -0
- package/dist/modules/search/src/admin/endpoints/index-manage.js +32 -0
- package/dist/modules/search/src/admin/endpoints/index.js +21 -0
- package/dist/modules/search/src/admin/endpoints/popular.js +11 -0
- package/dist/modules/search/src/admin/endpoints/synonyms.js +30 -0
- package/dist/modules/search/src/admin/endpoints/zero-results.js +11 -0
- package/dist/modules/search/src/embedding-provider.js +77 -0
- package/dist/modules/search/src/index.js +75 -0
- package/dist/modules/search/src/meilisearch-provider.js +138 -0
- package/dist/modules/search/src/schema.js +61 -0
- package/dist/modules/search/src/service-impl.js +770 -0
- package/dist/modules/search/src/service.js +1 -0
- package/dist/modules/search/src/store/components/_hooks.js +10 -0
- package/dist/modules/search/src/store/components/index.jsx +9 -0
- package/dist/modules/search/src/store/components/search-bar.jsx +91 -0
- package/dist/modules/search/src/store/components/search-page.jsx +17 -0
- package/dist/modules/search/src/store/components/search-results.jsx +51 -0
- package/dist/modules/search/src/store/endpoints/click.js +15 -0
- package/dist/modules/search/src/store/endpoints/index.js +12 -0
- package/dist/modules/search/src/store/endpoints/recent.js +18 -0
- package/dist/modules/search/src/store/endpoints/search.js +57 -0
- package/dist/modules/search/src/store/endpoints/store-search.js +33 -0
- package/dist/modules/search/src/store/endpoints/suggest.js +12 -0
- package/package.json +1 -1
- package/src/__tests__/admin-settings.test.ts +367 -0
- package/src/__tests__/store-endpoints.test.ts +392 -0
- package/src/admin/endpoints/get-settings.ts +77 -0
- package/dist/__tests__/controllers.test.d.ts +0 -2
- package/dist/__tests__/controllers.test.d.ts.map +0 -1
- package/dist/__tests__/embedding-provider.test.d.ts +0 -2
- package/dist/__tests__/embedding-provider.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint-security.test.d.ts +0 -2
- package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
- package/dist/__tests__/meilisearch-provider.test.d.ts +0 -2
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +0 -1
- package/dist/__tests__/service-impl.test.d.ts +0 -2
- package/dist/__tests__/service-impl.test.d.ts.map +0 -1
- package/dist/admin/components/index.d.ts.map +0 -1
- package/dist/admin/components/search-analytics.d.ts +0 -2
- package/dist/admin/components/search-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/analytics.d.ts +0 -15
- package/dist/admin/endpoints/analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/bulk-index.d.ts +0 -20
- package/dist/admin/endpoints/bulk-index.d.ts.map +0 -1
- package/dist/admin/endpoints/click-analytics.d.ts +0 -7
- package/dist/admin/endpoints/click-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/get-settings.d.ts +0 -17
- package/dist/admin/endpoints/get-settings.d.ts.map +0 -1
- package/dist/admin/endpoints/index-manage.d.ts +0 -26
- package/dist/admin/endpoints/index-manage.d.ts.map +0 -1
- package/dist/admin/endpoints/index.d.ts +0 -125
- package/dist/admin/endpoints/index.d.ts.map +0 -1
- package/dist/admin/endpoints/popular.d.ts +0 -10
- package/dist/admin/endpoints/popular.d.ts.map +0 -1
- package/dist/admin/endpoints/synonyms.d.ts +0 -30
- package/dist/admin/endpoints/synonyms.d.ts.map +0 -1
- package/dist/admin/endpoints/zero-results.d.ts +0 -10
- package/dist/admin/endpoints/zero-results.d.ts.map +0 -1
- package/dist/embedding-provider.d.ts +0 -28
- package/dist/embedding-provider.d.ts.map +0 -1
- package/dist/index.d.ts +0 -23
- package/dist/index.d.ts.map +0 -1
- package/dist/meilisearch-provider.d.ts +0 -104
- package/dist/meilisearch-provider.d.ts.map +0 -1
- package/dist/schema.d.ts +0 -133
- package/dist/schema.d.ts.map +0 -1
- package/dist/service-impl.d.ts +0 -6
- package/dist/service-impl.d.ts.map +0 -1
- package/dist/service.d.ts +0 -127
- package/dist/service.d.ts.map +0 -1
- package/dist/store/components/_hooks.d.ts +0 -6
- package/dist/store/components/_hooks.d.ts.map +0 -1
- package/dist/store/components/index.d.ts +0 -10
- package/dist/store/components/index.d.ts.map +0 -1
- package/dist/store/components/search-bar.d.ts +0 -7
- package/dist/store/components/search-bar.d.ts.map +0 -1
- package/dist/store/components/search-page.d.ts +0 -4
- package/dist/store/components/search-page.d.ts.map +0 -1
- package/dist/store/components/search-results.d.ts +0 -9
- package/dist/store/components/search-results.d.ts.map +0 -1
- package/dist/store/endpoints/click.d.ts +0 -14
- package/dist/store/endpoints/click.d.ts.map +0 -1
- package/dist/store/endpoints/index.d.ts +0 -85
- package/dist/store/endpoints/index.d.ts.map +0 -1
- package/dist/store/endpoints/recent.d.ts +0 -15
- package/dist/store/endpoints/recent.d.ts.map +0 -1
- package/dist/store/endpoints/search.d.ts +0 -36
- package/dist/store/endpoints/search.d.ts.map +0 -1
- package/dist/store/endpoints/store-search.d.ts +0 -16
- package/dist/store/endpoints/store-search.d.ts.map +0 -1
- package/dist/store/endpoints/suggest.d.ts +0 -11
- package/dist/store/endpoints/suggest.d.ts.map +0 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { createSearchController } from "../service-impl";
|
|
4
|
+
describe("createSearchController", () => {
|
|
5
|
+
let mockData;
|
|
6
|
+
let controller;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockData = createMockDataService();
|
|
9
|
+
controller = createSearchController(mockData);
|
|
10
|
+
});
|
|
11
|
+
// ── indexItem ────────────────────────────────────────────────────────
|
|
12
|
+
describe("indexItem", () => {
|
|
13
|
+
it("indexes a new item", async () => {
|
|
14
|
+
const item = await controller.indexItem({
|
|
15
|
+
entityType: "product",
|
|
16
|
+
entityId: "prod_1",
|
|
17
|
+
title: "Red T-Shirt",
|
|
18
|
+
body: "A comfortable cotton t-shirt in red",
|
|
19
|
+
tags: ["clothing", "t-shirt", "red"],
|
|
20
|
+
url: "/products/red-t-shirt",
|
|
21
|
+
image: "/images/red-tshirt.jpg",
|
|
22
|
+
});
|
|
23
|
+
expect(item.id).toBeDefined();
|
|
24
|
+
expect(item.entityType).toBe("product");
|
|
25
|
+
expect(item.entityId).toBe("prod_1");
|
|
26
|
+
expect(item.title).toBe("Red T-Shirt");
|
|
27
|
+
expect(item.tags).toEqual(["clothing", "t-shirt", "red"]);
|
|
28
|
+
expect(item.indexedAt).toBeInstanceOf(Date);
|
|
29
|
+
});
|
|
30
|
+
it("updates an existing indexed item", async () => {
|
|
31
|
+
await controller.indexItem({
|
|
32
|
+
entityType: "product",
|
|
33
|
+
entityId: "prod_1",
|
|
34
|
+
title: "Red T-Shirt",
|
|
35
|
+
url: "/products/red-t-shirt",
|
|
36
|
+
});
|
|
37
|
+
const updated = await controller.indexItem({
|
|
38
|
+
entityType: "product",
|
|
39
|
+
entityId: "prod_1",
|
|
40
|
+
title: "Updated Red T-Shirt",
|
|
41
|
+
url: "/products/red-t-shirt",
|
|
42
|
+
});
|
|
43
|
+
expect(updated.title).toBe("Updated Red T-Shirt");
|
|
44
|
+
const count = await controller.getIndexCount();
|
|
45
|
+
expect(count).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
it("defaults tags and metadata", async () => {
|
|
48
|
+
const item = await controller.indexItem({
|
|
49
|
+
entityType: "blog",
|
|
50
|
+
entityId: "post_1",
|
|
51
|
+
title: "Hello World",
|
|
52
|
+
url: "/blog/hello-world",
|
|
53
|
+
});
|
|
54
|
+
expect(item.tags).toEqual([]);
|
|
55
|
+
expect(item.metadata).toEqual({});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
// ── bulkIndex ────────────────────────────────────────────────────────
|
|
59
|
+
describe("bulkIndex", () => {
|
|
60
|
+
it("indexes multiple items at once", async () => {
|
|
61
|
+
const result = await controller.bulkIndex([
|
|
62
|
+
{
|
|
63
|
+
entityType: "product",
|
|
64
|
+
entityId: "p1",
|
|
65
|
+
title: "Item 1",
|
|
66
|
+
url: "/p1",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
entityType: "product",
|
|
70
|
+
entityId: "p2",
|
|
71
|
+
title: "Item 2",
|
|
72
|
+
url: "/p2",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
entityType: "product",
|
|
76
|
+
entityId: "p3",
|
|
77
|
+
title: "Item 3",
|
|
78
|
+
url: "/p3",
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
expect(result.indexed).toBe(3);
|
|
82
|
+
expect(result.errors).toBe(0);
|
|
83
|
+
const count = await controller.getIndexCount();
|
|
84
|
+
expect(count).toBe(3);
|
|
85
|
+
});
|
|
86
|
+
it("updates existing items in bulk", async () => {
|
|
87
|
+
await controller.indexItem({
|
|
88
|
+
entityType: "product",
|
|
89
|
+
entityId: "p1",
|
|
90
|
+
title: "Old Title",
|
|
91
|
+
url: "/p1",
|
|
92
|
+
});
|
|
93
|
+
const result = await controller.bulkIndex([
|
|
94
|
+
{
|
|
95
|
+
entityType: "product",
|
|
96
|
+
entityId: "p1",
|
|
97
|
+
title: "New Title",
|
|
98
|
+
url: "/p1",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
entityType: "product",
|
|
102
|
+
entityId: "p2",
|
|
103
|
+
title: "Item 2",
|
|
104
|
+
url: "/p2",
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
expect(result.indexed).toBe(2);
|
|
108
|
+
expect(result.errors).toBe(0);
|
|
109
|
+
const count = await controller.getIndexCount();
|
|
110
|
+
expect(count).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
// ── removeFromIndex ─────────────────────────────────────────────────
|
|
114
|
+
describe("removeFromIndex", () => {
|
|
115
|
+
it("removes an indexed item", async () => {
|
|
116
|
+
await controller.indexItem({
|
|
117
|
+
entityType: "product",
|
|
118
|
+
entityId: "prod_1",
|
|
119
|
+
title: "Red T-Shirt",
|
|
120
|
+
url: "/products/red-t-shirt",
|
|
121
|
+
});
|
|
122
|
+
const removed = await controller.removeFromIndex("product", "prod_1");
|
|
123
|
+
expect(removed).toBe(true);
|
|
124
|
+
const count = await controller.getIndexCount();
|
|
125
|
+
expect(count).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
it("returns false for non-existent item", async () => {
|
|
128
|
+
const removed = await controller.removeFromIndex("product", "missing");
|
|
129
|
+
expect(removed).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
// ── search ──────────────────────────────────────────────────────────
|
|
133
|
+
describe("search", () => {
|
|
134
|
+
beforeEach(async () => {
|
|
135
|
+
await controller.indexItem({
|
|
136
|
+
entityType: "product",
|
|
137
|
+
entityId: "prod_1",
|
|
138
|
+
title: "Red T-Shirt",
|
|
139
|
+
body: "Comfortable cotton t-shirt",
|
|
140
|
+
tags: ["clothing", "red"],
|
|
141
|
+
url: "/products/red-t-shirt",
|
|
142
|
+
});
|
|
143
|
+
await controller.indexItem({
|
|
144
|
+
entityType: "product",
|
|
145
|
+
entityId: "prod_2",
|
|
146
|
+
title: "Blue Jeans",
|
|
147
|
+
body: "Classic denim jeans",
|
|
148
|
+
tags: ["clothing", "blue", "denim"],
|
|
149
|
+
url: "/products/blue-jeans",
|
|
150
|
+
});
|
|
151
|
+
await controller.indexItem({
|
|
152
|
+
entityType: "blog",
|
|
153
|
+
entityId: "post_1",
|
|
154
|
+
title: "How to Style T-Shirts",
|
|
155
|
+
body: "Tips for styling your favorite t-shirts",
|
|
156
|
+
tags: ["fashion", "tips"],
|
|
157
|
+
url: "/blog/style-t-shirts",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it("returns matching results sorted by score", async () => {
|
|
161
|
+
const { results, total } = await controller.search("t-shirt");
|
|
162
|
+
expect(total).toBeGreaterThan(0);
|
|
163
|
+
expect(results[0].item.title).toContain("T-Shirt");
|
|
164
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
165
|
+
});
|
|
166
|
+
it("returns empty results for non-matching query", async () => {
|
|
167
|
+
const { results, total } = await controller.search("nonexistent", {
|
|
168
|
+
fuzzy: false,
|
|
169
|
+
});
|
|
170
|
+
expect(results).toHaveLength(0);
|
|
171
|
+
expect(total).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
it("returns empty results for empty query", async () => {
|
|
174
|
+
const { results, total } = await controller.search(" ");
|
|
175
|
+
expect(results).toHaveLength(0);
|
|
176
|
+
expect(total).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
it("filters by entityType", async () => {
|
|
179
|
+
const { results } = await controller.search("t-shirt", {
|
|
180
|
+
entityType: "product",
|
|
181
|
+
});
|
|
182
|
+
for (const r of results) {
|
|
183
|
+
expect(r.item.entityType).toBe("product");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
it("supports pagination with limit and skip", async () => {
|
|
187
|
+
const { results: page1 } = await controller.search("clothing", {
|
|
188
|
+
limit: 1,
|
|
189
|
+
skip: 0,
|
|
190
|
+
});
|
|
191
|
+
const { results: page2 } = await controller.search("clothing", {
|
|
192
|
+
limit: 1,
|
|
193
|
+
skip: 1,
|
|
194
|
+
});
|
|
195
|
+
expect(page1).toHaveLength(1);
|
|
196
|
+
if (page2.length > 0) {
|
|
197
|
+
expect(page1[0].item.id).not.toBe(page2[0].item.id);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
it("matches on tags", async () => {
|
|
201
|
+
const { results } = await controller.search("denim");
|
|
202
|
+
expect(results.length).toBeGreaterThan(0);
|
|
203
|
+
expect(results[0].item.entityId).toBe("prod_2");
|
|
204
|
+
});
|
|
205
|
+
it("matches on body content", async () => {
|
|
206
|
+
const { results } = await controller.search("cotton");
|
|
207
|
+
expect(results.length).toBeGreaterThan(0);
|
|
208
|
+
expect(results[0].item.entityId).toBe("prod_1");
|
|
209
|
+
});
|
|
210
|
+
it("ranks title matches higher than body matches", async () => {
|
|
211
|
+
const { results } = await controller.search("red");
|
|
212
|
+
expect(results.length).toBeGreaterThan(0);
|
|
213
|
+
expect(results[0].item.entityId).toBe("prod_1");
|
|
214
|
+
});
|
|
215
|
+
it("returns facets with results", async () => {
|
|
216
|
+
const { facets } = await controller.search("clothing");
|
|
217
|
+
expect(facets.entityTypes.length).toBeGreaterThan(0);
|
|
218
|
+
expect(facets.entityTypes[0].type).toBe("product");
|
|
219
|
+
expect(facets.tags.length).toBeGreaterThan(0);
|
|
220
|
+
});
|
|
221
|
+
it("returns highlights in results", async () => {
|
|
222
|
+
const { results } = await controller.search("red");
|
|
223
|
+
expect(results.length).toBeGreaterThan(0);
|
|
224
|
+
const highlight = results[0].highlights;
|
|
225
|
+
expect(highlight?.title).toContain("<mark>");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// ── fuzzy search ───────────────────────────────────────────────────
|
|
229
|
+
describe("fuzzy search", () => {
|
|
230
|
+
beforeEach(async () => {
|
|
231
|
+
await controller.indexItem({
|
|
232
|
+
entityType: "product",
|
|
233
|
+
entityId: "prod_1",
|
|
234
|
+
title: "Running Shoes",
|
|
235
|
+
tags: ["footwear", "athletic"],
|
|
236
|
+
url: "/products/running-shoes",
|
|
237
|
+
});
|
|
238
|
+
await controller.indexItem({
|
|
239
|
+
entityType: "product",
|
|
240
|
+
entityId: "prod_2",
|
|
241
|
+
title: "Leather Boots",
|
|
242
|
+
tags: ["footwear", "winter"],
|
|
243
|
+
url: "/products/leather-boots",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
it("finds results with typos when fuzzy is enabled", async () => {
|
|
247
|
+
const { results } = await controller.search("runnign", {
|
|
248
|
+
fuzzy: true,
|
|
249
|
+
});
|
|
250
|
+
expect(results.length).toBeGreaterThan(0);
|
|
251
|
+
expect(results[0].item.entityId).toBe("prod_1");
|
|
252
|
+
});
|
|
253
|
+
it("does not find typo results when fuzzy is disabled", async () => {
|
|
254
|
+
const { results } = await controller.search("runnign", {
|
|
255
|
+
fuzzy: false,
|
|
256
|
+
});
|
|
257
|
+
expect(results).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
it("fuzzy matches short words only with exact match", async () => {
|
|
260
|
+
// Words <= 3 chars get no fuzzy tolerance
|
|
261
|
+
const { results } = await controller.search("ren", {
|
|
262
|
+
fuzzy: true,
|
|
263
|
+
});
|
|
264
|
+
// "ren" is too short for fuzzy to match "run" (distance 2)
|
|
265
|
+
expect(results).toHaveLength(0);
|
|
266
|
+
});
|
|
267
|
+
it("fuzzy matches medium words with 1 edit distance", async () => {
|
|
268
|
+
const { results } = await controller.search("shoez", {
|
|
269
|
+
fuzzy: true,
|
|
270
|
+
});
|
|
271
|
+
expect(results.length).toBeGreaterThan(0);
|
|
272
|
+
});
|
|
273
|
+
it("fuzzy matches longer words with 2 edit distance", async () => {
|
|
274
|
+
const { results } = await controller.search("leathor", {
|
|
275
|
+
fuzzy: true,
|
|
276
|
+
});
|
|
277
|
+
expect(results.length).toBeGreaterThan(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
// ── tag filtering ──────────────────────────────────────────────────
|
|
281
|
+
describe("tag filtering", () => {
|
|
282
|
+
beforeEach(async () => {
|
|
283
|
+
await controller.indexItem({
|
|
284
|
+
entityType: "product",
|
|
285
|
+
entityId: "p1",
|
|
286
|
+
title: "Red Sneakers",
|
|
287
|
+
tags: ["shoes", "red", "sport"],
|
|
288
|
+
url: "/p1",
|
|
289
|
+
});
|
|
290
|
+
await controller.indexItem({
|
|
291
|
+
entityType: "product",
|
|
292
|
+
entityId: "p2",
|
|
293
|
+
title: "Red Jacket",
|
|
294
|
+
tags: ["outerwear", "red", "winter"],
|
|
295
|
+
url: "/p2",
|
|
296
|
+
});
|
|
297
|
+
await controller.indexItem({
|
|
298
|
+
entityType: "product",
|
|
299
|
+
entityId: "p3",
|
|
300
|
+
title: "Blue Sneakers",
|
|
301
|
+
tags: ["shoes", "blue", "sport"],
|
|
302
|
+
url: "/p3",
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
it("filters results by tags", async () => {
|
|
306
|
+
const { results } = await controller.search("sneakers", {
|
|
307
|
+
tags: ["red"],
|
|
308
|
+
});
|
|
309
|
+
expect(results).toHaveLength(1);
|
|
310
|
+
expect(results[0].item.entityId).toBe("p1");
|
|
311
|
+
});
|
|
312
|
+
it("returns all matching results without tag filter", async () => {
|
|
313
|
+
const { results } = await controller.search("red");
|
|
314
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
315
|
+
});
|
|
316
|
+
it("returns empty when no items match tag filter", async () => {
|
|
317
|
+
const { results } = await controller.search("sneakers", {
|
|
318
|
+
tags: ["winter"],
|
|
319
|
+
});
|
|
320
|
+
expect(results).toHaveLength(0);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
// ── sorting ────────────────────────────────────────────────────────
|
|
324
|
+
describe("sorting", () => {
|
|
325
|
+
beforeEach(async () => {
|
|
326
|
+
await controller.indexItem({
|
|
327
|
+
entityType: "product",
|
|
328
|
+
entityId: "p1",
|
|
329
|
+
title: "Alpha Widget",
|
|
330
|
+
tags: ["widget"],
|
|
331
|
+
url: "/p1",
|
|
332
|
+
});
|
|
333
|
+
// Add a small delay to ensure different timestamps
|
|
334
|
+
await controller.indexItem({
|
|
335
|
+
entityType: "product",
|
|
336
|
+
entityId: "p2",
|
|
337
|
+
title: "Beta Widget",
|
|
338
|
+
tags: ["widget"],
|
|
339
|
+
url: "/p2",
|
|
340
|
+
});
|
|
341
|
+
await controller.indexItem({
|
|
342
|
+
entityType: "product",
|
|
343
|
+
entityId: "p3",
|
|
344
|
+
title: "Charlie Widget",
|
|
345
|
+
tags: ["widget"],
|
|
346
|
+
url: "/p3",
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
it("sorts by title ascending", async () => {
|
|
350
|
+
const { results } = await controller.search("widget", {
|
|
351
|
+
sort: "title_asc",
|
|
352
|
+
});
|
|
353
|
+
expect(results[0].item.title).toBe("Alpha Widget");
|
|
354
|
+
expect(results[results.length - 1].item.title).toBe("Charlie Widget");
|
|
355
|
+
});
|
|
356
|
+
it("sorts by title descending", async () => {
|
|
357
|
+
const { results } = await controller.search("widget", {
|
|
358
|
+
sort: "title_desc",
|
|
359
|
+
});
|
|
360
|
+
expect(results[0].item.title).toBe("Charlie Widget");
|
|
361
|
+
expect(results[results.length - 1].item.title).toBe("Alpha Widget");
|
|
362
|
+
});
|
|
363
|
+
it("defaults to relevance sorting", async () => {
|
|
364
|
+
const { results } = await controller.search("widget");
|
|
365
|
+
// All have same relevance, so order is stable
|
|
366
|
+
expect(results.length).toBe(3);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
// ── did-you-mean ───────────────────────────────────────────────────
|
|
370
|
+
describe("did-you-mean", () => {
|
|
371
|
+
beforeEach(async () => {
|
|
372
|
+
await controller.indexItem({
|
|
373
|
+
entityType: "product",
|
|
374
|
+
entityId: "p1",
|
|
375
|
+
title: "Running Shoes",
|
|
376
|
+
url: "/p1",
|
|
377
|
+
});
|
|
378
|
+
await controller.indexItem({
|
|
379
|
+
entityType: "product",
|
|
380
|
+
entityId: "p2",
|
|
381
|
+
title: "Leather Boots",
|
|
382
|
+
url: "/p2",
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
it("suggests correction for misspelled query with zero results", async () => {
|
|
386
|
+
// "bootes" is close to "boots" (dist 1), won't substring-match "Running Shoes"
|
|
387
|
+
const { didYouMean } = await controller.search("bootes", {
|
|
388
|
+
fuzzy: false,
|
|
389
|
+
});
|
|
390
|
+
expect(didYouMean).toBeDefined();
|
|
391
|
+
expect(didYouMean).toBe("boots");
|
|
392
|
+
});
|
|
393
|
+
it("does not suggest correction when results are found", async () => {
|
|
394
|
+
const { didYouMean } = await controller.search("running");
|
|
395
|
+
expect(didYouMean).toBeUndefined();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
// ── synonym expansion ───────────────────────────────────────────────
|
|
399
|
+
describe("search with synonyms", () => {
|
|
400
|
+
beforeEach(async () => {
|
|
401
|
+
await controller.indexItem({
|
|
402
|
+
entityType: "product",
|
|
403
|
+
entityId: "prod_1",
|
|
404
|
+
title: "T-Shirt",
|
|
405
|
+
url: "/products/t-shirt",
|
|
406
|
+
});
|
|
407
|
+
await controller.addSynonym("tee", ["t-shirt", "tshirt"]);
|
|
408
|
+
});
|
|
409
|
+
it("expands query with synonyms", async () => {
|
|
410
|
+
const { results } = await controller.search("tee");
|
|
411
|
+
expect(results.length).toBeGreaterThan(0);
|
|
412
|
+
expect(results[0].item.entityId).toBe("prod_1");
|
|
413
|
+
});
|
|
414
|
+
it("also expands in reverse (synonym → term)", async () => {
|
|
415
|
+
await controller.indexItem({
|
|
416
|
+
entityType: "product",
|
|
417
|
+
entityId: "prod_2",
|
|
418
|
+
title: "Tee Collection",
|
|
419
|
+
url: "/products/tee-collection",
|
|
420
|
+
});
|
|
421
|
+
const { results } = await controller.search("tshirt");
|
|
422
|
+
expect(results.length).toBeGreaterThan(0);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
// ── suggest ─────────────────────────────────────────────────────────
|
|
426
|
+
describe("suggest", () => {
|
|
427
|
+
beforeEach(async () => {
|
|
428
|
+
await controller.indexItem({
|
|
429
|
+
entityType: "product",
|
|
430
|
+
entityId: "prod_1",
|
|
431
|
+
title: "Red T-Shirt",
|
|
432
|
+
url: "/products/red-t-shirt",
|
|
433
|
+
});
|
|
434
|
+
await controller.indexItem({
|
|
435
|
+
entityType: "product",
|
|
436
|
+
entityId: "prod_2",
|
|
437
|
+
title: "Red Sneakers",
|
|
438
|
+
url: "/products/red-sneakers",
|
|
439
|
+
});
|
|
440
|
+
await controller.recordQuery("red t-shirt", 5);
|
|
441
|
+
await controller.recordQuery("red t-shirt", 3);
|
|
442
|
+
await controller.recordQuery("red sneakers", 2);
|
|
443
|
+
});
|
|
444
|
+
it("returns suggestions matching prefix", async () => {
|
|
445
|
+
const suggestions = await controller.suggest("red");
|
|
446
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
447
|
+
for (const s of suggestions) {
|
|
448
|
+
expect(s.toLowerCase()).toContain("red");
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
it("prioritizes popular queries over title matches", async () => {
|
|
452
|
+
const suggestions = await controller.suggest("red");
|
|
453
|
+
expect(suggestions[0].toLowerCase()).toContain("red t-shirt");
|
|
454
|
+
});
|
|
455
|
+
it("respects limit", async () => {
|
|
456
|
+
const suggestions = await controller.suggest("red", 1);
|
|
457
|
+
expect(suggestions).toHaveLength(1);
|
|
458
|
+
});
|
|
459
|
+
it("returns empty for empty prefix", async () => {
|
|
460
|
+
const suggestions = await controller.suggest("");
|
|
461
|
+
expect(suggestions).toHaveLength(0);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
// ── recordQuery ─────────────────────────────────────────────────────
|
|
465
|
+
describe("recordQuery", () => {
|
|
466
|
+
it("records a search query", async () => {
|
|
467
|
+
const query = await controller.recordQuery("red shoes", 10, "sess_1");
|
|
468
|
+
expect(query.id).toBeDefined();
|
|
469
|
+
expect(query.term).toBe("red shoes");
|
|
470
|
+
expect(query.normalizedTerm).toBe("red shoes");
|
|
471
|
+
expect(query.resultCount).toBe(10);
|
|
472
|
+
expect(query.sessionId).toBe("sess_1");
|
|
473
|
+
expect(query.searchedAt).toBeInstanceOf(Date);
|
|
474
|
+
});
|
|
475
|
+
it("records without sessionId", async () => {
|
|
476
|
+
const query = await controller.recordQuery("blue hat", 5);
|
|
477
|
+
expect(query.sessionId).toBeUndefined();
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
// ── recordClick ─────────────────────────────────────────────────────
|
|
481
|
+
describe("recordClick", () => {
|
|
482
|
+
it("records a search result click", async () => {
|
|
483
|
+
const query = await controller.recordQuery("shoes", 10, "sess_1");
|
|
484
|
+
const click = await controller.recordClick({
|
|
485
|
+
queryId: query.id,
|
|
486
|
+
term: "shoes",
|
|
487
|
+
entityType: "product",
|
|
488
|
+
entityId: "prod_1",
|
|
489
|
+
position: 0,
|
|
490
|
+
});
|
|
491
|
+
expect(click.id).toBeDefined();
|
|
492
|
+
expect(click.queryId).toBe(query.id);
|
|
493
|
+
expect(click.term).toBe("shoes");
|
|
494
|
+
expect(click.position).toBe(0);
|
|
495
|
+
expect(click.clickedAt).toBeInstanceOf(Date);
|
|
496
|
+
});
|
|
497
|
+
it("records multiple clicks for different positions", async () => {
|
|
498
|
+
const query = await controller.recordQuery("shoes", 10);
|
|
499
|
+
const click1 = await controller.recordClick({
|
|
500
|
+
queryId: query.id,
|
|
501
|
+
term: "shoes",
|
|
502
|
+
entityType: "product",
|
|
503
|
+
entityId: "prod_1",
|
|
504
|
+
position: 0,
|
|
505
|
+
});
|
|
506
|
+
const click2 = await controller.recordClick({
|
|
507
|
+
queryId: query.id,
|
|
508
|
+
term: "shoes",
|
|
509
|
+
entityType: "product",
|
|
510
|
+
entityId: "prod_2",
|
|
511
|
+
position: 1,
|
|
512
|
+
});
|
|
513
|
+
expect(click1.id).not.toBe(click2.id);
|
|
514
|
+
expect(click1.position).toBe(0);
|
|
515
|
+
expect(click2.position).toBe(1);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
// ── getRecentQueries ────────────────────────────────────────────────
|
|
519
|
+
describe("getRecentQueries", () => {
|
|
520
|
+
it("returns recent queries for a session", async () => {
|
|
521
|
+
await controller.recordQuery("shoes", 10, "sess_1");
|
|
522
|
+
await controller.recordQuery("hats", 5, "sess_1");
|
|
523
|
+
await controller.recordQuery("bags", 3, "sess_2");
|
|
524
|
+
const recent = await controller.getRecentQueries("sess_1");
|
|
525
|
+
expect(recent).toHaveLength(2);
|
|
526
|
+
});
|
|
527
|
+
it("deduplicates by normalized term", async () => {
|
|
528
|
+
await controller.recordQuery("Red Shoes", 10, "sess_1");
|
|
529
|
+
await controller.recordQuery("red shoes", 8, "sess_1");
|
|
530
|
+
const recent = await controller.getRecentQueries("sess_1");
|
|
531
|
+
expect(recent).toHaveLength(1);
|
|
532
|
+
});
|
|
533
|
+
it("respects limit", async () => {
|
|
534
|
+
for (let i = 0; i < 5; i++) {
|
|
535
|
+
await controller.recordQuery(`term_${i}`, i, "sess_1");
|
|
536
|
+
}
|
|
537
|
+
const recent = await controller.getRecentQueries("sess_1", 3);
|
|
538
|
+
expect(recent).toHaveLength(3);
|
|
539
|
+
});
|
|
540
|
+
it("returns empty for unknown session", async () => {
|
|
541
|
+
const recent = await controller.getRecentQueries("unknown");
|
|
542
|
+
expect(recent).toHaveLength(0);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
// ── getPopularTerms ─────────────────────────────────────────────────
|
|
546
|
+
describe("getPopularTerms", () => {
|
|
547
|
+
it("returns terms sorted by frequency", async () => {
|
|
548
|
+
await controller.recordQuery("shoes", 10);
|
|
549
|
+
await controller.recordQuery("shoes", 8);
|
|
550
|
+
await controller.recordQuery("shoes", 12);
|
|
551
|
+
await controller.recordQuery("hats", 5);
|
|
552
|
+
await controller.recordQuery("hats", 3);
|
|
553
|
+
await controller.recordQuery("bags", 1);
|
|
554
|
+
const popular = await controller.getPopularTerms();
|
|
555
|
+
expect(popular[0].term).toBe("shoes");
|
|
556
|
+
expect(popular[0].count).toBe(3);
|
|
557
|
+
expect(popular[0].avgResultCount).toBe(10);
|
|
558
|
+
expect(popular[1].term).toBe("hats");
|
|
559
|
+
expect(popular[1].count).toBe(2);
|
|
560
|
+
});
|
|
561
|
+
it("respects limit", async () => {
|
|
562
|
+
for (let i = 0; i < 10; i++) {
|
|
563
|
+
await controller.recordQuery(`term_${i}`, i);
|
|
564
|
+
}
|
|
565
|
+
const popular = await controller.getPopularTerms(5);
|
|
566
|
+
expect(popular).toHaveLength(5);
|
|
567
|
+
});
|
|
568
|
+
it("returns empty when no queries", async () => {
|
|
569
|
+
const popular = await controller.getPopularTerms();
|
|
570
|
+
expect(popular).toHaveLength(0);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
// ── getZeroResultQueries ────────────────────────────────────────────
|
|
574
|
+
describe("getZeroResultQueries", () => {
|
|
575
|
+
it("returns only zero-result queries", async () => {
|
|
576
|
+
await controller.recordQuery("existing", 10);
|
|
577
|
+
await controller.recordQuery("missing", 0);
|
|
578
|
+
await controller.recordQuery("missing", 0);
|
|
579
|
+
await controller.recordQuery("also missing", 0);
|
|
580
|
+
const zero = await controller.getZeroResultQueries();
|
|
581
|
+
expect(zero).toHaveLength(2);
|
|
582
|
+
expect(zero[0].term).toBe("missing");
|
|
583
|
+
expect(zero[0].count).toBe(2);
|
|
584
|
+
expect(zero[0].avgResultCount).toBe(0);
|
|
585
|
+
});
|
|
586
|
+
it("returns empty when all queries have results", async () => {
|
|
587
|
+
await controller.recordQuery("shoes", 10);
|
|
588
|
+
const zero = await controller.getZeroResultQueries();
|
|
589
|
+
expect(zero).toHaveLength(0);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
// ── getAnalytics ────────────────────────────────────────────────────
|
|
593
|
+
describe("getAnalytics", () => {
|
|
594
|
+
it("returns analytics summary", async () => {
|
|
595
|
+
await controller.recordQuery("shoes", 10);
|
|
596
|
+
await controller.recordQuery("shoes", 8);
|
|
597
|
+
await controller.recordQuery("hats", 0);
|
|
598
|
+
const analytics = await controller.getAnalytics();
|
|
599
|
+
expect(analytics.totalQueries).toBe(3);
|
|
600
|
+
expect(analytics.uniqueTerms).toBe(2);
|
|
601
|
+
expect(analytics.avgResultCount).toBe(6);
|
|
602
|
+
expect(analytics.zeroResultCount).toBe(1);
|
|
603
|
+
expect(analytics.zeroResultRate).toBe(33);
|
|
604
|
+
expect(analytics.clickThroughRate).toBe(0);
|
|
605
|
+
expect(analytics.avgClickPosition).toBe(0);
|
|
606
|
+
});
|
|
607
|
+
it("returns zeros when no queries", async () => {
|
|
608
|
+
const analytics = await controller.getAnalytics();
|
|
609
|
+
expect(analytics.totalQueries).toBe(0);
|
|
610
|
+
expect(analytics.uniqueTerms).toBe(0);
|
|
611
|
+
expect(analytics.avgResultCount).toBe(0);
|
|
612
|
+
expect(analytics.zeroResultCount).toBe(0);
|
|
613
|
+
expect(analytics.zeroResultRate).toBe(0);
|
|
614
|
+
expect(analytics.clickThroughRate).toBe(0);
|
|
615
|
+
expect(analytics.avgClickPosition).toBe(0);
|
|
616
|
+
});
|
|
617
|
+
it("computes click-through rate and avg position", async () => {
|
|
618
|
+
const q1 = await controller.recordQuery("shoes", 10);
|
|
619
|
+
const q2 = await controller.recordQuery("hats", 5);
|
|
620
|
+
await controller.recordQuery("bags", 3);
|
|
621
|
+
await controller.recordClick({
|
|
622
|
+
queryId: q1.id,
|
|
623
|
+
term: "shoes",
|
|
624
|
+
entityType: "product",
|
|
625
|
+
entityId: "p1",
|
|
626
|
+
position: 0,
|
|
627
|
+
});
|
|
628
|
+
await controller.recordClick({
|
|
629
|
+
queryId: q2.id,
|
|
630
|
+
term: "hats",
|
|
631
|
+
entityType: "product",
|
|
632
|
+
entityId: "p2",
|
|
633
|
+
position: 2,
|
|
634
|
+
});
|
|
635
|
+
const analytics = await controller.getAnalytics();
|
|
636
|
+
// 2 queries with clicks out of 3 queries with results = 67%
|
|
637
|
+
expect(analytics.clickThroughRate).toBe(67);
|
|
638
|
+
// avg position: (0 + 2) / 2 = 1
|
|
639
|
+
expect(analytics.avgClickPosition).toBe(1);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
// ── synonyms ────────────────────────────────────────────────────────
|
|
643
|
+
describe("synonyms", () => {
|
|
644
|
+
it("adds a synonym", async () => {
|
|
645
|
+
const synonym = await controller.addSynonym("tee", ["t-shirt", "tshirt"]);
|
|
646
|
+
expect(synonym.id).toBeDefined();
|
|
647
|
+
expect(synonym.term).toBe("tee");
|
|
648
|
+
expect(synonym.synonyms).toEqual(["t-shirt", "tshirt"]);
|
|
649
|
+
expect(synonym.createdAt).toBeInstanceOf(Date);
|
|
650
|
+
});
|
|
651
|
+
it("updates existing synonym for same term", async () => {
|
|
652
|
+
await controller.addSynonym("tee", ["t-shirt"]);
|
|
653
|
+
const updated = await controller.addSynonym("tee", [
|
|
654
|
+
"t-shirt",
|
|
655
|
+
"tshirt",
|
|
656
|
+
"shirt",
|
|
657
|
+
]);
|
|
658
|
+
expect(updated.synonyms).toEqual(["t-shirt", "tshirt", "shirt"]);
|
|
659
|
+
const all = await controller.listSynonyms();
|
|
660
|
+
expect(all).toHaveLength(1);
|
|
661
|
+
});
|
|
662
|
+
it("removes a synonym", async () => {
|
|
663
|
+
const synonym = await controller.addSynonym("tee", ["t-shirt"]);
|
|
664
|
+
const removed = await controller.removeSynonym(synonym.id);
|
|
665
|
+
expect(removed).toBe(true);
|
|
666
|
+
const all = await controller.listSynonyms();
|
|
667
|
+
expect(all).toHaveLength(0);
|
|
668
|
+
});
|
|
669
|
+
it("returns false when removing non-existent synonym", async () => {
|
|
670
|
+
const removed = await controller.removeSynonym("missing");
|
|
671
|
+
expect(removed).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
it("lists all synonyms", async () => {
|
|
674
|
+
await controller.addSynonym("tee", ["t-shirt"]);
|
|
675
|
+
await controller.addSynonym("sneaker", ["shoe", "trainer"]);
|
|
676
|
+
const all = await controller.listSynonyms();
|
|
677
|
+
expect(all).toHaveLength(2);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
// ── getIndexCount ───────────────────────────────────────────────────
|
|
681
|
+
describe("getIndexCount", () => {
|
|
682
|
+
it("returns 0 when empty", async () => {
|
|
683
|
+
const count = await controller.getIndexCount();
|
|
684
|
+
expect(count).toBe(0);
|
|
685
|
+
});
|
|
686
|
+
it("returns correct count", async () => {
|
|
687
|
+
await controller.indexItem({
|
|
688
|
+
entityType: "product",
|
|
689
|
+
entityId: "prod_1",
|
|
690
|
+
title: "Item 1",
|
|
691
|
+
url: "/1",
|
|
692
|
+
});
|
|
693
|
+
await controller.indexItem({
|
|
694
|
+
entityType: "product",
|
|
695
|
+
entityId: "prod_2",
|
|
696
|
+
title: "Item 2",
|
|
697
|
+
url: "/2",
|
|
698
|
+
});
|
|
699
|
+
const count = await controller.getIndexCount();
|
|
700
|
+
expect(count).toBe(2);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
});
|