test(site): cover provider delete cascade

This commit is contained in:
Tracy Johnson
2026-05-29 16:20:14 +00:00
parent eef05e39ef
commit 55c4f24511
8 changed files with 453 additions and 68 deletions
@@ -1,7 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, userEvent, within } from "storybook/test";
import {
expect,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import type { AIProvider } from "#/api/typesGenerated";
import { API } from "#/api/api";
import { chatModelConfigs } from "#/api/queries/chats";
import type { AIProvider, ChatModelConfig } from "#/api/typesGenerated";
import {
MockAIProviderAnthropic,
MockAIProviderBedrock,
@@ -19,8 +28,58 @@ const routingFor = (path: string) =>
],
});
const seed = (provider: AIProvider) => ({
queries: [{ key: ["ai", "providers", provider.name], data: provider }],
const model = (
overrides: Partial<ChatModelConfig> &
Pick<ChatModelConfig, "id" | "provider" | "model">,
): ChatModelConfig => ({
id: overrides.id,
provider: overrides.provider,
ai_provider_id: overrides.ai_provider_id,
model: overrides.model,
display_name: overrides.display_name ?? overrides.model,
enabled: overrides.enabled ?? true,
is_default: overrides.is_default ?? false,
context_limit: overrides.context_limit ?? 200000,
compression_threshold: overrides.compression_threshold ?? 70,
model_config: overrides.model_config,
created_at: overrides.created_at ?? "2026-05-14T10:00:00Z",
updated_at: overrides.updated_at ?? "2026-05-14T10:00:00Z",
});
const seed = (
provider: AIProvider,
models: readonly ChatModelConfig[] = [],
) => ({
queries: [
{ key: ["ai", "providers", provider.name], data: provider },
{ key: chatModelConfigs().queryKey, data: models },
],
});
const openAIAssociatedModels = [
model({
id: "model-openai-default",
provider: "openai",
ai_provider_id: MockAIProviderOpenAI.id,
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
}),
model({
id: "model-openai-secondary",
provider: "openai",
ai_provider_id: MockAIProviderOpenAI.id,
model: "gpt-4o-mini",
display_name: "GPT-4o Mini",
}),
] satisfies readonly ChatModelConfig[];
const anthropicFallbackModel = model({
id: "model-anthropic-fallback",
provider: "anthropic",
ai_provider_id: MockAIProviderAnthropic.id,
model: "claude-sonnet-4",
display_name: "Claude Sonnet 4",
});
const meta: Meta<typeof UpdateProviderPageView> = {
@@ -71,9 +130,120 @@ export const DeleteDialogOpen: Story = {
name: /^delete$/i,
});
await userEvent.click(deleteButton);
// DeleteDialog renders via Radix portal, so search the document, not
// The dialog renders via Radix portal, so search the document, not
// just the story canvas.
await expect(await screen.findByRole("dialog")).toBeInTheDocument();
await expect(await screen.findByText(/irreversible/i)).toBeInTheDocument();
},
};
export const DeleteDialogWithAssociatedModels: Story = {
parameters: {
reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`),
...seed(MockAIProviderOpenAI, openAIAssociatedModels),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", { name: /^delete$/i }),
);
await expect(
await screen.findByText(/Deleting this provider will also disable/i),
).toBeInTheDocument();
await expect(screen.getByText("2 models")).toBeInTheDocument();
},
};
export const DeleteDialogCascadeConfirmed: Story = {
parameters: {
reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`),
...seed(MockAIProviderOpenAI, [
...openAIAssociatedModels,
anthropicFallbackModel,
]),
},
beforeEach: () => {
spyOn(API.experimental, "updateChatModelConfig").mockResolvedValue(
anthropicFallbackModel,
);
spyOn(API, "deleteAIProvider").mockResolvedValue(undefined);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", { name: /^delete$/i }),
);
await userEvent.type(
await screen.findByLabelText(
`Type ${MockAIProviderOpenAI.name} to confirm`,
),
MockAIProviderOpenAI.name,
);
await userEvent.click(
screen.getByRole("button", { name: "Delete provider" }),
);
await waitFor(() => {
expect(API.experimental.updateChatModelConfig).toHaveBeenCalledTimes(3);
});
expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith(
1,
"model-openai-default",
{ enabled: false },
);
expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith(
2,
"model-openai-secondary",
{ enabled: false },
);
expect(API.experimental.updateChatModelConfig).toHaveBeenNthCalledWith(
3,
"model-anthropic-fallback",
{ is_default: true },
);
await waitFor(() => {
expect(API.deleteAIProvider).toHaveBeenCalledWith(
MockAIProviderOpenAI.name,
);
});
},
};
export const DeleteDialogCascadeFailure: Story = {
parameters: {
reactRouter: routingFor(`/ai/settings/${MockAIProviderOpenAI.name}`),
...seed(MockAIProviderOpenAI, [
...openAIAssociatedModels,
anthropicFallbackModel,
]),
},
beforeEach: () => {
spyOn(API.experimental, "updateChatModelConfig")
.mockResolvedValueOnce(anthropicFallbackModel)
.mockRejectedValueOnce(new Error("Failed to disable model."));
spyOn(API, "deleteAIProvider").mockResolvedValue(undefined);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", { name: /^delete$/i }),
);
await userEvent.type(
await screen.findByLabelText(
`Type ${MockAIProviderOpenAI.name} to confirm`,
),
MockAIProviderOpenAI.name,
);
await userEvent.click(
screen.getByRole("button", { name: "Delete provider" }),
);
await waitFor(() => {
expect(API.experimental.updateChatModelConfig).toHaveBeenCalledTimes(2);
});
expect(API.deleteAIProvider).not.toHaveBeenCalled();
await expect(await screen.findByRole("dialog")).toBeInTheDocument();
await expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
},
};
@@ -21,6 +21,7 @@ import { Button } from "#/components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -30,6 +31,7 @@ import { Loader } from "#/components/Loader/Loader";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { cascadeDisableProviderModels } from "#/pages/AISettingsPage/utils/providerDelete";
import { pageTitle } from "#/utils/page";
import { ProviderForm } from "../components/ProviderForm";
import { getProviderIcon } from "../components/ProviderIcon";
@@ -247,11 +249,11 @@ const UpdateProviderPageView: React.FC = () => {
<DialogContent variant="destructive">
<DialogHeader>
<DialogTitle>Delete provider</DialogTitle>
<DialogDescription>
Deleting this provider is irreversible!
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 text-sm text-content-secondary">
<p className="m-0 font-medium text-content-destructive">
Deleting this provider is irreversible!
</p>
{associatedModelCount > 0 && (
<ul className="m-0 pl-5">
<li>
@@ -306,31 +308,12 @@ const UpdateProviderPageView: React.FC = () => {
const deleteAll = async () => {
setIsCascadeDeleting(true);
try {
const hadDefault = associatedModels.some(
(mc) => mc.is_default,
);
const disabledIds = new Set(
associatedModels.map((mc) => mc.id),
);
for (const mc of associatedModels) {
await API.experimental.updateChatModelConfig(mc.id, {
enabled: false,
});
}
if (hadDefault) {
const allModels = modelConfigsQuery.data ?? [];
const newDefault = allModels.find(
(mc) => mc.enabled && !disabledIds.has(mc.id),
);
if (newDefault) {
await API.experimental.updateChatModelConfig(
newDefault.id,
{
is_default: true,
},
);
}
}
await cascadeDisableProviderModels({
associatedModels,
allModels: modelConfigsQuery.data ?? [],
updateModelConfig:
API.experimental.updateChatModelConfig,
});
await invalidateChatConfigurationQueries(queryClient);
deleteMutation.mutate(undefined, {
onSuccess: () => {
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import { cascadeDisableProviderModels } from "./providerDelete";
const model = (
overrides: Partial<TypesGen.ChatModelConfig> &
Pick<TypesGen.ChatModelConfig, "id" | "provider" | "model">,
): TypesGen.ChatModelConfig => ({
id: overrides.id,
provider: overrides.provider,
ai_provider_id: overrides.ai_provider_id,
model: overrides.model,
display_name: overrides.display_name ?? overrides.model,
enabled: overrides.enabled ?? true,
is_default: overrides.is_default ?? false,
context_limit: overrides.context_limit ?? 200000,
compression_threshold: overrides.compression_threshold ?? 70,
model_config: overrides.model_config,
created_at: overrides.created_at ?? "2026-02-18T12:00:00.000Z",
updated_at: overrides.updated_at ?? "2026-02-18T12:00:00.000Z",
});
describe("cascadeDisableProviderModels", () => {
it("disables associated models before reassigning the default", async () => {
const associatedModels = [
model({
id: "model-associated-default",
provider: "openai",
model: "gpt-4o",
is_default: true,
}),
model({
id: "model-associated-secondary",
provider: "openai",
model: "gpt-4o-mini",
}),
];
const allModels = [
...associatedModels,
model({
id: "model-next-default",
provider: "anthropic",
model: "claude-sonnet-4",
}),
];
const updateModelConfig = vi.fn(async () => undefined);
await cascadeDisableProviderModels({
associatedModels,
allModels,
updateModelConfig,
});
expect(updateModelConfig).toHaveBeenNthCalledWith(
1,
"model-associated-default",
{ enabled: false },
);
expect(updateModelConfig).toHaveBeenNthCalledWith(
2,
"model-associated-secondary",
{ enabled: false },
);
expect(updateModelConfig).toHaveBeenNthCalledWith(3, "model-next-default", {
is_default: true,
});
});
it("does not reassign the default when the deleted provider had no default", async () => {
const updateModelConfig = vi.fn(async () => undefined);
await cascadeDisableProviderModels({
associatedModels: [
model({
id: "model-associated",
provider: "openai",
model: "gpt-4o",
}),
],
allModels: [
model({
id: "model-associated",
provider: "openai",
model: "gpt-4o",
}),
model({
id: "model-other-default",
provider: "anthropic",
model: "claude-sonnet-4",
is_default: true,
}),
],
updateModelConfig,
});
expect(updateModelConfig).toHaveBeenCalledTimes(1);
expect(updateModelConfig).toHaveBeenCalledWith("model-associated", {
enabled: false,
});
});
it("stops before reassigning the default when a model disable fails", async () => {
const error = new Error("failed to disable model");
const updateModelConfig = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(error);
await expect(
cascadeDisableProviderModels({
associatedModels: [
model({
id: "model-associated-default",
provider: "openai",
model: "gpt-4o",
is_default: true,
}),
model({
id: "model-associated-secondary",
provider: "openai",
model: "gpt-4o-mini",
}),
],
allModels: [
model({
id: "model-next-default",
provider: "anthropic",
model: "claude-sonnet-4",
}),
],
updateModelConfig,
}),
).rejects.toThrow(error);
expect(updateModelConfig).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,34 @@
import type * as TypesGen from "#/api/typesGenerated";
type UpdateModelConfig = (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
export const cascadeDisableProviderModels = async ({
associatedModels,
allModels,
updateModelConfig,
}: {
associatedModels: readonly TypesGen.ChatModelConfig[];
allModels: readonly TypesGen.ChatModelConfig[];
updateModelConfig: UpdateModelConfig;
}) => {
const disabledIds = new Set(associatedModels.map((model) => model.id));
const hadDefault = associatedModels.some((model) => model.is_default);
for (const model of associatedModels) {
await updateModelConfig(model.id, { enabled: false });
}
if (!hadDefault) {
return;
}
const newDefault = allModels.find(
(model) => model.enabled && !disabledIds.has(model.id),
);
if (newDefault) {
await updateModelConfig(newDefault.id, { is_default: true });
}
};
@@ -2264,6 +2264,23 @@ export const ProviderDeleteConfirmation: Story = {
has_api_key: true,
}),
],
modelConfigsData: [
createModelConfig({
id: "model-openai-default",
provider: "openai",
ai_provider_id: "provider-openai",
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
}),
createModelConfig({
id: "model-openai-secondary",
provider: "openai",
ai_provider_id: "provider-openai",
model: "gpt-4o-mini",
display_name: "GPT-4o Mini",
}),
],
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
@@ -2278,8 +2295,9 @@ export const ProviderDeleteConfirmation: Story = {
// The confirmation dialog should appear - leave it visible
// so the Chromatic snapshot captures this state.
await expect(
await body.findByText(/Are you sure you want to delete this provider/i),
await body.findByText(/Deleting this provider will also disable/i),
).toBeInTheDocument();
expect(body.getByText(/2 models/i)).toBeInTheDocument();
await expect(body.getByRole("dialog")).toBeInTheDocument();
await expect(
body.getByRole("button", { name: "Delete provider" }),
@@ -2336,6 +2354,37 @@ export const ProviderDeleteConfirmed: Story = {
source: "database",
has_api_key: true,
}),
createProviderConfig({
id: "provider-anthropic",
provider: "anthropic",
display_name: "Anthropic",
source: "database",
has_api_key: true,
}),
],
modelConfigsData: [
createModelConfig({
id: "model-openai-default",
provider: "openai",
ai_provider_id: "provider-openai",
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
}),
createModelConfig({
id: "model-openai-secondary",
provider: "openai",
ai_provider_id: "provider-openai",
model: "gpt-4o-mini",
display_name: "GPT-4o Mini",
}),
createModelConfig({
id: "model-anthropic-fallback",
provider: "anthropic",
ai_provider_id: "provider-anthropic",
model: "claude-sonnet-4",
display_name: "Claude Sonnet 4",
}),
],
},
play: async ({ canvasElement, args }) => {
@@ -2348,7 +2397,26 @@ export const ProviderDeleteConfirmed: Story = {
await body.findByRole("button", { name: "Delete provider" }),
);
// The delete callback should have been called.
await waitFor(() => {
expect(args.onUpdateModel).toHaveBeenCalledTimes(3);
});
expect(args.onUpdateModel).toHaveBeenNthCalledWith(
1,
"model-openai-default",
{
enabled: false,
},
);
expect(args.onUpdateModel).toHaveBeenNthCalledWith(
2,
"model-openai-secondary",
{ enabled: false },
);
expect(args.onUpdateModel).toHaveBeenNthCalledWith(
3,
"model-anthropic-fallback",
{ is_default: true },
);
await waitFor(() => {
expect(args.onDeleteProvider).toHaveBeenCalledTimes(1);
});
@@ -324,7 +324,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider}
onDeleteProvider={onDeleteProvider}
onDisableModel={onUpdateModel}
onUpdateModel={onUpdateModel}
allModelConfigs={modelConfigs}
/>
) : (
@@ -7,9 +7,11 @@ import {
useId,
useState,
} from "react";
import { useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { invalidateChatConfigurationQueries } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
@@ -20,6 +22,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cascadeDisableProviderModels } from "#/pages/AISettingsPage/utils/providerDelete";
import { formatProviderLabel } from "../../utils/modelOptions";
import { BackButton } from "../BackButton";
import { ConfirmDeleteDialog } from "../ConfirmDeleteDialog";
@@ -47,7 +50,7 @@ interface ProviderFormProps {
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onDisableModel: (
onUpdateModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
@@ -62,11 +65,12 @@ export const ProviderForm: FC<ProviderFormProps> = ({
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onDisableModel,
onUpdateModel,
allModelConfigs,
onBack,
}) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
const apiKeyInputId = useId();
@@ -383,36 +387,25 @@ export const ProviderForm: FC<ProviderFormProps> = ({
entity="provider"
description={deleteProviderDescription}
onConfirm={() => {
setIsCascadeDeleting(true);
const disabledIds = new Set(
providerState.modelConfigs.map((mc) => mc.id),
);
const hadDefault = providerState.modelConfigs.some(
(mc) => mc.is_default,
);
const chain = providerState.modelConfigs.reduce<Promise<unknown>>(
(prev, mc) =>
prev.then(() => onDisableModel(mc.id, { enabled: false })),
Promise.resolve(),
);
chain
.then(() => {
if (hadDefault) {
const newDefault = allModelConfigs.find(
(mc) => mc.enabled && !disabledIds.has(mc.id),
);
if (newDefault) {
return onDisableModel(newDefault.id, { is_default: true });
}
}
})
.then(() => onDeleteProvider(providerConfig.id))
.catch((error: unknown) => {
const deleteProvider = async () => {
setIsCascadeDeleting(true);
try {
await cascadeDisableProviderModels({
associatedModels: providerState.modelConfigs,
allModels: allModelConfigs,
updateModelConfig: onUpdateModel,
});
await onDeleteProvider(providerConfig.id);
setIsCascadeDeleting(false);
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to delete provider."),
);
})
.then(() => setIsCascadeDeleting(false));
setIsCascadeDeleting(false);
await invalidateChatConfigurationQueries(queryClient);
}
};
void deleteProvider();
}}
isPending={isCascadeDeleting || isProviderMutationPending}
open={confirmingDelete}
@@ -81,7 +81,7 @@ interface ProvidersSectionProps {
req: UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onDisableModel: (
onUpdateModel: (
modelConfigId: string,
req: UpdateChatModelConfigRequest,
) => Promise<unknown>;
@@ -97,7 +97,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onDisableModel,
onUpdateModel,
allModelConfigs,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -207,7 +207,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
}
}}
onBack={clearProviderView}
onDisableModel={onDisableModel}
onUpdateModel={onUpdateModel}
allModelConfigs={allModelConfigs}
/>
);