@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.
- package/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +72 -0
- package/README.md +171 -28
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/embedding-provider.test.d.ts +2 -0
- package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
- package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/search-analytics.d.ts +2 -0
- package/dist/admin/components/search-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/analytics.d.ts +15 -0
- package/dist/admin/endpoints/analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-index.d.ts +20 -0
- package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
- package/dist/admin/endpoints/click-analytics.d.ts +7 -0
- package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/get-settings.d.ts +17 -0
- package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
- package/dist/admin/endpoints/index-manage.d.ts +26 -0
- package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +125 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/popular.d.ts +10 -0
- package/dist/admin/endpoints/popular.d.ts.map +1 -0
- package/dist/admin/endpoints/synonyms.d.ts +30 -0
- package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
- package/dist/admin/endpoints/zero-results.d.ts +10 -0
- package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
- package/dist/embedding-provider.d.ts +28 -0
- package/dist/embedding-provider.d.ts.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/meilisearch-provider.d.ts +104 -0
- package/dist/meilisearch-provider.d.ts.map +1 -0
- package/dist/schema.d.ts +133 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +6 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +127 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +6 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +10 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/search-bar.d.ts +7 -0
- package/dist/store/components/search-bar.d.ts.map +1 -0
- package/dist/store/components/search-page.d.ts +4 -0
- package/dist/store/components/search-page.d.ts.map +1 -0
- package/dist/store/components/search-results.d.ts +9 -0
- package/dist/store/components/search-results.d.ts.map +1 -0
- package/dist/store/endpoints/click.d.ts +14 -0
- package/dist/store/endpoints/click.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +85 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/recent.d.ts +15 -0
- package/dist/store/endpoints/recent.d.ts.map +1 -0
- package/dist/store/endpoints/search.d.ts +36 -0
- package/dist/store/endpoints/search.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +16 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/dist/store/endpoints/suggest.d.ts +11 -0
- package/dist/store/endpoints/suggest.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1026 -0
- package/src/__tests__/embedding-provider.test.ts +195 -0
- package/src/__tests__/endpoint-security.test.ts +300 -0
- package/src/__tests__/meilisearch-provider.test.ts +400 -0
- package/src/__tests__/service-impl.test.ts +341 -8
- package/src/admin/components/search-analytics.tsx +120 -0
- package/src/admin/endpoints/bulk-index.ts +34 -0
- package/src/admin/endpoints/click-analytics.ts +16 -0
- package/src/admin/endpoints/get-settings.ts +56 -0
- package/src/admin/endpoints/index-manage.ts +4 -1
- package/src/admin/endpoints/index.ts +6 -0
- package/src/admin/endpoints/synonyms.ts +1 -1
- package/src/embedding-provider.ts +99 -0
- package/src/index.ts +60 -4
- package/src/meilisearch-provider.ts +239 -0
- package/src/schema.ts +15 -0
- package/src/service-impl.ts +605 -34
- package/src/service.ts +60 -1
- package/src/store/endpoints/click.ts +21 -0
- package/src/store/endpoints/index.ts +2 -0
- package/src/store/endpoints/search.ts +38 -10
- package/src/store/endpoints/suggest.ts +2 -2
- package/vitest.config.ts +2 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
cosineSimilarity,
|
|
4
|
+
OpenAIEmbeddingProvider,
|
|
5
|
+
} from "../embedding-provider";
|
|
6
|
+
|
|
7
|
+
// ── cosineSimilarity ───────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe("cosineSimilarity", () => {
|
|
10
|
+
it("returns 1 for identical vectors", () => {
|
|
11
|
+
expect(cosineSimilarity([1, 2, 3], [1, 2, 3])).toBeCloseTo(1);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns -1 for opposite vectors", () => {
|
|
15
|
+
expect(cosineSimilarity([1, 0, 0], [-1, 0, 0])).toBeCloseTo(-1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns 0 for orthogonal vectors", () => {
|
|
19
|
+
expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns 0 for empty vectors", () => {
|
|
23
|
+
expect(cosineSimilarity([], [])).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns 0 for mismatched lengths", () => {
|
|
27
|
+
expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns 0 for zero vectors", () => {
|
|
31
|
+
expect(cosineSimilarity([0, 0, 0], [0, 0, 0])).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("computes correct similarity for non-trivial vectors", () => {
|
|
35
|
+
// cos([1,2,3], [4,5,6]) = 32 / (sqrt(14) * sqrt(77)) ≈ 0.9746
|
|
36
|
+
expect(cosineSimilarity([1, 2, 3], [4, 5, 6])).toBeCloseTo(0.9746, 3);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── OpenAIEmbeddingProvider ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("OpenAIEmbeddingProvider", () => {
|
|
43
|
+
const originalFetch = globalThis.fetch;
|
|
44
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockFetch = vi.fn();
|
|
48
|
+
globalThis.fetch = mockFetch as typeof fetch;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
globalThis.fetch = originalFetch;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function makeProvider(opts?: { model?: string; baseUrl?: string }) {
|
|
56
|
+
return new OpenAIEmbeddingProvider("test-api-key", opts);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mockEmbeddingResponse(embeddings: number[][]) {
|
|
60
|
+
mockFetch.mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
json: async () => ({
|
|
63
|
+
data: embeddings.map((embedding, index) => ({ embedding, index })),
|
|
64
|
+
model: "text-embedding-3-small",
|
|
65
|
+
usage: { prompt_tokens: 10, total_tokens: 10 },
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── generateEmbedding ──────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("generateEmbedding", () => {
|
|
73
|
+
it("returns an embedding vector", async () => {
|
|
74
|
+
const vec = [0.1, 0.2, 0.3];
|
|
75
|
+
mockEmbeddingResponse([vec]);
|
|
76
|
+
|
|
77
|
+
const provider = makeProvider();
|
|
78
|
+
const result = await provider.generateEmbedding("hello world");
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual(vec);
|
|
81
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends correct request to OpenAI", async () => {
|
|
85
|
+
mockEmbeddingResponse([[0.1]]);
|
|
86
|
+
|
|
87
|
+
const provider = makeProvider();
|
|
88
|
+
await provider.generateEmbedding("test input");
|
|
89
|
+
|
|
90
|
+
const [url, options] = mockFetch.mock.calls[0];
|
|
91
|
+
expect(url).toBe("https://api.openai.com/v1/embeddings");
|
|
92
|
+
expect(options.method).toBe("POST");
|
|
93
|
+
expect(options.headers.Authorization).toBe("Bearer test-api-key");
|
|
94
|
+
const body = JSON.parse(options.body);
|
|
95
|
+
expect(body.input).toEqual(["test input"]);
|
|
96
|
+
expect(body.model).toBe("text-embedding-3-small");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("uses custom base URL and model", async () => {
|
|
100
|
+
mockEmbeddingResponse([[0.1]]);
|
|
101
|
+
|
|
102
|
+
const provider = makeProvider({
|
|
103
|
+
model: "custom-model",
|
|
104
|
+
baseUrl: "https://custom.api/v1",
|
|
105
|
+
});
|
|
106
|
+
await provider.generateEmbedding("test");
|
|
107
|
+
|
|
108
|
+
const [url, options] = mockFetch.mock.calls[0];
|
|
109
|
+
expect(url).toBe("https://custom.api/v1/embeddings");
|
|
110
|
+
const body = JSON.parse(options.body);
|
|
111
|
+
expect(body.model).toBe("custom-model");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns null on API error", async () => {
|
|
115
|
+
mockFetch.mockResolvedValueOnce({
|
|
116
|
+
ok: false,
|
|
117
|
+
status: 429,
|
|
118
|
+
json: async () => ({
|
|
119
|
+
error: { message: "Rate limit exceeded", type: "rate_limit" },
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const provider = makeProvider();
|
|
124
|
+
const result = await provider.generateEmbedding("test");
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null on network error", async () => {
|
|
129
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
130
|
+
|
|
131
|
+
const provider = makeProvider();
|
|
132
|
+
const result = await provider.generateEmbedding("test");
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── generateEmbeddings ─────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("generateEmbeddings", () => {
|
|
140
|
+
it("returns multiple embeddings", async () => {
|
|
141
|
+
const vecs = [
|
|
142
|
+
[0.1, 0.2],
|
|
143
|
+
[0.3, 0.4],
|
|
144
|
+
];
|
|
145
|
+
mockEmbeddingResponse(vecs);
|
|
146
|
+
|
|
147
|
+
const provider = makeProvider();
|
|
148
|
+
const results = await provider.generateEmbeddings(["foo", "bar"]);
|
|
149
|
+
|
|
150
|
+
expect(results).toEqual(vecs);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns empty array for empty input", async () => {
|
|
154
|
+
const provider = makeProvider();
|
|
155
|
+
const results = await provider.generateEmbeddings([]);
|
|
156
|
+
|
|
157
|
+
expect(results).toEqual([]);
|
|
158
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns nulls for all-whitespace inputs", async () => {
|
|
162
|
+
const provider = makeProvider();
|
|
163
|
+
const results = await provider.generateEmbeddings([" ", "\t", "\n"]);
|
|
164
|
+
|
|
165
|
+
expect(results).toEqual([null, null, null]);
|
|
166
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("truncates inputs longer than 8000 chars", async () => {
|
|
170
|
+
mockEmbeddingResponse([[0.1]]);
|
|
171
|
+
|
|
172
|
+
const provider = makeProvider();
|
|
173
|
+
const longText = "a".repeat(10000);
|
|
174
|
+
await provider.generateEmbeddings([longText]);
|
|
175
|
+
|
|
176
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
177
|
+
expect(body.input[0].length).toBe(8000);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns nulls on API error", async () => {
|
|
181
|
+
mockFetch.mockResolvedValueOnce({
|
|
182
|
+
ok: false,
|
|
183
|
+
status: 500,
|
|
184
|
+
json: async () => ({
|
|
185
|
+
error: { message: "Internal error", type: "server_error" },
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const provider = makeProvider();
|
|
190
|
+
const results = await provider.generateEmbeddings(["a", "b"]);
|
|
191
|
+
|
|
192
|
+
expect(results).toEqual([null, null]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Security regression tests for search endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Search is public-facing and accepts user-supplied text.
|
|
9
|
+
* These tests verify:
|
|
10
|
+
* - Session isolation: recent queries are scoped to sessionId
|
|
11
|
+
* - Index integrity: removing an item doesn't leak leftover results
|
|
12
|
+
* - Synonym injection: adding synonyms doesn't corrupt unrelated searches
|
|
13
|
+
* - Query recording: fire-and-forget recording doesn't affect results
|
|
14
|
+
* - Boundary conditions: extreme input lengths, empty strings, special chars
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe("search endpoint security", () => {
|
|
18
|
+
let mockData: ReturnType<typeof createMockDataService>;
|
|
19
|
+
let controller: ReturnType<typeof createSearchController>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockData = createMockDataService();
|
|
23
|
+
controller = createSearchController(mockData);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ── Session Isolation ──────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("session isolation on recent queries", () => {
|
|
29
|
+
it("getRecentQueries only returns queries for the given session", async () => {
|
|
30
|
+
await controller.recordQuery("shoes", 5, "session_A");
|
|
31
|
+
await controller.recordQuery("boots", 3, "session_B");
|
|
32
|
+
await controller.recordQuery("sandals", 2, "session_A");
|
|
33
|
+
|
|
34
|
+
const sessionA = await controller.getRecentQueries("session_A");
|
|
35
|
+
expect(sessionA).toHaveLength(2);
|
|
36
|
+
for (const q of sessionA) {
|
|
37
|
+
expect(q.sessionId).toBe("session_A");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sessionB = await controller.getRecentQueries("session_B");
|
|
41
|
+
expect(sessionB).toHaveLength(1);
|
|
42
|
+
expect(sessionB[0].sessionId).toBe("session_B");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("queries without sessionId do not appear in any session results", async () => {
|
|
46
|
+
await controller.recordQuery("anonymous-search", 1);
|
|
47
|
+
|
|
48
|
+
const results = await controller.getRecentQueries("any_session");
|
|
49
|
+
expect(results).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("session A cannot see session B recent queries even with same term", async () => {
|
|
53
|
+
await controller.recordQuery("laptop", 10, "session_A");
|
|
54
|
+
await controller.recordQuery("laptop", 10, "session_B");
|
|
55
|
+
|
|
56
|
+
const a = await controller.getRecentQueries("session_A");
|
|
57
|
+
const b = await controller.getRecentQueries("session_B");
|
|
58
|
+
expect(a).toHaveLength(1);
|
|
59
|
+
expect(b).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Index Integrity ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("index integrity after removal", () => {
|
|
66
|
+
it("removed item does not appear in search results", async () => {
|
|
67
|
+
await controller.indexItem({
|
|
68
|
+
entityType: "product",
|
|
69
|
+
entityId: "secret_prod",
|
|
70
|
+
title: "Classified Widget",
|
|
71
|
+
url: "/products/secret",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const before = await controller.search("Classified");
|
|
75
|
+
expect(before.total).toBeGreaterThan(0);
|
|
76
|
+
|
|
77
|
+
await controller.removeFromIndex("product", "secret_prod");
|
|
78
|
+
|
|
79
|
+
const after = await controller.search("Classified");
|
|
80
|
+
expect(after.total).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("removing one entityType does not remove same entityId in another type", async () => {
|
|
84
|
+
await controller.indexItem({
|
|
85
|
+
entityType: "product",
|
|
86
|
+
entityId: "shared_id",
|
|
87
|
+
title: "Product Item",
|
|
88
|
+
url: "/product/shared",
|
|
89
|
+
});
|
|
90
|
+
await controller.indexItem({
|
|
91
|
+
entityType: "blog",
|
|
92
|
+
entityId: "shared_id",
|
|
93
|
+
title: "Blog Post Item",
|
|
94
|
+
url: "/blog/shared",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await controller.removeFromIndex("product", "shared_id");
|
|
98
|
+
|
|
99
|
+
const results = await controller.search("Item");
|
|
100
|
+
expect(results.total).toBe(1);
|
|
101
|
+
expect(results.results[0].item.entityType).toBe("blog");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Synonym Safety ─────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("synonym management safety", () => {
|
|
108
|
+
it("synonyms only expand for configured terms", async () => {
|
|
109
|
+
await controller.indexItem({
|
|
110
|
+
entityType: "product",
|
|
111
|
+
entityId: "tshirt_1",
|
|
112
|
+
title: "Cotton T-Shirt",
|
|
113
|
+
url: "/products/tshirt",
|
|
114
|
+
});
|
|
115
|
+
await controller.indexItem({
|
|
116
|
+
entityType: "product",
|
|
117
|
+
entityId: "pants_1",
|
|
118
|
+
title: "Denim Pants",
|
|
119
|
+
url: "/products/pants",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await controller.addSynonym("tee", ["t-shirt"]);
|
|
123
|
+
|
|
124
|
+
// "tee" should find t-shirt
|
|
125
|
+
const teeResults = await controller.search("tee");
|
|
126
|
+
expect(teeResults.total).toBeGreaterThan(0);
|
|
127
|
+
|
|
128
|
+
// "tee" should NOT find pants
|
|
129
|
+
for (const r of teeResults.results) {
|
|
130
|
+
expect(r.item.title).not.toContain("Pants");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("removing a synonym stops expansion", async () => {
|
|
135
|
+
await controller.indexItem({
|
|
136
|
+
entityType: "product",
|
|
137
|
+
entityId: "shoe_1",
|
|
138
|
+
title: "Running Shoes",
|
|
139
|
+
url: "/products/shoes",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const syn = await controller.addSynonym("sneakers", ["shoes"]);
|
|
143
|
+
const before = await controller.search("sneakers");
|
|
144
|
+
expect(before.total).toBeGreaterThan(0);
|
|
145
|
+
|
|
146
|
+
await controller.removeSynonym(syn.id);
|
|
147
|
+
const after = await controller.search("sneakers");
|
|
148
|
+
expect(after.total).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Boundary Conditions ────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("input boundary handling", () => {
|
|
155
|
+
it("empty query returns no results without error", async () => {
|
|
156
|
+
await controller.indexItem({
|
|
157
|
+
entityType: "product",
|
|
158
|
+
entityId: "p1",
|
|
159
|
+
title: "Widget",
|
|
160
|
+
url: "/p",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const result = await controller.search("");
|
|
164
|
+
expect(result.results).toHaveLength(0);
|
|
165
|
+
expect(result.total).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("whitespace-only query returns no results", async () => {
|
|
169
|
+
await controller.indexItem({
|
|
170
|
+
entityType: "product",
|
|
171
|
+
entityId: "p1",
|
|
172
|
+
title: "Widget",
|
|
173
|
+
url: "/p",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await controller.search(" ");
|
|
177
|
+
expect(result.results).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("suggest with empty prefix returns empty", async () => {
|
|
181
|
+
await controller.recordQuery("popular term", 10);
|
|
182
|
+
const suggestions = await controller.suggest("");
|
|
183
|
+
expect(suggestions).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("search with special characters does not crash", async () => {
|
|
187
|
+
await controller.indexItem({
|
|
188
|
+
entityType: "product",
|
|
189
|
+
entityId: "p1",
|
|
190
|
+
title: "Widget",
|
|
191
|
+
url: "/p",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// These should not throw
|
|
195
|
+
await controller.search("<script>alert(1)</script>");
|
|
196
|
+
await controller.search("'; DROP TABLE--");
|
|
197
|
+
await controller.search("../../etc/passwd");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("entityType filter prevents cross-type leakage", async () => {
|
|
201
|
+
await controller.indexItem({
|
|
202
|
+
entityType: "product",
|
|
203
|
+
entityId: "p1",
|
|
204
|
+
title: "Shared Name Widget",
|
|
205
|
+
url: "/products/p1",
|
|
206
|
+
});
|
|
207
|
+
await controller.indexItem({
|
|
208
|
+
entityType: "page",
|
|
209
|
+
entityId: "pg1",
|
|
210
|
+
title: "Shared Name Widget",
|
|
211
|
+
url: "/pages/pg1",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const products = await controller.search("Shared Name", {
|
|
215
|
+
entityType: "product",
|
|
216
|
+
});
|
|
217
|
+
expect(products.total).toBe(1);
|
|
218
|
+
expect(products.results[0].item.entityType).toBe("product");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Analytics Integrity ────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
describe("analytics data integrity", () => {
|
|
225
|
+
it("analytics counts reflect actual query history", async () => {
|
|
226
|
+
await controller.recordQuery("shoes", 5);
|
|
227
|
+
await controller.recordQuery("shoes", 5);
|
|
228
|
+
await controller.recordQuery("boots", 0);
|
|
229
|
+
|
|
230
|
+
const analytics = await controller.getAnalytics();
|
|
231
|
+
expect(analytics.totalQueries).toBe(3);
|
|
232
|
+
expect(analytics.zeroResultCount).toBe(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("zero-result queries are tracked accurately", async () => {
|
|
236
|
+
await controller.recordQuery("nonexistent", 0);
|
|
237
|
+
await controller.recordQuery("also-missing", 0);
|
|
238
|
+
await controller.recordQuery("found-it", 3);
|
|
239
|
+
|
|
240
|
+
const zeroResults = await controller.getZeroResultQueries();
|
|
241
|
+
expect(zeroResults).toHaveLength(2);
|
|
242
|
+
for (const term of zeroResults) {
|
|
243
|
+
expect(["nonexistent", "also-missing"]).toContain(term.term);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("popular terms exclude zero-result queries from suggestions", async () => {
|
|
248
|
+
await controller.recordQuery("widget", 10);
|
|
249
|
+
await controller.recordQuery("widget", 10);
|
|
250
|
+
await controller.recordQuery("nope", 0);
|
|
251
|
+
|
|
252
|
+
const popular = await controller.getPopularTerms();
|
|
253
|
+
const terms = popular.map((p) => p.term);
|
|
254
|
+
expect(terms).toContain("widget");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── Re-indexing Deduplication ───────────────────────────────────
|
|
259
|
+
|
|
260
|
+
describe("re-indexing deduplication", () => {
|
|
261
|
+
it("re-indexing same entity updates rather than duplicates", async () => {
|
|
262
|
+
await controller.indexItem({
|
|
263
|
+
entityType: "product",
|
|
264
|
+
entityId: "p1",
|
|
265
|
+
title: "Old Title",
|
|
266
|
+
url: "/p1",
|
|
267
|
+
});
|
|
268
|
+
await controller.indexItem({
|
|
269
|
+
entityType: "product",
|
|
270
|
+
entityId: "p1",
|
|
271
|
+
title: "New Title",
|
|
272
|
+
url: "/p1",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const results = await controller.search("Title");
|
|
276
|
+
expect(results.total).toBe(1);
|
|
277
|
+
expect(results.results[0].item.title).toBe("New Title");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("indexCount does not grow on re-index", async () => {
|
|
281
|
+
await controller.indexItem({
|
|
282
|
+
entityType: "product",
|
|
283
|
+
entityId: "p1",
|
|
284
|
+
title: "Widget",
|
|
285
|
+
url: "/p",
|
|
286
|
+
});
|
|
287
|
+
const count1 = await controller.getIndexCount();
|
|
288
|
+
|
|
289
|
+
await controller.indexItem({
|
|
290
|
+
entityType: "product",
|
|
291
|
+
entityId: "p1",
|
|
292
|
+
title: "Updated Widget",
|
|
293
|
+
url: "/p",
|
|
294
|
+
});
|
|
295
|
+
const count2 = await controller.getIndexCount();
|
|
296
|
+
|
|
297
|
+
expect(count2).toBe(count1);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|