import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MeiliSearchProvider } from "../meilisearch-provider";
const MOCK_HOST = "http://meili.test:7700";
const MOCK_KEY = "test-master-key-abc123";
// ── Realistic MeiliSearch API response fixtures ──────────────────────────
const taskResponse = {
taskUid: 42,
indexUid: "search",
status: "enqueued" as const,
type: "documentAdditionOrUpdate",
enqueuedAt: "2026-03-17T10:00:00.000Z",
};
const searchResponse = {
hits: [
{
id: "idx-001",
entityType: "product",
entityId: "prod-abc",
title: "Organic Cotton T-Shirt",
body: "Soft, breathable organic cotton tee. Available in multiple colors.",
tags: ["clothing", "organic", "cotton"],
url: "/products/organic-cotton-t-shirt",
image: "https://blob.store/images/tshirt.jpg",
indexedAt: "2026-03-15T08:30:00.000Z",
_formatted: {
title: "Organic Cotton T-Shirt",
body: "Soft, breathable organic cotton tee. Available in multiple colors.",
},
_rankingScore: 0.92,
},
{
id: "idx-002",
entityType: "product",
entityId: "prod-def",
title: "Vintage Band T-Shirt",
body: "Classic vintage-style band t-shirt with distressed print.",
tags: ["clothing", "vintage"],
url: "/products/vintage-band-t-shirt",
image: null,
indexedAt: "2026-03-14T12:00:00.000Z",
_formatted: {
title: "Vintage Band T-Shirt",
body: "Classic vintage-style band t-shirt with distressed print.",
},
_rankingScore: 0.78,
},
],
query: "t-shirt",
processingTimeMs: 3,
estimatedTotalHits: 2,
facetDistribution: {
entityType: { product: 2 },
tags: { clothing: 2, organic: 1, cotton: 1, vintage: 1 },
},
};
const errorResponse = {
message: "Index `nonexistent` not found.",
code: "index_not_found",
type: "invalid_request",
link: "https://docs.meilisearch.com/errors#index_not_found",
};
const healthResponse = { status: "available" as const };
const statsResponse = {
numberOfDocuments: 156,
isIndexing: false,
fieldDistribution: {
id: 156,
entityType: 156,
title: 156,
body: 142,
tags: 156,
url: 156,
},
};
// ── Tests ────────────────────────────────────────────────────────────────
describe("MeiliSearchProvider", () => {
let provider: MeiliSearchProvider;
const mockFetch = vi.fn();
beforeEach(() => {
mockFetch.mockClear();
provider = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY, "search");
vi.stubGlobal("fetch", mockFetch);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── addDocuments ──────────────────────────────────────────────────
describe("addDocuments", () => {
it("sends documents to the correct endpoint with Bearer auth", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve(taskResponse),
});
const docs = [
{
id: "idx-001",
entityType: "product",
entityId: "prod-abc",
title: "Organic Cotton T-Shirt",
tags: ["clothing"],
url: "/products/organic-cotton-t-shirt",
indexedAt: "2026-03-15T08:30:00.000Z",
},
];
const result = await provider.addDocuments(docs);
expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe(`${MOCK_HOST}/indexes/search/documents`);
expect(opts.method).toBe("POST");
expect(opts.headers.Authorization).toBe(`Bearer ${MOCK_KEY}`);
expect(opts.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(opts.body)).toEqual(docs);
expect(result.taskUid).toBe(42);
expect(result.status).toBe("enqueued");
});
it("throws on API error with descriptive message", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: () => Promise.resolve(errorResponse),
});
await expect(provider.addDocuments([])).rejects.toThrow(
"MeiliSearch error: Index `nonexistent` not found. (index_not_found)",
);
});
});
// ── deleteDocument ────────────────────────────────────────────────
describe("deleteDocument", () => {
it("sends DELETE request for the document ID", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () =>
Promise.resolve({
...taskResponse,
type: "documentDeletion",
}),
});
const result = await provider.deleteDocument("idx-001");
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe(`${MOCK_HOST}/indexes/search/documents/idx-001`);
expect(opts.method).toBe("DELETE");
expect(result.taskUid).toBe(42);
});
it("URL-encodes document IDs with special characters", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve(taskResponse),
});
await provider.deleteDocument("id/with/slashes");
const [url] = mockFetch.mock.calls[0];
expect(url).toBe(
`${MOCK_HOST}/indexes/search/documents/id%2Fwith%2Fslashes`,
);
});
});
// ── search ───────────────────────────────────────────────────────
describe("search", () => {
it("sends POST request with query and returns hits", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(searchResponse),
});
const result = await provider.search("t-shirt", {
limit: 20,
offset: 0,
facets: ["entityType", "tags"],
attributesToHighlight: ["title", "body"],
highlightPreTag: "",
highlightPostTag: "",
showRankingScore: true,
});
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe(`${MOCK_HOST}/indexes/search/search`);
expect(opts.method).toBe("POST");
const body = JSON.parse(opts.body);
expect(body.q).toBe("t-shirt");
expect(body.limit).toBe(20);
expect(body.facets).toEqual(["entityType", "tags"]);
expect(body.attributesToHighlight).toEqual(["title", "body"]);
expect(body.highlightPreTag).toBe("");
expect(body.showRankingScore).toBe(true);
expect(result.hits).toHaveLength(2);
expect(result.hits[0].title).toBe("Organic Cotton T-Shirt");
expect(result.hits[0]._formatted?.title).toBe(
"Organic Cotton T-Shirt",
);
expect(result.hits[0]._rankingScore).toBe(0.92);
expect(result.estimatedTotalHits).toBe(2);
expect(result.processingTimeMs).toBe(3);
expect(result.facetDistribution?.entityType).toEqual({ product: 2 });
});
it("passes filter and sort options", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...searchResponse, hits: [] }),
});
await provider.search("shoes", {
filter: 'entityType = "product" AND (tags = "footwear")',
sort: ["indexedAt:desc"],
matchingStrategy: "last",
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.filter).toBe(
'entityType = "product" AND (tags = "footwear")',
);
expect(body.sort).toEqual(["indexedAt:desc"]);
expect(body.matchingStrategy).toBe("last");
});
it("sends minimal body when no options provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...searchResponse, hits: [] }),
});
await provider.search("test");
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).toEqual({ q: "test" });
});
});
// ── isHealthy ────────────────────────────────────────────────────
describe("isHealthy", () => {
it("returns true when MeiliSearch is available", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(healthResponse),
});
expect(await provider.isHealthy()).toBe(true);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe(`${MOCK_HOST}/health`);
expect(opts.method).toBe("GET");
});
it("returns false when MeiliSearch is unreachable", async () => {
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
expect(await provider.isHealthy()).toBe(false);
});
it("returns false on non-200 response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
json: () =>
Promise.resolve({ message: "Server unavailable", code: "503" }),
});
expect(await provider.isHealthy()).toBe(false);
});
});
// ── getStats ──────────────────────────────────────────────────────
describe("getStats", () => {
it("returns index statistics", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(statsResponse),
});
const stats = await provider.getStats();
expect(stats).not.toBeNull();
expect(stats?.numberOfDocuments).toBe(156);
expect(stats?.isIndexing).toBe(false);
expect(stats?.fieldDistribution.title).toBe(156);
});
it("returns null when stats endpoint fails", async () => {
mockFetch.mockRejectedValueOnce(new Error("Network error"));
expect(await provider.getStats()).toBeNull();
});
});
// ── configureIndex ───────────────────────────────────────────────
describe("configureIndex", () => {
it("sends PATCH request with filterable and sortable attributes", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 202,
json: () => Promise.resolve(taskResponse),
});
await provider.configureIndex();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe(`${MOCK_HOST}/indexes/search/settings`);
expect(opts.method).toBe("PATCH");
const body = JSON.parse(opts.body);
expect(body.filterableAttributes).toContain("entityType");
expect(body.filterableAttributes).toContain("tags");
expect(body.sortableAttributes).toContain("indexedAt");
expect(body.searchableAttributes).toContain("title");
expect(body.searchableAttributes).toContain("body");
});
it("silently handles errors during index configuration", async () => {
mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(provider.configureIndex()).resolves.toBeUndefined();
});
});
// ── constructor ──────────────────────────────────────────────────
describe("constructor", () => {
it("strips trailing slash from host", async () => {
const p = new MeiliSearchProvider("http://meili.test:7700/", MOCK_KEY);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(healthResponse),
});
await p.isHealthy();
expect(mockFetch.mock.calls[0][0]).toBe("http://meili.test:7700/health");
});
it("uses custom index UID when provided", async () => {
const p = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY, "products");
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...searchResponse, hits: [] }),
});
await p.search("test");
expect(mockFetch.mock.calls[0][0]).toBe(
`${MOCK_HOST}/indexes/products/search`,
);
});
it("defaults index UID to 'search'", async () => {
const p = new MeiliSearchProvider(MOCK_HOST, MOCK_KEY);
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...searchResponse, hits: [] }),
});
await p.search("test");
expect(mockFetch.mock.calls[0][0]).toBe(
`${MOCK_HOST}/indexes/search/search`,
);
});
});
});