/** * @file Tests for the AI Router settings tab — listing profiles, creating one, * and the two rules that keep a key safe: the form never shows one, and an * edit that leaves the field blank must not send an empty key that would wipe * the stored one. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AiRouterConfig } from "../AiRouterConfig"; import { api } from "../../lib/api"; vi.mock("../../lib/api", () => ({ api: { aiRouters: { list: vi.fn(), create: vi.fn(), update: vi.fn(), remove: vi.fn(), test: vi.fn(), }, }, })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string, opts?: Record) => opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key, }), })); const native = { name: "native", label: "Native (Claude / Codex)", builtin: true, baseUrl: "", hasKey: false, model: "claude-sonnet-5", models: { opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5-20251001", smallFast: "claude-haiku-4-5-20251001", subagent: "claude-haiku-4-5-20251001", }, }; const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false, baseUrl: "http://localhost:20128/v1", hasKey: true, model: "Highest", models: { opus: "Highest", sonnet: "Highest", haiku: "Highest", smallFast: "Highest", subagent: "Highest", }, }; beforeEach(() => { vi.mocked(api.aiRouters.list) .mockReset() .mockResolvedValue({ items: [native] }); vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter); vi.mocked(api.aiRouters.update) .mockReset() .mockResolvedValue({ ...nineRouter, lanes: { updated: 0, failed: [] } }); vi.mocked(api.aiRouters.remove) .mockReset() .mockResolvedValue({ removed: "9router", lanes: { updated: 0, failed: [] } }); vi.mocked(api.aiRouters.test) .mockReset() .mockResolvedValue({ ok: true, status: 200, message: "Highest" }); }); describe("AiRouterConfig", () => { it("lists the built-in native profile with no edit or delete control", async () => { render(); expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument(); expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull(); expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull(); }); it("saves a new profile with the typed endpoint, key and model", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router"); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest"); await user.click(screen.getByRole("button", { name: "aiRouter.save" })); await waitFor(() => expect(api.aiRouters.create).toHaveBeenCalledWith({ label: "9Router", baseUrl: "http://localhost:20128/v1", model: "Highest", // Every tier equals the headline model, so none is sent: the server // fills them, which is what makes one field enough. models: {}, apiKey: "sk-typed", }) ); }); it("sends only the tiers that differ from the headline model", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router"); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest"); await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo"); await user.click(screen.getByRole("button", { name: "aiRouter.save" })); await waitFor(() => expect(api.aiRouters.create).toHaveBeenCalledWith( expect.objectContaining({ models: { opus: "PremiumCombo" } }) ) ); }); it("never puts a stored key in the edit form, and omits it when left blank", async () => { // The API cannot return a key, so an edit that sends `apiKey: ""` would // silently erase the one on disk. vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); render(); const user = userEvent.setup(); await user.click(await screen.findByLabelText("aiRouter.edit 9Router")); expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue(""); await user.clear(screen.getByLabelText("aiRouter.fieldLabel")); await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà"); await user.click(screen.getByRole("button", { name: "aiRouter.save" })); await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled()); const call = vi.mocked(api.aiRouters.update).mock.calls[0]; expect(call?.[1]).not.toHaveProperty("apiKey"); }); it("warns when a rotated key did not reach every lane using the profile", async () => { // The whole point of pushing an edit out to the lanes is that a revoked key // stops being used. A lane that silently kept the old one must be named, // or the user believes a rotation is complete when it is not. vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); vi.mocked(api.aiRouters.update).mockResolvedValue({ ...nineRouter, lanes: { updated: 1, failed: ["7 (checkout): EBADJSON"] }, }); render(); const user = userEvent.setup(); await user.click(await screen.findByLabelText("aiRouter.edit 9Router")); await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-rotated"); await user.click(screen.getByRole("button", { name: "aiRouter.save" })); expect(await screen.findByText("aiRouter.lanesFailed")).toBeInTheDocument(); }); it("surfaces a server error instead of closing the form", async () => { vi.mocked(api.aiRouters.create).mockRejectedValue( new Error("baseUrl must be http:// or https://") ); render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad"); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x"); await user.click(screen.getByRole("button", { name: "aiRouter.save" })); expect(await screen.findByText(/must be http/)).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument(); }); }); describe("AiRouterConfig — testing an endpoint before saving it", () => { it("sends the typed endpoint, key and model, and reports success", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest"); await user.click(screen.getByTestId("router-test")); await waitFor(() => expect(api.aiRouters.test).toHaveBeenCalledWith({ baseUrl: "http://localhost:20128/v1", model: "Highest", apiKey: "sk-typed", }) ); expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk"); }); it("names the profile being edited so the server can reuse its stored key", async () => { vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); render(); const user = userEvent.setup(); await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/)); await user.click(screen.getByTestId("router-test")); await waitFor(() => expect(api.aiRouters.test).toHaveBeenCalledWith({ baseUrl: "http://localhost:20128/v1", model: "Highest", name: "9router", }) ); }); it("shows the router's own refusal rather than claiming it works", async () => { vi.mocked(api.aiRouters.test).mockResolvedValue({ ok: false, status: 401, message: "invalid api key", }); render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest"); await user.click(screen.getByTestId("router-test")); expect(await screen.findByTestId("router-test-result")).toHaveTextContent( "aiRouter.testFailed" ); }); it("drops a stale verdict as soon as a field changes", async () => { render(); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ })); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1"); await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest"); await user.click(screen.getByTestId("router-test")); expect(await screen.findByTestId("router-test-result")).toBeInTheDocument(); await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2"); await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull()); }); });