80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createSearchClient: vi.fn(),
|
|
searchWithFallback: vi.fn(),
|
|
findKnowledgeBaseArticles: vi.fn(),
|
|
findKnowledgeBaseArticlesByIds: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@toolctrl/search", () => ({
|
|
createSearchClient: mocks.createSearchClient,
|
|
kbArticlesIndex: { name: "kb_articles", primaryKey: "id" },
|
|
searchWithFallback: mocks.searchWithFallback,
|
|
}));
|
|
|
|
vi.mock("../db/kbArticles.db.server", () => ({
|
|
findKnowledgeBaseArticles: mocks.findKnowledgeBaseArticles,
|
|
findKnowledgeBaseArticlesByIds: mocks.findKnowledgeBaseArticlesByIds,
|
|
}));
|
|
|
|
import { searchKnowledgeBaseArticles } from "./kbArticleSearch.server";
|
|
|
|
const originalEnv = { ...process.env };
|
|
|
|
describe("searchKnowledgeBaseArticles", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
process.env.MEILI_URL = "http://meili.test";
|
|
delete process.env.SEARCH_BACKEND;
|
|
mocks.createSearchClient.mockReturnValue({ index: vi.fn() });
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
it("hydrates Meilisearch hits by ID instead of scanning the full SQL search set", async () => {
|
|
const articleA = { id: "article_a", title: "A" };
|
|
const articleB = { id: "article_b", title: "B" };
|
|
|
|
mocks.searchWithFallback.mockResolvedValue({
|
|
hits: [{ document: { id: "article_b" } }, { document: { id: "article_a" } }],
|
|
estimatedTotalHits: 2,
|
|
processingTimeMs: 3,
|
|
});
|
|
mocks.findKnowledgeBaseArticlesByIds.mockResolvedValue([articleA, articleB]);
|
|
|
|
await expect(
|
|
searchKnowledgeBaseArticles({
|
|
knowledgeBaseSlug: "docs",
|
|
language: "de",
|
|
query: "billing",
|
|
tenantId: "tenant_1",
|
|
})
|
|
).resolves.toEqual([articleB, articleA]);
|
|
|
|
expect(mocks.findKnowledgeBaseArticlesByIds).toHaveBeenCalledWith({
|
|
knowledgeBaseSlug: "docs",
|
|
language: "de",
|
|
ids: ["article_b", "article_a"],
|
|
});
|
|
expect(mocks.findKnowledgeBaseArticles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("escapes the language value in the Meilisearch filter to prevent filter injection", async () => {
|
|
mocks.searchWithFallback.mockResolvedValue({ hits: [], estimatedTotalHits: 0, processingTimeMs: 1 });
|
|
|
|
await searchKnowledgeBaseArticles({
|
|
knowledgeBaseSlug: "docs",
|
|
// A crafted language attempting to break out of the quoted filter string.
|
|
language: 'de" OR isPublished = "true',
|
|
query: "billing",
|
|
tenantId: "tenant_1",
|
|
});
|
|
|
|
const passedOptions = mocks.searchWithFallback.mock.calls[0]?.[0]?.options;
|
|
expect(passedOptions.extraFilters).toBe('language = "de\\" OR isPublished = \\"true" AND isPublished = true');
|
|
});
|
|
});
|