| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- import { beforeEach, describe, expect, it, vi } from "vitest";
- import type { Provider } from "@/types/provider";
- const getSessionMock = vi.fn();
- const findAllProvidersFreshMock = vi.fn();
- vi.mock("@/lib/auth", () => ({
- getSession: getSessionMock,
- }));
- vi.mock("@/repository/provider", () => ({
- findAllProvidersFresh: findAllProvidersFreshMock,
- updateProvidersBatch: vi.fn(),
- deleteProvidersBatch: vi.fn(),
- }));
- vi.mock("@/lib/cache/provider-cache", () => ({
- publishProviderCacheInvalidation: vi.fn(),
- }));
- vi.mock("@/lib/circuit-breaker", () => ({
- clearProviderState: vi.fn(),
- clearConfigCache: vi.fn(),
- resetCircuit: vi.fn(),
- }));
- vi.mock("@/lib/logger", () => ({
- logger: {
- trace: vi.fn(),
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- },
- }));
- function buildTestProvider(overrides: Partial<Provider> = {}): Provider {
- return {
- id: 1,
- name: "Test Provider",
- url: "https://api.example.com",
- key: "test-key",
- providerVendorId: null,
- isEnabled: true,
- weight: 10,
- priority: 1,
- groupPriorities: null,
- costMultiplier: 1.0,
- groupTag: null,
- providerType: "claude",
- preserveClientIp: false,
- modelRedirects: null,
- allowedModels: null,
- mcpPassthroughType: "none",
- mcpPassthroughUrl: null,
- limit5hUsd: null,
- limitDailyUsd: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitTotalUsd: null,
- totalCostResetAt: null,
- limitConcurrentSessions: 10,
- maxRetryAttempts: null,
- circuitBreakerFailureThreshold: 5,
- circuitBreakerOpenDuration: 1800000,
- circuitBreakerHalfOpenSuccessThreshold: 2,
- proxyUrl: null,
- proxyFallbackToDirect: false,
- firstByteTimeoutStreamingMs: 30000,
- streamingIdleTimeoutMs: 10000,
- requestTimeoutNonStreamingMs: 600000,
- websiteUrl: null,
- faviconUrl: null,
- cacheTtlPreference: null,
- swapCacheTtlBilling: false,
- context1mPreference: null,
- codexReasoningEffortPreference: null,
- codexReasoningSummaryPreference: null,
- codexTextVerbosityPreference: null,
- codexParallelToolCallsPreference: null,
- anthropicMaxTokensPreference: null,
- anthropicThinkingBudgetPreference: null,
- anthropicAdaptiveThinking: null,
- geminiGoogleSearchPreference: null,
- tpm: null,
- rpm: null,
- rpd: null,
- cc: null,
- createdAt: new Date("2026-01-01"),
- updatedAt: new Date("2026-01-01"),
- ...overrides,
- };
- }
- describe("Provider Batch Preview Engine - Row Generation", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.resetModules();
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- });
- it("generates correct before/after row for single provider single field change", async () => {
- const provider = buildTestProvider({
- id: 5,
- name: "Claude One",
- groupTag: "old-group",
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [5],
- patch: { group_tag: { set: "new-group" } },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0]).toEqual({
- providerId: 5,
- providerName: "Claude One",
- field: "group_tag",
- status: "changed",
- before: "old-group",
- after: "new-group",
- });
- });
- it("generates rows for each provider-field combination", async () => {
- const providerA = buildTestProvider({
- id: 1,
- name: "Provider A",
- priority: 5,
- weight: 10,
- });
- const providerB = buildTestProvider({
- id: 2,
- name: "Provider B",
- priority: 3,
- weight: 20,
- });
- findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [1, 2],
- patch: {
- priority: { set: 10 },
- weight: { set: 50 },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(4);
- expect(result.data.rows).toContainEqual({
- providerId: 1,
- providerName: "Provider A",
- field: "priority",
- status: "changed",
- before: 5,
- after: 10,
- });
- expect(result.data.rows).toContainEqual({
- providerId: 1,
- providerName: "Provider A",
- field: "weight",
- status: "changed",
- before: 10,
- after: 50,
- });
- expect(result.data.rows).toContainEqual({
- providerId: 2,
- providerName: "Provider B",
- field: "priority",
- status: "changed",
- before: 3,
- after: 10,
- });
- expect(result.data.rows).toContainEqual({
- providerId: 2,
- providerName: "Provider B",
- field: "weight",
- status: "changed",
- before: 20,
- after: 50,
- });
- });
- it("marks anthropic fields as skipped for non-claude providers", async () => {
- const provider = buildTestProvider({
- id: 10,
- name: "OpenAI Compat",
- providerType: "openai-compatible",
- anthropicThinkingBudgetPreference: null,
- anthropicAdaptiveThinking: null,
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [10],
- patch: {
- anthropic_thinking_budget_preference: { set: "8192" },
- anthropic_adaptive_thinking: {
- set: { effort: "high", modelMatchMode: "all", models: [] },
- },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(2);
- const budgetRow = result.data.rows.find(
- (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
- );
- expect(budgetRow).toEqual({
- providerId: 10,
- providerName: "OpenAI Compat",
- field: "anthropic_thinking_budget_preference",
- status: "skipped",
- before: null,
- after: "8192",
- skipReason: expect.any(String),
- });
- const adaptiveRow = result.data.rows.find(
- (r: { field: string }) => r.field === "anthropic_adaptive_thinking"
- );
- expect(adaptiveRow).toEqual({
- providerId: 10,
- providerName: "OpenAI Compat",
- field: "anthropic_adaptive_thinking",
- status: "skipped",
- before: null,
- after: { effort: "high", modelMatchMode: "all", models: [] },
- skipReason: expect.any(String),
- });
- });
- it("marks anthropic fields as changed for claude providers", async () => {
- const provider = buildTestProvider({
- id: 20,
- name: "Claude Main",
- providerType: "claude",
- anthropicThinkingBudgetPreference: "inherit",
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [20],
- patch: { anthropic_thinking_budget_preference: { set: "16000" } },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0]).toEqual({
- providerId: 20,
- providerName: "Claude Main",
- field: "anthropic_thinking_budget_preference",
- status: "changed",
- before: "inherit",
- after: "16000",
- });
- });
- it("marks anthropic fields as changed for claude-auth providers", async () => {
- const provider = buildTestProvider({
- id: 21,
- name: "Claude Auth",
- providerType: "claude-auth",
- anthropicAdaptiveThinking: null,
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [21],
- patch: {
- anthropic_adaptive_thinking: {
- set: { effort: "medium", modelMatchMode: "all", models: [] },
- },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0].status).toBe("changed");
- expect(result.data.rows[0].providerId).toBe(21);
- });
- it("computes correct after values for clear mode", async () => {
- const provider = buildTestProvider({
- id: 30,
- name: "Clear Test",
- providerType: "claude",
- groupTag: "old-tag",
- modelRedirects: { "model-a": "model-b" },
- allowedModels: ["claude-3"],
- anthropicThinkingBudgetPreference: "8192",
- anthropicAdaptiveThinking: {
- effort: "high",
- modelMatchMode: "all",
- models: [],
- },
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [30],
- patch: {
- group_tag: { clear: true },
- model_redirects: { clear: true },
- allowed_models: { clear: true },
- anthropic_thinking_budget_preference: { clear: true },
- anthropic_adaptive_thinking: { clear: true },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(5);
- const groupTagRow = result.data.rows.find((r: { field: string }) => r.field === "group_tag");
- expect(groupTagRow?.before).toBe("old-tag");
- expect(groupTagRow?.after).toBeNull();
- const modelRedirectsRow = result.data.rows.find(
- (r: { field: string }) => r.field === "model_redirects"
- );
- expect(modelRedirectsRow?.before).toEqual({ "model-a": "model-b" });
- expect(modelRedirectsRow?.after).toBeNull();
- const allowedModelsRow = result.data.rows.find(
- (r: { field: string }) => r.field === "allowed_models"
- );
- expect(allowedModelsRow?.before).toEqual(["claude-3"]);
- expect(allowedModelsRow?.after).toBeNull();
- // anthropic_thinking_budget_preference clears to "inherit"
- const budgetRow = result.data.rows.find(
- (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
- );
- expect(budgetRow?.before).toBe("8192");
- expect(budgetRow?.after).toBe("inherit");
- const adaptiveRow = result.data.rows.find(
- (r: { field: string }) => r.field === "anthropic_adaptive_thinking"
- );
- expect(adaptiveRow?.before).toEqual({
- effort: "high",
- modelMatchMode: "all",
- models: [],
- });
- expect(adaptiveRow?.after).toBeNull();
- });
- it("normalizes empty allowed_models array to null in after value", async () => {
- const provider = buildTestProvider({
- id: 40,
- name: "Models Test",
- allowedModels: ["claude-3"],
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [40],
- patch: { allowed_models: { set: [] } },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0].before).toEqual(["claude-3"]);
- expect(result.data.rows[0].after).toBeNull();
- });
- it("includes correct skipCount in summary", async () => {
- const claudeProvider = buildTestProvider({
- id: 50,
- name: "Claude",
- providerType: "claude",
- });
- const openaiProvider = buildTestProvider({
- id: 51,
- name: "OpenAI",
- providerType: "openai-compatible",
- });
- const geminiProvider = buildTestProvider({
- id: 52,
- name: "Gemini",
- providerType: "gemini",
- });
- findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider, geminiProvider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [50, 51, 52],
- patch: {
- anthropic_thinking_budget_preference: { set: "8192" },
- group_tag: { set: "new-tag" },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- // 3 providers x 2 fields = 6 rows
- expect(result.data.rows).toHaveLength(6);
- // 2 non-claude providers x 1 anthropic field = 2 skipped
- expect(result.data.summary.skipCount).toBe(2);
- expect(result.data.summary.providerCount).toBe(3);
- expect(result.data.summary.fieldCount).toBe(2);
- });
- it("returns rows in the preview result for snapshot storage", async () => {
- const provider = buildTestProvider({
- id: 60,
- name: "Snapshot Test",
- isEnabled: true,
- });
- findAllProvidersFreshMock.mockResolvedValue([provider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [60],
- patch: { is_enabled: { set: false } },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toBeDefined();
- expect(Array.isArray(result.data.rows)).toBe(true);
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0]).toEqual({
- providerId: 60,
- providerName: "Snapshot Test",
- field: "is_enabled",
- status: "changed",
- before: true,
- after: false,
- });
- });
- it("only generates rows for providers matching requested IDs", async () => {
- const providerA = buildTestProvider({ id: 100, name: "Match" });
- const providerB = buildTestProvider({ id: 200, name: "No Match" });
- findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [100],
- patch: { priority: { set: 99 } },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(1);
- expect(result.data.rows[0].providerId).toBe(100);
- });
- it("skips anthropic fields for all non-claude provider types", async () => {
- const codexProvider = buildTestProvider({
- id: 70,
- name: "Codex",
- providerType: "codex",
- });
- const geminiCliProvider = buildTestProvider({
- id: 71,
- name: "Gemini CLI",
- providerType: "gemini-cli",
- });
- findAllProvidersFreshMock.mockResolvedValue([codexProvider, geminiCliProvider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [70, 71],
- patch: {
- anthropic_adaptive_thinking: {
- set: { effort: "low", modelMatchMode: "all", models: [] },
- },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- expect(result.data.rows).toHaveLength(2);
- expect(result.data.rows.every((r: { status: string }) => r.status === "skipped")).toBe(true);
- expect(result.data.summary.skipCount).toBe(2);
- });
- it("handles mixed changed and skipped rows across providers", async () => {
- const claudeProvider = buildTestProvider({
- id: 80,
- name: "Claude",
- providerType: "claude",
- groupTag: "alpha",
- anthropicThinkingBudgetPreference: null,
- });
- const openaiProvider = buildTestProvider({
- id: 81,
- name: "OpenAI",
- providerType: "openai-compatible",
- groupTag: "beta",
- anthropicThinkingBudgetPreference: null,
- });
- findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider]);
- const { previewProviderBatchPatch } = await import("@/actions/providers");
- const result = await previewProviderBatchPatch({
- providerIds: [80, 81],
- patch: {
- group_tag: { set: "gamma" },
- anthropic_thinking_budget_preference: { set: "4096" },
- },
- });
- expect(result.ok).toBe(true);
- if (!result.ok) return;
- // 2 providers x 2 fields = 4 rows
- expect(result.data.rows).toHaveLength(4);
- // group_tag: both changed (universal field)
- const groupTagRows = result.data.rows.filter((r: { field: string }) => r.field === "group_tag");
- expect(groupTagRows).toHaveLength(2);
- expect(groupTagRows.every((r: { status: string }) => r.status === "changed")).toBe(true);
- // anthropic_thinking_budget_preference: claude changed, openai skipped
- const budgetRows = result.data.rows.filter(
- (r: { field: string }) => r.field === "anthropic_thinking_budget_preference"
- );
- expect(budgetRows).toHaveLength(2);
- const claudeBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 80);
- expect(claudeBudget?.status).toBe("changed");
- const openaiBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 81);
- expect(openaiBudget?.status).toBe("skipped");
- expect(openaiBudget?.skipReason).toBeTruthy();
- expect(result.data.summary.skipCount).toBe(1);
- });
- });
|