@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 +138 -0
- package/package.json +46 -0
- package/src/__tests__/service-impl.test.ts +467 -0
- package/src/admin/components/index.tsx +1 -0
- package/src/admin/components/search-analytics.tsx +292 -0
- package/src/admin/endpoints/analytics.ts +15 -0
- package/src/admin/endpoints/index-manage.ts +43 -0
- package/src/admin/endpoints/index.ts +16 -0
- package/src/admin/endpoints/popular.ts +17 -0
- package/src/admin/endpoints/synonyms.ts +49 -0
- package/src/admin/endpoints/zero-results.ts +17 -0
- package/src/index.ts +61 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +48 -0
- package/src/service-impl.ts +395 -0
- package/src/service.ts +97 -0
- package/src/store/components/_hooks.ts +12 -0
- package/src/store/components/index.tsx +12 -0
- package/src/store/components/search-bar.tsx +153 -0
- package/src/store/components/search-page.tsx +26 -0
- package/src/store/components/search-results.tsx +102 -0
- package/src/store/endpoints/index.ts +11 -0
- package/src/store/endpoints/recent.ts +27 -0
- package/src/store/endpoints/search.ts +42 -0
- package/src/store/endpoints/store-search.ts +41 -0
- package/src/store/endpoints/suggest.ts +21 -0
- package/tsconfig.json +9 -0
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";
|