@86d-app/search 0.0.3

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/README.md ADDED
@@ -0,0 +1,138 @@
1
+ <p align="center">
2
+ <a href="https://86d.app">
3
+ <img src="https://86d.app/logo" height="96" alt="86d" />
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ Dynamic Commerce
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://vercel.com/changelog"><strong>npm</strong></a> ·
13
+ <a href="https://x.com/86d_app"><strong>X</strong></a> ·
14
+ <a href="https://vercel.com/templates"><strong>LinkedIn</strong></a>
15
+ </p>
16
+ <br/>
17
+
18
+ > [!WARNING]
19
+ > This project is under active development and is not ready for production use. Please proceed with caution. Use at your own risk.
20
+
21
+ # @86d-app/search
22
+
23
+ Unified search, autocomplete, and search analytics module for 86d commerce platform.
24
+
25
+ ## Installation
26
+
27
+ ```sh
28
+ npm install @86d-app/search
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```ts
34
+ import search from "@86d-app/search";
35
+
36
+ const module = search({
37
+ maxResults: 100,
38
+ });
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ | Option | Type | Default | Description |
44
+ |---|---|---|---|
45
+ | `maxResults` | `number` | — | Maximum number of results per query |
46
+
47
+ ## Store Endpoints
48
+
49
+ | Method | Path | Description |
50
+ |---|---|---|
51
+ | `GET` | `/search?q=...&type=...&limit=...&skip=...` | Full-text search with optional entity type filtering |
52
+ | `GET` | `/search/suggest?prefix=...&limit=...` | Autocomplete suggestions |
53
+ | `GET` | `/search/recent?sessionId=...&limit=...` | Recent search queries by session |
54
+
55
+ ## Admin Endpoints
56
+
57
+ | Method | Path | Description |
58
+ |---|---|---|
59
+ | `GET` | `/admin/search/analytics` | Search analytics summary |
60
+ | `GET` | `/admin/search/popular` | Most popular search terms |
61
+ | `GET` | `/admin/search/zero-results` | Queries that returned zero results |
62
+ | `GET` | `/admin/search/synonyms` | List all synonym groups |
63
+ | `POST` | `/admin/search/synonyms/add` | Add a synonym group |
64
+ | `POST` | `/admin/search/synonyms/:id/delete` | Delete a synonym group |
65
+ | `POST` | `/admin/search/index` | Manually index an item |
66
+ | `POST` | `/admin/search/index/remove` | Remove an item from the index |
67
+
68
+ ## Controller API
69
+
70
+ ```ts
71
+ interface SearchController {
72
+ indexItem(params: {
73
+ entityType: string;
74
+ entityId: string;
75
+ title: string;
76
+ body?: string;
77
+ tags?: string[];
78
+ url?: string;
79
+ image?: string;
80
+ metadata?: Record<string, unknown>;
81
+ }): Promise<SearchIndexItem>;
82
+
83
+ removeFromIndex(entityType: string, entityId: string): Promise<void>;
84
+
85
+ search(query: string, options?: {
86
+ entityType?: string;
87
+ limit?: number;
88
+ skip?: number;
89
+ }): Promise<{ results: SearchResult[]; total: number }>;
90
+
91
+ suggest(prefix: string, limit?: number): Promise<string[]>;
92
+ recordQuery(term: string, resultCount: number, sessionId?: string): Promise<void>;
93
+ getRecentQueries(sessionId: string, limit?: number): Promise<SearchQuery[]>;
94
+ getPopularTerms(limit?: number): Promise<PopularTerm[]>;
95
+ getZeroResultQueries(limit?: number): Promise<SearchQuery[]>;
96
+ getAnalytics(): Promise<SearchAnalyticsSummary>;
97
+ addSynonym(term: string, synonyms: string[]): Promise<SearchSynonym>;
98
+ removeSynonym(id: string): Promise<void>;
99
+ listSynonyms(): Promise<SearchSynonym[]>;
100
+ getIndexCount(): Promise<number>;
101
+ }
102
+ ```
103
+
104
+ ## Types
105
+
106
+ ```ts
107
+ interface SearchIndexItem {
108
+ id: string;
109
+ entityType: string;
110
+ entityId: string;
111
+ title: string;
112
+ body?: string;
113
+ tags: string[];
114
+ url?: string;
115
+ image?: string;
116
+ metadata?: Record<string, unknown>;
117
+ indexedAt: Date;
118
+ }
119
+
120
+ interface SearchResult {
121
+ item: SearchIndexItem;
122
+ score: number;
123
+ }
124
+
125
+ interface SearchAnalyticsSummary {
126
+ totalQueries: number;
127
+ uniqueTerms: number;
128
+ avgResultCount: number;
129
+ zeroResultCount: number;
130
+ zeroResultRate: number;
131
+ }
132
+
133
+ interface PopularTerm {
134
+ term: string;
135
+ count: number;
136
+ avgResultCount: number;
137
+ }
138
+ ```
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@86d-app/search",
3
+ "version": "0.0.3",
4
+ "description": "Unified search, autocomplete, and search analytics module for 86d commerce platform",
5
+ "keywords": [
6
+ "commerce",
7
+ "search",
8
+ "autocomplete",
9
+ "analytics",
10
+ "86d"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "86d <chat@86d.app>",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/86d-app/86d.git",
17
+ "directory": "modules/search"
18
+ },
19
+ "homepage": "https://github.com/86d-app/86d/tree/main/modules/search",
20
+ "type": "module",
21
+ "exports": {
22
+ "./components": "./src/store/components",
23
+ "./admin-components": "./src/admin/components",
24
+ "./*": "./src/*"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "check": "biome check src",
29
+ "check:fix": "biome check --write src",
30
+ "clean": "git clean -xdf .cache .turbo dist node_modules",
31
+ "dev": "tsc",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest watch",
34
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false"
35
+ },
36
+ "dependencies": {
37
+ "@86d-app/core": "workspace:*"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "catalog:",
41
+ "@types/mdx": "catalog:mdx",
42
+ "@types/react": "catalog:react",
43
+ "typescript": "catalog:",
44
+ "vitest": "catalog:vite"
45
+ }
46
+ }
@@ -0,0 +1,467 @@
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("createSearchController", () => {
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 ────────────────────────────────────────────────────────
15
+
16
+ describe("indexItem", () => {
17
+ it("indexes a new item", async () => {
18
+ const item = await controller.indexItem({
19
+ entityType: "product",
20
+ entityId: "prod_1",
21
+ title: "Red T-Shirt",
22
+ body: "A comfortable cotton t-shirt in red",
23
+ tags: ["clothing", "t-shirt", "red"],
24
+ url: "/products/red-t-shirt",
25
+ image: "/images/red-tshirt.jpg",
26
+ });
27
+ expect(item.id).toBeDefined();
28
+ expect(item.entityType).toBe("product");
29
+ expect(item.entityId).toBe("prod_1");
30
+ expect(item.title).toBe("Red T-Shirt");
31
+ expect(item.tags).toEqual(["clothing", "t-shirt", "red"]);
32
+ expect(item.indexedAt).toBeInstanceOf(Date);
33
+ });
34
+
35
+ it("updates an existing indexed item", async () => {
36
+ await controller.indexItem({
37
+ entityType: "product",
38
+ entityId: "prod_1",
39
+ title: "Red T-Shirt",
40
+ url: "/products/red-t-shirt",
41
+ });
42
+ const updated = await controller.indexItem({
43
+ entityType: "product",
44
+ entityId: "prod_1",
45
+ title: "Updated Red T-Shirt",
46
+ url: "/products/red-t-shirt",
47
+ });
48
+ expect(updated.title).toBe("Updated Red T-Shirt");
49
+
50
+ const count = await controller.getIndexCount();
51
+ expect(count).toBe(1);
52
+ });
53
+
54
+ it("defaults tags and metadata", async () => {
55
+ const item = await controller.indexItem({
56
+ entityType: "blog",
57
+ entityId: "post_1",
58
+ title: "Hello World",
59
+ url: "/blog/hello-world",
60
+ });
61
+ expect(item.tags).toEqual([]);
62
+ expect(item.metadata).toEqual({});
63
+ });
64
+ });
65
+
66
+ // ── removeFromIndex ─────────────────────────────────────────────────
67
+
68
+ describe("removeFromIndex", () => {
69
+ it("removes an indexed item", async () => {
70
+ await controller.indexItem({
71
+ entityType: "product",
72
+ entityId: "prod_1",
73
+ title: "Red T-Shirt",
74
+ url: "/products/red-t-shirt",
75
+ });
76
+ const removed = await controller.removeFromIndex("product", "prod_1");
77
+ expect(removed).toBe(true);
78
+ const count = await controller.getIndexCount();
79
+ expect(count).toBe(0);
80
+ });
81
+
82
+ it("returns false for non-existent item", async () => {
83
+ const removed = await controller.removeFromIndex("product", "missing");
84
+ expect(removed).toBe(false);
85
+ });
86
+ });
87
+
88
+ // ── search ──────────────────────────────────────────────────────────
89
+
90
+ describe("search", () => {
91
+ beforeEach(async () => {
92
+ await controller.indexItem({
93
+ entityType: "product",
94
+ entityId: "prod_1",
95
+ title: "Red T-Shirt",
96
+ body: "Comfortable cotton t-shirt",
97
+ tags: ["clothing", "red"],
98
+ url: "/products/red-t-shirt",
99
+ });
100
+ await controller.indexItem({
101
+ entityType: "product",
102
+ entityId: "prod_2",
103
+ title: "Blue Jeans",
104
+ body: "Classic denim jeans",
105
+ tags: ["clothing", "blue", "denim"],
106
+ url: "/products/blue-jeans",
107
+ });
108
+ await controller.indexItem({
109
+ entityType: "blog",
110
+ entityId: "post_1",
111
+ title: "How to Style T-Shirts",
112
+ body: "Tips for styling your favorite t-shirts",
113
+ tags: ["fashion", "tips"],
114
+ url: "/blog/style-t-shirts",
115
+ });
116
+ });
117
+
118
+ it("returns matching results sorted by score", async () => {
119
+ const { results, total } = await controller.search("t-shirt");
120
+ expect(total).toBeGreaterThan(0);
121
+ expect(results[0].item.title).toContain("T-Shirt");
122
+ expect(results[0].score).toBeGreaterThan(0);
123
+ });
124
+
125
+ it("returns empty results for non-matching query", async () => {
126
+ const { results, total } = await controller.search("nonexistent");
127
+ expect(results).toHaveLength(0);
128
+ expect(total).toBe(0);
129
+ });
130
+
131
+ it("returns empty results for empty query", async () => {
132
+ const { results, total } = await controller.search(" ");
133
+ expect(results).toHaveLength(0);
134
+ expect(total).toBe(0);
135
+ });
136
+
137
+ it("filters by entityType", async () => {
138
+ const { results } = await controller.search("t-shirt", {
139
+ entityType: "product",
140
+ });
141
+ for (const r of results) {
142
+ expect(r.item.entityType).toBe("product");
143
+ }
144
+ });
145
+
146
+ it("supports pagination with limit and skip", async () => {
147
+ const { results: page1 } = await controller.search("clothing", {
148
+ limit: 1,
149
+ skip: 0,
150
+ });
151
+ const { results: page2 } = await controller.search("clothing", {
152
+ limit: 1,
153
+ skip: 1,
154
+ });
155
+ expect(page1).toHaveLength(1);
156
+ if (page2.length > 0) {
157
+ expect(page1[0].item.id).not.toBe(page2[0].item.id);
158
+ }
159
+ });
160
+
161
+ it("matches on tags", async () => {
162
+ const { results } = await controller.search("denim");
163
+ expect(results.length).toBeGreaterThan(0);
164
+ expect(results[0].item.entityId).toBe("prod_2");
165
+ });
166
+
167
+ it("matches on body content", async () => {
168
+ const { results } = await controller.search("cotton");
169
+ expect(results.length).toBeGreaterThan(0);
170
+ expect(results[0].item.entityId).toBe("prod_1");
171
+ });
172
+
173
+ it("ranks title matches higher than body matches", async () => {
174
+ // "Red T-Shirt" has "red" in title; "Blue Jeans" does not
175
+ const { results } = await controller.search("red");
176
+ expect(results.length).toBeGreaterThan(0);
177
+ expect(results[0].item.entityId).toBe("prod_1");
178
+ });
179
+ });
180
+
181
+ // ── synonym expansion ───────────────────────────────────────────────
182
+
183
+ describe("search with synonyms", () => {
184
+ beforeEach(async () => {
185
+ await controller.indexItem({
186
+ entityType: "product",
187
+ entityId: "prod_1",
188
+ title: "T-Shirt",
189
+ url: "/products/t-shirt",
190
+ });
191
+ await controller.addSynonym("tee", ["t-shirt", "tshirt"]);
192
+ });
193
+
194
+ it("expands query with synonyms", async () => {
195
+ const { results } = await controller.search("tee");
196
+ expect(results.length).toBeGreaterThan(0);
197
+ expect(results[0].item.entityId).toBe("prod_1");
198
+ });
199
+
200
+ it("also expands in reverse (synonym → term)", async () => {
201
+ await controller.indexItem({
202
+ entityType: "product",
203
+ entityId: "prod_2",
204
+ title: "Tee Collection",
205
+ url: "/products/tee-collection",
206
+ });
207
+ const { results } = await controller.search("tshirt");
208
+ // "tshirt" is a synonym of "tee", so "tee" should also be searched
209
+ expect(results.length).toBeGreaterThan(0);
210
+ });
211
+ });
212
+
213
+ // ── suggest ─────────────────────────────────────────────────────────
214
+
215
+ describe("suggest", () => {
216
+ beforeEach(async () => {
217
+ await controller.indexItem({
218
+ entityType: "product",
219
+ entityId: "prod_1",
220
+ title: "Red T-Shirt",
221
+ url: "/products/red-t-shirt",
222
+ });
223
+ await controller.indexItem({
224
+ entityType: "product",
225
+ entityId: "prod_2",
226
+ title: "Red Sneakers",
227
+ url: "/products/red-sneakers",
228
+ });
229
+ // Record some queries
230
+ await controller.recordQuery("red t-shirt", 5);
231
+ await controller.recordQuery("red t-shirt", 3);
232
+ await controller.recordQuery("red sneakers", 2);
233
+ });
234
+
235
+ it("returns suggestions matching prefix", async () => {
236
+ const suggestions = await controller.suggest("red");
237
+ expect(suggestions.length).toBeGreaterThan(0);
238
+ for (const s of suggestions) {
239
+ expect(s.toLowerCase()).toContain("red");
240
+ }
241
+ });
242
+
243
+ it("prioritizes popular queries over title matches", async () => {
244
+ const suggestions = await controller.suggest("red");
245
+ // "red t-shirt" was searched twice, should appear before "red sneakers"
246
+ expect(suggestions[0].toLowerCase()).toContain("red t-shirt");
247
+ });
248
+
249
+ it("respects limit", async () => {
250
+ const suggestions = await controller.suggest("red", 1);
251
+ expect(suggestions).toHaveLength(1);
252
+ });
253
+
254
+ it("returns empty for empty prefix", async () => {
255
+ const suggestions = await controller.suggest("");
256
+ expect(suggestions).toHaveLength(0);
257
+ });
258
+ });
259
+
260
+ // ── recordQuery ─────────────────────────────────────────────────────
261
+
262
+ describe("recordQuery", () => {
263
+ it("records a search query", async () => {
264
+ const query = await controller.recordQuery("red shoes", 10, "sess_1");
265
+ expect(query.id).toBeDefined();
266
+ expect(query.term).toBe("red shoes");
267
+ expect(query.normalizedTerm).toBe("red shoes");
268
+ expect(query.resultCount).toBe(10);
269
+ expect(query.sessionId).toBe("sess_1");
270
+ expect(query.searchedAt).toBeInstanceOf(Date);
271
+ });
272
+
273
+ it("records without sessionId", async () => {
274
+ const query = await controller.recordQuery("blue hat", 5);
275
+ expect(query.sessionId).toBeUndefined();
276
+ });
277
+ });
278
+
279
+ // ── getRecentQueries ────────────────────────────────────────────────
280
+
281
+ describe("getRecentQueries", () => {
282
+ it("returns recent queries for a session", async () => {
283
+ await controller.recordQuery("shoes", 10, "sess_1");
284
+ await controller.recordQuery("hats", 5, "sess_1");
285
+ await controller.recordQuery("bags", 3, "sess_2");
286
+
287
+ const recent = await controller.getRecentQueries("sess_1");
288
+ expect(recent).toHaveLength(2);
289
+ });
290
+
291
+ it("deduplicates by normalized term", async () => {
292
+ await controller.recordQuery("Red Shoes", 10, "sess_1");
293
+ await controller.recordQuery("red shoes", 8, "sess_1");
294
+
295
+ const recent = await controller.getRecentQueries("sess_1");
296
+ expect(recent).toHaveLength(1);
297
+ });
298
+
299
+ it("respects limit", async () => {
300
+ for (let i = 0; i < 5; i++) {
301
+ await controller.recordQuery(`term_${i}`, i, "sess_1");
302
+ }
303
+ const recent = await controller.getRecentQueries("sess_1", 3);
304
+ expect(recent).toHaveLength(3);
305
+ });
306
+
307
+ it("returns empty for unknown session", async () => {
308
+ const recent = await controller.getRecentQueries("unknown");
309
+ expect(recent).toHaveLength(0);
310
+ });
311
+ });
312
+
313
+ // ── getPopularTerms ─────────────────────────────────────────────────
314
+
315
+ describe("getPopularTerms", () => {
316
+ it("returns terms sorted by frequency", async () => {
317
+ await controller.recordQuery("shoes", 10);
318
+ await controller.recordQuery("shoes", 8);
319
+ await controller.recordQuery("shoes", 12);
320
+ await controller.recordQuery("hats", 5);
321
+ await controller.recordQuery("hats", 3);
322
+ await controller.recordQuery("bags", 1);
323
+
324
+ const popular = await controller.getPopularTerms();
325
+ expect(popular[0].term).toBe("shoes");
326
+ expect(popular[0].count).toBe(3);
327
+ expect(popular[0].avgResultCount).toBe(10); // (10+8+12)/3 = 10
328
+ expect(popular[1].term).toBe("hats");
329
+ expect(popular[1].count).toBe(2);
330
+ });
331
+
332
+ it("respects limit", async () => {
333
+ for (let i = 0; i < 10; i++) {
334
+ await controller.recordQuery(`term_${i}`, i);
335
+ }
336
+ const popular = await controller.getPopularTerms(5);
337
+ expect(popular).toHaveLength(5);
338
+ });
339
+
340
+ it("returns empty when no queries", async () => {
341
+ const popular = await controller.getPopularTerms();
342
+ expect(popular).toHaveLength(0);
343
+ });
344
+ });
345
+
346
+ // ── getZeroResultQueries ────────────────────────────────────────────
347
+
348
+ describe("getZeroResultQueries", () => {
349
+ it("returns only zero-result queries", async () => {
350
+ await controller.recordQuery("existing", 10);
351
+ await controller.recordQuery("missing", 0);
352
+ await controller.recordQuery("missing", 0);
353
+ await controller.recordQuery("also missing", 0);
354
+
355
+ const zero = await controller.getZeroResultQueries();
356
+ expect(zero).toHaveLength(2);
357
+ expect(zero[0].term).toBe("missing");
358
+ expect(zero[0].count).toBe(2);
359
+ expect(zero[0].avgResultCount).toBe(0);
360
+ });
361
+
362
+ it("returns empty when all queries have results", async () => {
363
+ await controller.recordQuery("shoes", 10);
364
+ const zero = await controller.getZeroResultQueries();
365
+ expect(zero).toHaveLength(0);
366
+ });
367
+ });
368
+
369
+ // ── getAnalytics ────────────────────────────────────────────────────
370
+
371
+ describe("getAnalytics", () => {
372
+ it("returns analytics summary", async () => {
373
+ await controller.recordQuery("shoes", 10);
374
+ await controller.recordQuery("shoes", 8);
375
+ await controller.recordQuery("hats", 0);
376
+
377
+ const analytics = await controller.getAnalytics();
378
+ expect(analytics.totalQueries).toBe(3);
379
+ expect(analytics.uniqueTerms).toBe(2);
380
+ expect(analytics.avgResultCount).toBe(6); // (10+8+0)/3 = 6
381
+ expect(analytics.zeroResultCount).toBe(1);
382
+ expect(analytics.zeroResultRate).toBe(33); // 1/3 = 33%
383
+ });
384
+
385
+ it("returns zeros when no queries", async () => {
386
+ const analytics = await controller.getAnalytics();
387
+ expect(analytics.totalQueries).toBe(0);
388
+ expect(analytics.uniqueTerms).toBe(0);
389
+ expect(analytics.avgResultCount).toBe(0);
390
+ expect(analytics.zeroResultCount).toBe(0);
391
+ expect(analytics.zeroResultRate).toBe(0);
392
+ });
393
+ });
394
+
395
+ // ── synonyms ────────────────────────────────────────────────────────
396
+
397
+ describe("synonyms", () => {
398
+ it("adds a synonym", async () => {
399
+ const synonym = await controller.addSynonym("tee", ["t-shirt", "tshirt"]);
400
+ expect(synonym.id).toBeDefined();
401
+ expect(synonym.term).toBe("tee");
402
+ expect(synonym.synonyms).toEqual(["t-shirt", "tshirt"]);
403
+ expect(synonym.createdAt).toBeInstanceOf(Date);
404
+ });
405
+
406
+ it("updates existing synonym for same term", async () => {
407
+ await controller.addSynonym("tee", ["t-shirt"]);
408
+ const updated = await controller.addSynonym("tee", [
409
+ "t-shirt",
410
+ "tshirt",
411
+ "shirt",
412
+ ]);
413
+ expect(updated.synonyms).toEqual(["t-shirt", "tshirt", "shirt"]);
414
+
415
+ const all = await controller.listSynonyms();
416
+ expect(all).toHaveLength(1);
417
+ });
418
+
419
+ it("removes a synonym", async () => {
420
+ const synonym = await controller.addSynonym("tee", ["t-shirt"]);
421
+ const removed = await controller.removeSynonym(synonym.id);
422
+ expect(removed).toBe(true);
423
+
424
+ const all = await controller.listSynonyms();
425
+ expect(all).toHaveLength(0);
426
+ });
427
+
428
+ it("returns false when removing non-existent synonym", async () => {
429
+ const removed = await controller.removeSynonym("missing");
430
+ expect(removed).toBe(false);
431
+ });
432
+
433
+ it("lists all synonyms", async () => {
434
+ await controller.addSynonym("tee", ["t-shirt"]);
435
+ await controller.addSynonym("sneaker", ["shoe", "trainer"]);
436
+
437
+ const all = await controller.listSynonyms();
438
+ expect(all).toHaveLength(2);
439
+ });
440
+ });
441
+
442
+ // ── getIndexCount ───────────────────────────────────────────────────
443
+
444
+ describe("getIndexCount", () => {
445
+ it("returns 0 when empty", async () => {
446
+ const count = await controller.getIndexCount();
447
+ expect(count).toBe(0);
448
+ });
449
+
450
+ it("returns correct count", async () => {
451
+ await controller.indexItem({
452
+ entityType: "product",
453
+ entityId: "prod_1",
454
+ title: "Item 1",
455
+ url: "/1",
456
+ });
457
+ await controller.indexItem({
458
+ entityType: "product",
459
+ entityId: "prod_2",
460
+ title: "Item 2",
461
+ url: "/2",
462
+ });
463
+ const count = await controller.getIndexCount();
464
+ expect(count).toBe(2);
465
+ });
466
+ });
467
+ });
@@ -0,0 +1 @@
1
+ export { SearchAnalytics } from "./search-analytics";