MUST-FIX - isSourceUniqueViolation read the wrong error field: kysely-postgres-js (postgres@3.4.8) puts the violated constraint on `constraint_name`, not node-postgres' `.constraint`, so a concurrent same-slug+language import's 23505 was never recognized as a source-collision and surfaced a false "name already exists" error. Now read `constraint_name` (with `.constraint` as a fallback for other drivers). Fix the faked test fixture (it built the error with the same wrong `.constraint` field, masking the bug): it now uses `constraint_name`, so the test genuinely exercises the skip path and FAILS against the unfixed code. - Extract the catalog modal's role-state computation into a pure `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors role-launch.ts) and cover it with vitest: import / installed / update / same-slug-different-language. SUGGESTIONS - Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring the server; narrow the consumer via `"reason" in result` (the boolean discriminant does not narrow under strictNullChecks:false). - README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL (remote http(s) base / local path / empty => in-repo folder). - check.mjs: drop the redundant `const key = slug` alias. - Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation (4 branches) via renderHook with a mocked service. - Cover importFromCatalog "bundle not in index" => BadGateway. - Cover updateFromCatalog "slug in index but missing in bundle file" => not-in-catalog. ARCHITECTURE - Extract the shared catalog read prefix: a private `loadBundleById` (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the import insert and update patch. The three orchestrations and their distinct write paths stay separate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import React from "react";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
|
|
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
|
|
// a user-facing notification message. These tests pin each of the four branches
|
|
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
|
|
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
|
|
|
|
const notificationsShowMock = vi.fn();
|
|
vi.mock("@mantine/notifications", () => ({
|
|
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
|
|
}));
|
|
|
|
// `t` echoes the key so we assert against the exact English message strings.
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
|
updateAiRoleFromCatalog: vi.fn(),
|
|
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
|
|
// the module import resolves; they are unused by these tests.
|
|
createAiRole: vi.fn(),
|
|
deleteAiChat: vi.fn(),
|
|
deleteAiRole: vi.fn(),
|
|
getAiChatMessages: vi.fn(),
|
|
getAiChats: vi.fn(),
|
|
getAiRoleCatalog: vi.fn(),
|
|
getAiRoleCatalogBundle: vi.fn(),
|
|
getAiRoles: vi.fn(),
|
|
importAiRolesFromCatalog: vi.fn(),
|
|
renameAiChat: vi.fn(),
|
|
updateAiRole: vi.fn(),
|
|
}));
|
|
|
|
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
|
|
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
}
|
|
|
|
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
|
|
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
|
|
const { result: hook } = renderHook(
|
|
() => useUpdateAiRoleFromCatalogMutation(),
|
|
{ wrapper: createWrapper() },
|
|
);
|
|
hook.current.mutate("role-1");
|
|
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
|
|
}
|
|
|
|
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("updated:true -> 'Updated to the latest version'", async () => {
|
|
await runMutation({
|
|
updated: true,
|
|
fromVersion: 1,
|
|
toVersion: 2,
|
|
role: { id: "role-1" } as never,
|
|
});
|
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
|
message: "Updated to the latest version",
|
|
});
|
|
});
|
|
|
|
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
|
|
await runMutation({ updated: false, reason: "not-in-catalog" });
|
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
|
message: "This role is no longer in the catalog",
|
|
});
|
|
});
|
|
|
|
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
|
|
await runMutation({ updated: false, reason: "language-unavailable" });
|
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
|
message: "This language is no longer available in the catalog",
|
|
});
|
|
});
|
|
|
|
it("up-to-date -> 'Already up to date'", async () => {
|
|
await runMutation({ updated: false, reason: "up-to-date" });
|
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
|
message: "Already up to date",
|
|
});
|
|
});
|
|
});
|