236 lines
9.0 KiB
TypeScript
236 lines
9.0 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
const getAllKnowledgeBaseArticlesMock = vi.fn();
|
|
const createKnowledgeBaseArticleMock = vi.fn();
|
|
const getAllKnowledgeBaseCategoriesMock = vi.fn();
|
|
const getFixedTMock = vi.fn();
|
|
const knowledgeBaseArticleCountMock = vi.fn();
|
|
const knowledgeBaseCategoryCountMock = vi.fn();
|
|
const knowledgeBaseDeleteMock = vi.fn();
|
|
const getKnowledgeBaseByIdMock = vi.fn();
|
|
const cachifiedMock = vi.fn();
|
|
|
|
vi.mock("../db/kbArticles.db.server", () => ({
|
|
createKnowledgeBaseArticle: (...args: unknown[]) => createKnowledgeBaseArticleMock(...args),
|
|
getAllKnowledgeBaseArticles: (...args: unknown[]) => getAllKnowledgeBaseArticlesMock(...args),
|
|
getFeaturedKnowledgeBaseArticles: vi.fn(),
|
|
getKbArticleById: vi.fn(),
|
|
getKbArticleByIdInKnowledgeBase: vi.fn(),
|
|
getKbArticleBySlug: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../db/kbCategories.db.server", () => ({
|
|
createKnowledgeBaseCategory: vi.fn(),
|
|
getAllKnowledgeBaseCategories: (...args: unknown[]) => getAllKnowledgeBaseCategoriesMock(...args),
|
|
getKbCategoryById: vi.fn(),
|
|
getKbCategoryBySlug: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../db/knowledgeBase.db.server", () => ({
|
|
getAllKnowledgeBases: vi.fn(),
|
|
getKnowledgeBaseById: (...args: unknown[]) => getKnowledgeBaseByIdMock(...args),
|
|
getKnowledgeBaseBySlug: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../services/kbArticleSearch.server", () => ({
|
|
searchKnowledgeBaseArticles: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/locale/i18next.server", () => ({
|
|
remixI18Next: {
|
|
getFixedT: (...args: unknown[]) => getFixedTMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/cache.server", () => ({
|
|
cachified: (...args: unknown[]) => cachifiedMock(...args),
|
|
}));
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
knowledgeBaseArticle: { count: (...args: unknown[]) => knowledgeBaseArticleCountMock(...args) },
|
|
knowledgeBaseCategory: { count: (...args: unknown[]) => knowledgeBaseCategoryCountMock(...args) },
|
|
knowledgeBase: { delete: (...args: unknown[]) => knowledgeBaseDeleteMock(...args) },
|
|
},
|
|
}));
|
|
|
|
describe("KnowledgeBaseServiceServer", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getAllKnowledgeBaseArticlesMock.mockResolvedValue([
|
|
{
|
|
id: "article-1",
|
|
slug: "untitled",
|
|
order: 1,
|
|
},
|
|
]);
|
|
getAllKnowledgeBaseCategoriesMock.mockResolvedValue([]);
|
|
createKnowledgeBaseArticleMock.mockImplementation(async (input) => ({ id: "created", ...input }));
|
|
getFixedTMock.mockImplementation(async (locale: string) => (key: string, vars?: Record<string, unknown>) => `${locale}:${key}:${vars?.number ?? ""}`);
|
|
knowledgeBaseArticleCountMock.mockResolvedValue(0);
|
|
knowledgeBaseCategoryCountMock.mockResolvedValue(0);
|
|
knowledgeBaseDeleteMock.mockResolvedValue({ id: "kb-1" });
|
|
getKnowledgeBaseByIdMock.mockResolvedValue(null);
|
|
cachifiedMock.mockImplementation(async ({ getFreshValue }: { getFreshValue: () => unknown }) => getFreshValue());
|
|
});
|
|
|
|
test("newArticle localizes generated untitled titles and persists the resolved language", async () => {
|
|
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
|
|
|
|
const created = await KnowledgeBaseServiceServer.newArticle({
|
|
kb: {
|
|
id: "kb-1",
|
|
slug: "docs",
|
|
defaultLanguage: "de",
|
|
} as never,
|
|
params: {},
|
|
userId: "user-1",
|
|
position: "last",
|
|
});
|
|
|
|
expect(getFixedTMock).toHaveBeenCalledWith("de", "translations");
|
|
expect(createKnowledgeBaseArticleMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" })
|
|
);
|
|
expect(created).toMatchObject({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" });
|
|
});
|
|
|
|
test("public article DTOs never expose draft content or author identity", async () => {
|
|
const publishedAt = new Date("2026-07-14T00:00:00.000Z");
|
|
getAllKnowledgeBaseArticlesMock.mockResolvedValueOnce([
|
|
{
|
|
id: "article-public",
|
|
createdAt: publishedAt,
|
|
updatedAt: publishedAt,
|
|
slug: "published",
|
|
title: "Published",
|
|
description: "Public description",
|
|
order: 0,
|
|
contentDraft: "private draft",
|
|
contentPublished: "public content",
|
|
contentPublishedAsText: "public content",
|
|
contentType: "markdown",
|
|
language: "de",
|
|
featuredOrder: null,
|
|
createdByUser: { firstName: "Private", lastName: "Author", avatar: null },
|
|
publishedAt,
|
|
sectionId: null,
|
|
relatedArticles: [],
|
|
seoImage: "",
|
|
},
|
|
]);
|
|
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
|
|
const kb = {
|
|
id: "kb-public",
|
|
slug: "docs",
|
|
title: "Docs",
|
|
defaultLanguage: "de",
|
|
} as never;
|
|
|
|
const articles = await runWithRlsContext({ publicKnowledgeBaseId: "kb-public" }, () =>
|
|
KnowledgeBaseServiceServer.getArticles({
|
|
kb,
|
|
categoryId: "category-public",
|
|
language: "de",
|
|
query: undefined,
|
|
params: {},
|
|
request: new Request("https://example.test/docs"),
|
|
})
|
|
);
|
|
|
|
expect(articles).toHaveLength(1);
|
|
expect(articles[0]).toMatchObject({
|
|
contentDraft: "",
|
|
contentPublished: "public content",
|
|
createdByUser: null,
|
|
});
|
|
});
|
|
|
|
test("public KB lookup re-reads the capability-bound id instead of using the shared slug cache", async () => {
|
|
getKnowledgeBaseByIdMock.mockResolvedValueOnce({
|
|
id: "kb-public",
|
|
createdAt: new Date("2026-07-14T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-14T00:00:00.000Z"),
|
|
basePath: "/docs",
|
|
slug: "docs",
|
|
title: "Docs",
|
|
description: "Public docs",
|
|
defaultLanguage: "de",
|
|
layout: "docs",
|
|
color: 0,
|
|
enabled: true,
|
|
languages: '["de"]',
|
|
links: "[]",
|
|
logo: "",
|
|
seoImage: "",
|
|
_count: { categories: 1, articles: 1, views: 0 },
|
|
});
|
|
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
|
|
|
|
await expect(
|
|
runWithRlsContext({ publicKnowledgeBaseId: "kb-public" }, () =>
|
|
KnowledgeBaseServiceServer.get({ slug: "docs", enabled: true, request: new Request("https://example.test/docs") })
|
|
)
|
|
).resolves.toMatchObject({ id: "kb-public", slug: "docs", enabled: true });
|
|
|
|
expect(getKnowledgeBaseByIdMock).toHaveBeenCalledWith("kb-public");
|
|
expect(cachifiedMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("del exposes coded delete guard errors for localized route handling", async () => {
|
|
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
|
|
knowledgeBaseArticleCountMock.mockResolvedValueOnce(1);
|
|
|
|
try {
|
|
await KnowledgeBaseServiceServer.del({ id: "kb-1" } as never);
|
|
throw new Error("Expected delete to reject");
|
|
} catch (error) {
|
|
expect(error).toMatchObject({ code: "cannot-delete-with-articles" });
|
|
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.admin.errors.cannotDeleteWithArticles");
|
|
}
|
|
});
|
|
|
|
test("duplicate and new article validation errors map to semantic i18n keys", async () => {
|
|
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
|
|
|
|
await expect(
|
|
KnowledgeBaseServiceServer.duplicateArticle({ kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never, language: "en", articleId: "missing" })
|
|
).rejects.toMatchObject({ code: "invalid-article" });
|
|
|
|
try {
|
|
await KnowledgeBaseServiceServer.newArticle({
|
|
kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never,
|
|
params: {},
|
|
userId: "user-1",
|
|
position: "last",
|
|
categoryId: "missing",
|
|
});
|
|
} catch (error) {
|
|
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.adminCategories.errors.categoryNotFound");
|
|
}
|
|
});
|
|
|
|
test("public lookup errors map to public i18n keys", async () => {
|
|
const { default: KnowledgeBaseServiceServer, getKnowledgeBasePublicErrorKey } = await import("./KnowledgeBaseService.server");
|
|
cachifiedMock.mockResolvedValueOnce(null);
|
|
|
|
try {
|
|
await KnowledgeBaseServiceServer.get({ slug: "missing", enabled: true, request: new Request("https://example.com/help") });
|
|
throw new Error("Expected lookup to reject");
|
|
} catch (error) {
|
|
expect(error).toMatchObject({ code: "not-found" });
|
|
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.notFound");
|
|
}
|
|
|
|
cachifiedMock.mockResolvedValueOnce({ title: "Draft docs", enabled: false });
|
|
try {
|
|
await KnowledgeBaseServiceServer.get({ slug: "draft", enabled: true, request: new Request("https://example.com/help") });
|
|
throw new Error("Expected disabled knowledge base to reject");
|
|
} catch (error) {
|
|
expect(error).toMatchObject({ code: "disabled" });
|
|
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.disabled");
|
|
}
|
|
});
|
|
});
|