@86d-app/search 0.0.4 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +72 -0
  3. package/README.md +171 -28
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/embedding-provider.test.d.ts +2 -0
  7. package/dist/__tests__/embedding-provider.test.d.ts.map +1 -0
  8. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  9. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  10. package/dist/__tests__/meilisearch-provider.test.d.ts +2 -0
  11. package/dist/__tests__/meilisearch-provider.test.d.ts.map +1 -0
  12. package/dist/__tests__/service-impl.test.d.ts +2 -0
  13. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  14. package/dist/admin/components/index.d.ts +2 -0
  15. package/dist/admin/components/index.d.ts.map +1 -0
  16. package/dist/admin/components/search-analytics.d.ts +2 -0
  17. package/dist/admin/components/search-analytics.d.ts.map +1 -0
  18. package/dist/admin/endpoints/analytics.d.ts +15 -0
  19. package/dist/admin/endpoints/analytics.d.ts.map +1 -0
  20. package/dist/admin/endpoints/bulk-index.d.ts +20 -0
  21. package/dist/admin/endpoints/bulk-index.d.ts.map +1 -0
  22. package/dist/admin/endpoints/click-analytics.d.ts +7 -0
  23. package/dist/admin/endpoints/click-analytics.d.ts.map +1 -0
  24. package/dist/admin/endpoints/get-settings.d.ts +17 -0
  25. package/dist/admin/endpoints/get-settings.d.ts.map +1 -0
  26. package/dist/admin/endpoints/index-manage.d.ts +26 -0
  27. package/dist/admin/endpoints/index-manage.d.ts.map +1 -0
  28. package/dist/admin/endpoints/index.d.ts +125 -0
  29. package/dist/admin/endpoints/index.d.ts.map +1 -0
  30. package/dist/admin/endpoints/popular.d.ts +10 -0
  31. package/dist/admin/endpoints/popular.d.ts.map +1 -0
  32. package/dist/admin/endpoints/synonyms.d.ts +30 -0
  33. package/dist/admin/endpoints/synonyms.d.ts.map +1 -0
  34. package/dist/admin/endpoints/zero-results.d.ts +10 -0
  35. package/dist/admin/endpoints/zero-results.d.ts.map +1 -0
  36. package/dist/embedding-provider.d.ts +28 -0
  37. package/dist/embedding-provider.d.ts.map +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/meilisearch-provider.d.ts +104 -0
  41. package/dist/meilisearch-provider.d.ts.map +1 -0
  42. package/dist/schema.d.ts +133 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/service-impl.d.ts +6 -0
  45. package/dist/service-impl.d.ts.map +1 -0
  46. package/dist/service.d.ts +127 -0
  47. package/dist/service.d.ts.map +1 -0
  48. package/dist/store/components/_hooks.d.ts +6 -0
  49. package/dist/store/components/_hooks.d.ts.map +1 -0
  50. package/dist/store/components/index.d.ts +10 -0
  51. package/dist/store/components/index.d.ts.map +1 -0
  52. package/dist/store/components/search-bar.d.ts +7 -0
  53. package/dist/store/components/search-bar.d.ts.map +1 -0
  54. package/dist/store/components/search-page.d.ts +4 -0
  55. package/dist/store/components/search-page.d.ts.map +1 -0
  56. package/dist/store/components/search-results.d.ts +9 -0
  57. package/dist/store/components/search-results.d.ts.map +1 -0
  58. package/dist/store/endpoints/click.d.ts +14 -0
  59. package/dist/store/endpoints/click.d.ts.map +1 -0
  60. package/dist/store/endpoints/index.d.ts +85 -0
  61. package/dist/store/endpoints/index.d.ts.map +1 -0
  62. package/dist/store/endpoints/recent.d.ts +15 -0
  63. package/dist/store/endpoints/recent.d.ts.map +1 -0
  64. package/dist/store/endpoints/search.d.ts +36 -0
  65. package/dist/store/endpoints/search.d.ts.map +1 -0
  66. package/dist/store/endpoints/store-search.d.ts +16 -0
  67. package/dist/store/endpoints/store-search.d.ts.map +1 -0
  68. package/dist/store/endpoints/suggest.d.ts +11 -0
  69. package/dist/store/endpoints/suggest.d.ts.map +1 -0
  70. package/package.json +3 -3
  71. package/src/__tests__/controllers.test.ts +1026 -0
  72. package/src/__tests__/embedding-provider.test.ts +195 -0
  73. package/src/__tests__/endpoint-security.test.ts +300 -0
  74. package/src/__tests__/meilisearch-provider.test.ts +400 -0
  75. package/src/__tests__/service-impl.test.ts +341 -8
  76. package/src/admin/components/search-analytics.tsx +120 -0
  77. package/src/admin/endpoints/bulk-index.ts +34 -0
  78. package/src/admin/endpoints/click-analytics.ts +16 -0
  79. package/src/admin/endpoints/get-settings.ts +56 -0
  80. package/src/admin/endpoints/index-manage.ts +4 -1
  81. package/src/admin/endpoints/index.ts +6 -0
  82. package/src/admin/endpoints/synonyms.ts +1 -1
  83. package/src/embedding-provider.ts +99 -0
  84. package/src/index.ts +60 -4
  85. package/src/meilisearch-provider.ts +239 -0
  86. package/src/schema.ts +15 -0
  87. package/src/service-impl.ts +605 -34
  88. package/src/service.ts +60 -1
  89. package/src/store/endpoints/click.ts +21 -0
  90. package/src/store/endpoints/index.ts +2 -0
  91. package/src/store/endpoints/recent.ts +1 -1
  92. package/src/store/endpoints/search.ts +38 -10
  93. package/src/store/endpoints/store-search.ts +1 -1
  94. package/src/store/endpoints/suggest.ts +2 -2
  95. package/vitest.config.ts +2 -0
