From 3e96605d4c9a3120f522d57ce4d6abb5d40c9753 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 4 Jul 2026 19:42:41 +0200 Subject: [PATCH] fix(web): avoid retesting new AI providers --- .../components/ai-section.test.tsx | 169 ++++++++++++++++++ .../integrations/components/ai-section.tsx | 25 ++- 2 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/features/settings/integrations/components/ai-section.test.tsx diff --git a/apps/web/src/features/settings/integrations/components/ai-section.test.tsx b/apps/web/src/features/settings/integrations/components/ai-section.test.tsx new file mode 100644 index 000000000..f668cb376 --- /dev/null +++ b/apps/web/src/features/settings/integrations/components/ai-section.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; + +type MutationName = "create" | "test" | "update" | "delete"; + +type MockProvider = { + id: string; + label: string; + provider: "openai"; + model: string; + baseURL: string; + enabled: boolean; + testStatus: string; + testError: string | null; + apiKeyPreview: string; + apiKeyFingerprint: string; + lastTestedAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +type MutationOptions = { + name: MutationName; + meta?: { noInvalidate?: boolean }; +}; + +type ComboboxProps = { + id: string; + value: string; + options: { value: string; label: string }[]; + onValueChange: (value: string) => void; +}; + +const queryClient = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), +})); + +const mutations = vi.hoisted(() => ({ + create: vi.fn(), + test: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +})); + +const mutationOptions = vi.hoisted(() => ({ + create: vi.fn((options?: MutationOptions) => ({ ...options, name: "create" })), + test: vi.fn((options?: MutationOptions) => ({ ...options, name: "test" })), + update: vi.fn((options?: MutationOptions) => ({ ...options, name: "update" })), + delete: vi.fn((options?: MutationOptions) => ({ ...options, name: "delete" })), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: [], isLoading: false, error: null }), + useQueryClient: () => queryClient, + useMutation: (options: MutationOptions) => ({ + isPending: false, + mutate: ( + input: unknown, + handlers?: { onSuccess?: (data: unknown) => void; onError?: (error: unknown) => void }, + ) => { + mutations[options.name](input).then(handlers?.onSuccess).catch(handlers?.onError); + }, + mutateAsync: mutations[options.name], + }), +})); + +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + aiProviders: { + list: { + queryOptions: () => ({ queryKey: ["aiProviders", "list"] }), + queryKey: () => ["aiProviders", "list"], + }, + create: { mutationOptions: mutationOptions.create }, + test: { mutationOptions: mutationOptions.test }, + update: { mutationOptions: mutationOptions.update }, + delete: { mutationOptions: mutationOptions.delete }, + }, + }, +})); + +vi.mock("@/components/ui/combobox", () => ({ + Combobox: ({ id, value, options, onValueChange }: ComboboxProps) => ( + + ), +})); + +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +i18n.loadAndActivate({ locale: "en", messages: {} }); + +const { AISettingsSection } = await import("./ai-section"); + +const provider = (overrides: Partial): MockProvider => ({ + id: "provider-1", + label: "OpenAI", + provider: "openai", + model: "gpt-4.1", + baseURL: "https://api.openai.com/v1", + enabled: false, + testStatus: "untested", + testError: null, + apiKeyPreview: "sk-p...test", + apiKeyFingerprint: "fingerprint", + lastTestedAt: null, + lastUsedAt: null, + createdAt: new Date("2026-07-04T00:00:00Z"), + updatedAt: new Date("2026-07-04T00:00:00Z"), + ...overrides, +}); + +const renderSection = () => + render( + + + , + ); + +describe("AISettingsSection", () => { + beforeEach(() => { + queryClient.invalidateQueries.mockReset(); + queryClient.setQueryData.mockReset(); + mutationOptions.create.mockClear(); + mutationOptions.test.mockClear(); + mutationOptions.update.mockClear(); + mutationOptions.delete.mockClear(); + mutations.create.mockReset(); + mutations.test.mockReset(); + mutations.update.mockReset(); + mutations.delete.mockReset(); + }); + + it("uses the save-and-test result as the connected provider row", async () => { + const created = provider({}); + const tested = provider({ enabled: true, testStatus: "success", lastTestedAt: new Date("2026-07-04T01:00:00Z") }); + + mutations.create.mockResolvedValue(created); + mutations.test.mockResolvedValue(tested); + + renderSection(); + + fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "sk-test" } }); + fireEvent.click(screen.getByRole("button", { name: /save & test provider/i })); + + await waitFor(() => expect(queryClient.setQueryData).toHaveBeenCalled()); + + expect(mutationOptions.create).toHaveBeenCalledWith({ meta: { noInvalidate: true } }); + expect(mutationOptions.test).toHaveBeenCalledWith({ meta: { noInvalidate: true } }); + expect(mutations.update).not.toHaveBeenCalled(); + + const [, updater] = queryClient.setQueryData.mock.calls.at(-1) as [ + unknown, + (providers: MockProvider[]) => MockProvider[], + ]; + expect(updater([created])).toEqual([tested]); + }); +}); diff --git a/apps/web/src/features/settings/integrations/components/ai-section.tsx b/apps/web/src/features/settings/integrations/components/ai-section.tsx index 12627ebc1..9326a9939 100644 --- a/apps/web/src/features/settings/integrations/components/ai-section.tsx +++ b/apps/web/src/features/settings/integrations/components/ai-section.tsx @@ -122,6 +122,13 @@ function providerLabel(provider: AIProvider) { return providerOptions.find((option) => option.value === provider)?.label ?? provider; } +function upsertProvider(providers: SavedProvider[] | undefined, provider: SavedProvider) { + if (!providers) return [provider]; + if (!providers.some((entry) => entry.id === provider.id)) return [...providers, provider]; + + return providers.map((entry) => (entry.id === provider.id ? provider : entry)); +} + function isAiProviderConfigError(error: unknown) { if (error instanceof ORPCError && error.code === "PRECONDITION_FAILED") return true; @@ -247,11 +254,18 @@ function CreateProviderForm() { [form.provider], ); const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() }); + const setProviderInCache = (provider: SavedProvider) => + queryClient.setQueryData(orpc.aiProviders.list.queryKey(), (providers: SavedProvider[] | undefined) => + upsertProvider(providers, provider), + ); - const { mutateAsync: createProvider, isPending: isCreating } = useMutation(orpc.aiProviders.create.mutationOptions()); - const { mutateAsync: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions()); - const { mutateAsync: enableProvider, isPending: isEnabling } = useMutation(orpc.aiProviders.update.mutationOptions()); - const isSaving = isCreating || isTesting || isEnabling; + const { mutateAsync: createProvider, isPending: isCreating } = useMutation( + orpc.aiProviders.create.mutationOptions({ meta: { noInvalidate: true } }), + ); + const { mutateAsync: testProvider, isPending: isTesting } = useMutation( + orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }), + ); + const isSaving = isCreating || isTesting; // Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save. const model = form.model.trim(); @@ -271,8 +285,9 @@ function CreateProviderForm() { // Test on save: verify the connection immediately instead of leaving it to a manual step. const tested = await testProvider({ id: created.id }); + setProviderInCache(tested); + if (tested.testStatus === "success") { - await enableProvider({ id: created.id, enabled: true }); setForm(emptyForm); setResult({ ok: true, message: t`Connection verified — provider is ready to use.` }); } else {