@@ -0,0 +1,400 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { MeiliSearchProvider } from "../meilisearch-provider";
3
+
4
+ const MOCK_HOST = "http://meili.test:7700";
5
+ const MOCK_KEY = "test-master-key-abc123";
6
+
7
+ // ── Realistic MeiliSearch API response fixtures ──────────────────────────
8
+
9
+ const taskResponse = {
10
+ taskUid: 42,
11
+ indexUid: "search",
12
+ status: "enqueued" as const,
13
+ type: "documentAdditionOrUpdate",
14
+ enqueuedAt: "2026-03-17T10:00:00.000Z",
15
+ };
16
+
17
+ const searchResponse = {
18
+ hits: [
19
+ {
20
+ id: "idx-001",
21
+ entityType: "product",
22
+ entityId: "prod-abc",
23
+ title: "Organic Cotton T-Shirt",
24
+ body: "Soft, breathable organic cotton tee. Available in multiple colors.",
25
+ tags: ["clothing", "organic", "cotton"],
26
+ url: "/products/organic-cotton-t-shirt",
27
+ image: "https://blob.store/images/tshirt.jpg",
28
+ indexedAt: "2026-03-15T08:30:00.000Z",
29
+ _formatted: {
30
+ title: "Organic Cotton <mark>T-Shirt</mark>",
31
+ body: "Soft, breathable organic cotton tee. Available in multiple colors.",
32
+ },
33
+ _rankingScore: 0.92,
34
+ },
35
+ {
36
+ id: "idx-002",
37
+ entityType: "product",
38
+ entityId: "prod-def",
39
+ title: "Vintage Band T-Shirt",
40
+ body: "Classic vintage-style band t-shirt with distressed print.",
41
+ tags: ["clothing", "vintage"],
42
+ url: "/products/vintage-band-t-shirt",
43
+ image: null,
44
+ indexedAt: "2026-03-14T12:00:00.000Z",
45
+ _formatted: {
46
+ title: "Vintage Band <mark>T-Shirt</mark>",
47
+ body: "Classic vintage-style band <mark>t-shirt</mark> with distressed print.",
48
+ },
49
+ _rankingScore: 0.78,
50
+ },
51
+ ],
52
+ query: "t-shirt",
53
+ processingTimeMs: 3,
54
+ estimatedTotalHits: 2,
55
+ facetDistribution: {
56
+ entityType: { product: 2 },
57
+ tags: { clothing: 2, organic: 1, cotton: 1, vintage: 1 },
58
+ },
59
+ };
60
+
61
+ const errorResponse = {
62
+ message: "Index `nonexistent` not found.",
63
+ code: "index_not_found",
64
+ type: "invalid_request",
65
+ link: "https://docs.meilisearch.com/errors#index_not_found",
66
+ };
67
+
68
+ const healthResponse = { status: "available" as const };
69
+
70
+ const statsResponse = {
71
+ numberOfDocuments: 156,
72
+ isIndexing: false,
73
+ fieldDistribution: {
74
+ id: 156,
75
+ entityType: 156,
76
+ title: 156,
77
+ body: 142,
78
+ tags: 156,
79
+ url: 156,
80
+ },
81
+ };
82
+
83
+ // ── Tests ────────────────────────────────────────────────────────────────
84
+
85
+ describe("MeiliSearchProvider", () => {
86
+ let provider: MeiliSearchProvider;
87
+ const mockFetch = vi.fn();
88
+
89
+ beforeEach(() => {
90
+ mockFetch.mockClear();
91
+ provider = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY, "search");
92
+ vi.stubGlobal("fetch", mockFetch);
93
+ });
94
+
95
+ afterEach(() => {
96
+ vi.restoreAllMocks();
97
+ });
98
+
99
+ // ── addDocuments ──────────────────────────────────────────────────
100
+
101
+ describe("addDocuments", () => {
102
+ it("sends documents to the correct endpoint with Bearer auth", async () => {
103
+ mockFetch.mockResolvedValueOnce({
104
+ ok: true,
105
+ status: 202,
106
+ json: () => Promise.resolve(taskResponse),
107
+ });
108
+
109
+ const docs = [
110
+ {
111
+ id: "idx-001",
112
+ entityType: "product",
113
+ entityId: "prod-abc",
114
+ title: "Organic Cotton T-Shirt",
115
+ tags: ["clothing"],
116
+ url: "/products/organic-cotton-t-shirt",
117
+ indexedAt: "2026-03-15T08:30:00.000Z",
118
+ },
119
+ ];
120
+
121
+ const result = await provider.addDocuments(docs);
122
+
123
+ expect(mockFetch).toHaveBeenCalledOnce();
124
+ const [url, opts] = mockFetch.mock.calls[0];
125
+ expect(url).toBe(`${MOCK_HOST}/indexes/search/documents`);
126
+ expect(opts.method).toBe("POST");
127
+ expect(opts.headers.Authorization).toBe(`Bearer ${MOCK_KEY}`);
128
+ expect(opts.headers["Content-Type"]).toBe("application/json");
129
+ expect(JSON.parse(opts.body)).toEqual(docs);
130
+ expect(result.taskUid).toBe(42);
131
+ expect(result.status).toBe("enqueued");
132
+ });
133
+
134
+ it("throws on API error with descriptive message", async () => {
135
+ mockFetch.mockResolvedValueOnce({
136
+ ok: false,
137
+ status: 404,
138
+ json: () => Promise.resolve(errorResponse),
139
+ });
140
+
141
+ await expect(provider.addDocuments([])).rejects.toThrow(
142
+ "MeiliSearch error: Index `nonexistent` not found. (index_not_found)",
143
+ );
144
+ });
145
+ });
146
+
147
+ // ── deleteDocument ────────────────────────────────────────────────
148
+
149
+ describe("deleteDocument", () => {
150
+ it("sends DELETE request for the document ID", async () => {
151
+ mockFetch.mockResolvedValueOnce({
152
+ ok: true,
153
+ status: 202,
154
+ json: () =>
155
+ Promise.resolve({
156
+ ...taskResponse,
157
+ type: "documentDeletion",
158
+ }),
159
+ });
160
+
161
+ const result = await provider.deleteDocument("idx-001");
162
+
163
+ const [url, opts] = mockFetch.mock.calls[0];
164
+ expect(url).toBe(`${MOCK_HOST}/indexes/search/documents/idx-001`);
165
+ expect(opts.method).toBe("DELETE");
166
+ expect(result.taskUid).toBe(42);
167
+ });
168
+
169
+ it("URL-encodes document IDs with special characters", async () => {
170
+ mockFetch.mockResolvedValueOnce({
171
+ ok: true,
172
+ status: 202,
173
+ json: () => Promise.resolve(taskResponse),
174
+ });
175
+
176
+ await provider.deleteDocument("id/with/slashes");
177
+
178
+ const [url] = mockFetch.mock.calls[0];
179
+ expect(url).toBe(
180
+ `${MOCK_HOST}/indexes/search/documents/id%2Fwith%2Fslashes`,
181
+ );
182
+ });
183
+ });
184
+
185
+ // ── search ───────────────────────────────────────────────────────
186
+
187
+ describe("search", () => {
188
+ it("sends POST request with query and returns hits", async () => {
189
+ mockFetch.mockResolvedValueOnce({
190
+ ok: true,
191
+ status: 200,
192
+ json: () => Promise.resolve(searchResponse),
193
+ });
194
+
195
+ const result = await provider.search("t-shirt", {
196
+ limit: 20,
197
+ offset: 0,
198
+ facets: ["entityType", "tags"],
199
+ attributesToHighlight: ["title", "body"],
200
+ highlightPreTag: "<mark>",
201
+ highlightPostTag: "</mark>",
202
+ showRankingScore: true,
203
+ });
204
+
205
+ const [url, opts] = mockFetch.mock.calls[0];
206
+ expect(url).toBe(`${MOCK_HOST}/indexes/search/search`);
207
+ expect(opts.method).toBe("POST");
208
+
209
+ const body = JSON.parse(opts.body);
210
+ expect(body.q).toBe("t-shirt");
211
+ expect(body.limit).toBe(20);
212
+ expect(body.facets).toEqual(["entityType", "tags"]);
213
+ expect(body.attributesToHighlight).toEqual(["title", "body"]);
214
+ expect(body.highlightPreTag).toBe("<mark>");
215
+ expect(body.showRankingScore).toBe(true);
216
+
217
+ expect(result.hits).toHaveLength(2);
218
+ expect(result.hits[0].title).toBe("Organic Cotton T-Shirt");
219
+ expect(result.hits[0]._formatted?.title).toBe(
220
+ "Organic Cotton <mark>T-Shirt</mark>",
221
+ );
222
+ expect(result.hits[0]._rankingScore).toBe(0.92);
223
+ expect(result.estimatedTotalHits).toBe(2);
224
+ expect(result.processingTimeMs).toBe(3);
225
+ expect(result.facetDistribution?.entityType).toEqual({ product: 2 });
226
+ });
227
+
228
+ it("passes filter and sort options", async () => {
229
+ mockFetch.mockResolvedValueOnce({
230
+ ok: true,
231
+ status: 200,
232
+ json: () => Promise.resolve({ ...searchResponse, hits: [] }),
233
+ });
234
+
235
+ await provider.search("shoes", {
236
+ filter: 'entityType = "product" AND (tags = "footwear")',
237
+ sort: ["indexedAt:desc"],
238
+ matchingStrategy: "last",
239
+ });
240
+
241
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
242
+ expect(body.filter).toBe(
243
+ 'entityType = "product" AND (tags = "footwear")',
244
+ );
245
+ expect(body.sort).toEqual(["indexedAt:desc"]);
246
+ expect(body.matchingStrategy).toBe("last");
247
+ });
248
+
249
+ it("sends minimal body when no options provided", async () => {
250
+ mockFetch.mockResolvedValueOnce({
251
+ ok: true,
252
+ status: 200,
253
+ json: () => Promise.resolve({ ...searchResponse, hits: [] }),
254
+ });
255
+
256
+ await provider.search("test");
257
+
258
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
259
+ expect(body).toEqual({ q: "test" });
260
+ });
261
+ });
262
+
263
+ // ── isHealthy ────────────────────────────────────────────────────
264
+
265
+ describe("isHealthy", () => {
266
+ it("returns true when MeiliSearch is available", async () => {
267
+ mockFetch.mockResolvedValueOnce({
268
+ ok: true,
269
+ status: 200,
270
+ json: () => Promise.resolve(healthResponse),
271
+ });
272
+
273
+ expect(await provider.isHealthy()).toBe(true);
274
+
275
+ const [url, opts] = mockFetch.mock.calls[0];
276
+ expect(url).toBe(`${MOCK_HOST}/health`);
277
+ expect(opts.method).toBe("GET");
278
+ });
279
+
280
+ it("returns false when MeiliSearch is unreachable", async () => {
281
+ mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
282
+
283
+ expect(await provider.isHealthy()).toBe(false);
284
+ });
285
+
286
+ it("returns false on non-200 response", async () => {
287
+ mockFetch.mockResolvedValueOnce({
288
+ ok: false,
289
+ status: 503,
290
+ json: () =>
291
+ Promise.resolve({ message: "Server unavailable", code: "503" }),
292
+ });
293
+
294
+ expect(await provider.isHealthy()).toBe(false);
295
+ });
296
+ });
297
+
298
+ // ── getStats ──────────────────────────────────────────────────────
299
+
300
+ describe("getStats", () => {
301
+ it("returns index statistics", async () => {
302
+ mockFetch.mockResolvedValueOnce({
303
+ ok: true,
304
+ status: 200,
305
+ json: () => Promise.resolve(statsResponse),
306
+ });
307
+
308
+ const stats = await provider.getStats();
309
+
310
+ expect(stats).not.toBeNull();
311
+ expect(stats?.numberOfDocuments).toBe(156);
312
+ expect(stats?.isIndexing).toBe(false);
313
+ expect(stats?.fieldDistribution.title).toBe(156);
314
+ });
315
+
316
+ it("returns null when stats endpoint fails", async () => {
317
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
318
+
319
+ expect(await provider.getStats()).toBeNull();
320
+ });
321
+ });
322
+
323
+ // ── configureIndex ───────────────────────────────────────────────
324
+
325
+ describe("configureIndex", () => {
326
+ it("sends PATCH request with filterable and sortable attributes", async () => {
327
+ mockFetch.mockResolvedValueOnce({
328
+ ok: true,
329
+ status: 202,
330
+ json: () => Promise.resolve(taskResponse),
331
+ });
332
+
333
+ await provider.configureIndex();
334
+
335
+ const [url, opts] = mockFetch.mock.calls[0];
336
+ expect(url).toBe(`${MOCK_HOST}/indexes/search/settings`);
337
+ expect(opts.method).toBe("PATCH");
338
+
339
+ const body = JSON.parse(opts.body);
340
+ expect(body.filterableAttributes).toContain("entityType");
341
+ expect(body.filterableAttributes).toContain("tags");
342
+ expect(body.sortableAttributes).toContain("indexedAt");
343
+ expect(body.searchableAttributes).toContain("title");
344
+ expect(body.searchableAttributes).toContain("body");
345
+ });
346
+
347
+ it("silently handles errors during index configuration", async () => {
348
+ mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
349
+
350
+ await expect(provider.configureIndex()).resolves.toBeUndefined();
351
+ });
352
+ });
353
+
354
+ // ── constructor ──────────────────────────────────────────────────
355
+
356
+ describe("constructor", () => {
357
+ it("strips trailing slash from host", async () => {
358
+ const p = new MeiliSearchProvider("http://meili.test:7700/", MOCK_KEY);
359
+ mockFetch.mockResolvedValueOnce({
360
+ ok: true,
361
+ status: 200,
362
+ json: () => Promise.resolve(healthResponse),
363
+ });
364
+
365
+ await p.isHealthy();
366
+
367
+ expect(mockFetch.mock.calls[0][0]).toBe("http://meili.test:7700/health");
368
+ });
369
+
370
+ it("uses custom index UID when provided", async () => {
371
+ const p = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY, "products");
372
+ mockFetch.mockResolvedValueOnce({
373
+ ok: true,
374
+ status: 200,
375
+ json: () => Promise.resolve({ ...searchResponse, hits: [] }),
376
+ });
377
+
378
+ await p.search("test");
379
+
380
+ expect(mockFetch.mock.calls[0][0]).toBe(
381
+ `${MOCK_HOST}/indexes/products/search`,
382
+ );
383
+ });
384
+
385
+ it("defaults index UID to 'search'", async () => {
386
+ const p = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY);
387
+ mockFetch.mockResolvedValueOnce({
388
+ ok: true,
389
+ status: 200,
390
+ json: () => Promise.resolve({ ...searchResponse, hits: [] }),
391
+ });
392
+
393
+ await p.search("test");
394
+
395
+ expect(mockFetch.mock.calls[0][0]).toBe(
396
+ `${MOCK_HOST}/indexes/search/search`,
397
+ );
398
+ });
399
+ });
400
+ });