mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
4c94e70f97
Add a complete HTTP proxy server implementation built on Axum framework, enabling local API request forwarding with automatic provider failover and load balancing capabilities. Backend Implementation (Rust): - Add proxy server module with 7 core components: * server.rs: Axum HTTP server lifecycle management (start/stop/status) * router.rs: API routing configuration for Claude/OpenAI/Gemini endpoints * handlers.rs: Request/response handling and transformation * forwarder.rs: Upstream forwarding logic with retry mechanism (652 lines) * error.rs: Comprehensive error handling and HTTP status mapping * types.rs: Shared types (ProxyConfig, ProxyStatus, ProxyServerInfo) * health.rs: Provider health check infrastructure Service Layer: - Add ProxyService (services/proxy.rs, 157 lines): * Manage proxy server lifecycle * Handle configuration updates * Track runtime status and metrics Database Layer: - Add proxy configuration DAO (dao/proxy.rs, 242 lines): * Persist proxy settings (listen address, port, timeout) * Store provider priority and availability flags - Update schema with proxy_config table (schema.rs): * Support runtime configuration persistence Tauri Commands: - Add 6 command endpoints (commands/proxy.rs): * start_proxy_server: Launch proxy server * stop_proxy_server: Gracefully shutdown server * get_proxy_status: Query runtime status * get_proxy_config: Retrieve current configuration * update_proxy_config: Modify settings without restart * is_proxy_running: Check server state Frontend Implementation (React + TypeScript): - Add ProxyPanel component (222 lines): * Real-time server status display * Start/stop controls * Provider availability monitoring - Add ProxySettingsDialog component (420 lines): * Configuration editor (address, port, timeout) * Provider priority management * Settings validation - Add React hooks: * useProxyConfig: Manage proxy configuration state * useProxyStatus: Poll and display server status - Add TypeScript types (types/proxy.ts): * Define ProxyConfig, ProxyStatus interfaces Provider Integration: - Extend Provider model with availability field (providers.rs): * Track provider health for failover logic - Update ProviderCard UI to display proxy status - Integrate proxy controls in Settings page Dependencies: - Add Axum 0.7 (async web framework) - Add Tower 0.4 (middleware and service abstractions) - Add Tower-HTTP (CORS layer) - Add Tokio sync primitives (oneshot, RwLock) Technical Details: - Graceful shutdown via oneshot channel - Shared state with Arc<RwLock<T>> for thread-safe config updates - CORS enabled for cross-origin frontend access - Request/response streaming support - Automatic retry with exponential backoff (forwarder) - API key extraction from multiple config formats (Claude/Codex/Gemini) File Statistics: - 41 files changed - 3491 insertions(+), 41 deletions(-) - Core modules: 1393 lines (server + forwarder + handlers) - Frontend UI: 642 lines (ProxyPanel + ProxySettingsDialog) - Database/DAO: 326 lines This implementation provides the foundation for advanced features like: - Multi-provider load balancing - Automatic failover on provider errors - Request logging and analytics - Usage tracking and cost monitoring
242 lines
6.7 KiB
TypeScript
242 lines
6.7 KiB
TypeScript
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { Provider } from "@/types";
|
|
import { ProviderList } from "@/components/providers/ProviderList";
|
|
|
|
const useDragSortMock = vi.fn();
|
|
const useSortableMock = vi.fn();
|
|
const providerCardRenderSpy = vi.fn();
|
|
|
|
vi.mock("@/hooks/useDragSort", () => ({
|
|
useDragSort: (...args: unknown[]) => useDragSortMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/components/providers/ProviderCard", () => ({
|
|
ProviderCard: (props: any) => {
|
|
providerCardRenderSpy(props);
|
|
const {
|
|
provider,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
} = props;
|
|
|
|
return (
|
|
<div data-testid={`provider-card-${provider.id}`}>
|
|
<button
|
|
data-testid={`switch-${provider.id}`}
|
|
onClick={() => onSwitch(provider)}
|
|
>
|
|
switch
|
|
</button>
|
|
<button
|
|
data-testid={`edit-${provider.id}`}
|
|
onClick={() => onEdit(provider)}
|
|
>
|
|
edit
|
|
</button>
|
|
<button
|
|
data-testid={`duplicate-${provider.id}`}
|
|
onClick={() => onDuplicate(provider)}
|
|
>
|
|
duplicate
|
|
</button>
|
|
<button
|
|
data-testid={`usage-${provider.id}`}
|
|
onClick={() => onConfigureUsage(provider)}
|
|
>
|
|
usage
|
|
</button>
|
|
<button
|
|
data-testid={`delete-${provider.id}`}
|
|
onClick={() => onDelete(provider)}
|
|
>
|
|
delete
|
|
</button>
|
|
<span data-testid={`is-current-${provider.id}`}>
|
|
{props.isCurrent ? "current" : "inactive"}
|
|
</span>
|
|
<span data-testid={`drag-attr-${provider.id}`}>
|
|
{props.dragHandleProps?.attributes?.["data-dnd-id"] ?? "none"}
|
|
</span>
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/UsageFooter", () => ({
|
|
default: () => <div data-testid="usage-footer" />,
|
|
}));
|
|
|
|
vi.mock("@dnd-kit/sortable", async () => {
|
|
const actual = await vi.importActual<any>("@dnd-kit/sortable");
|
|
|
|
return {
|
|
...actual,
|
|
useSortable: (...args: unknown[]) => useSortableMock(...args),
|
|
};
|
|
});
|
|
|
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
|
return {
|
|
id: overrides.id ?? "provider-1",
|
|
name: overrides.name ?? "Test Provider",
|
|
settingsConfig: overrides.settingsConfig ?? {},
|
|
category: overrides.category,
|
|
createdAt: overrides.createdAt,
|
|
sortIndex: overrides.sortIndex,
|
|
meta: overrides.meta,
|
|
websiteUrl: overrides.websiteUrl,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useDragSortMock.mockReset();
|
|
useSortableMock.mockReset();
|
|
providerCardRenderSpy.mockClear();
|
|
|
|
useSortableMock.mockImplementation(({ id }: { id: string }) => ({
|
|
setNodeRef: vi.fn(),
|
|
attributes: { "data-dnd-id": id },
|
|
listeners: { onPointerDown: vi.fn() },
|
|
transform: null,
|
|
transition: null,
|
|
isDragging: false,
|
|
}));
|
|
|
|
useDragSortMock.mockReturnValue({
|
|
sortedProviders: [],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
});
|
|
|
|
describe("ProviderList Component", () => {
|
|
it("should render skeleton placeholders when loading", () => {
|
|
const { container } = render(
|
|
<ProviderList
|
|
providers={{}}
|
|
currentProviderId=""
|
|
appId="claude"
|
|
onSwitch={vi.fn()}
|
|
onEdit={vi.fn()}
|
|
onDelete={vi.fn()}
|
|
onDuplicate={vi.fn()}
|
|
onOpenWebsite={vi.fn()}
|
|
onSetProxyTarget={vi.fn()}
|
|
isLoading
|
|
/>,
|
|
);
|
|
|
|
const placeholders = container.querySelectorAll(
|
|
".border-dashed.border-muted-foreground\\/40",
|
|
);
|
|
expect(placeholders).toHaveLength(3);
|
|
});
|
|
|
|
it("should show empty state and trigger create callback when no providers exist", () => {
|
|
const handleCreate = vi.fn();
|
|
useDragSortMock.mockReturnValueOnce({
|
|
sortedProviders: [],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ProviderList
|
|
providers={{}}
|
|
currentProviderId=""
|
|
appId="claude"
|
|
onSwitch={vi.fn()}
|
|
onEdit={vi.fn()}
|
|
onDelete={vi.fn()}
|
|
onDuplicate={vi.fn()}
|
|
onOpenWebsite={vi.fn()}
|
|
onSetProxyTarget={vi.fn()}
|
|
onCreate={handleCreate}
|
|
/>,
|
|
);
|
|
|
|
const addButton = screen.getByRole("button", {
|
|
name: "provider.addProvider",
|
|
});
|
|
fireEvent.click(addButton);
|
|
|
|
expect(handleCreate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should render in order returned by useDragSort and pass through action callbacks", () => {
|
|
const providerA = createProvider({ id: "a", name: "A" });
|
|
const providerB = createProvider({ id: "b", name: "B" });
|
|
|
|
const handleSwitch = vi.fn();
|
|
const handleEdit = vi.fn();
|
|
const handleDelete = vi.fn();
|
|
const handleDuplicate = vi.fn();
|
|
const handleUsage = vi.fn();
|
|
const handleOpenWebsite = vi.fn();
|
|
|
|
useDragSortMock.mockReturnValue({
|
|
sortedProviders: [providerB, providerA],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ProviderList
|
|
providers={{ a: providerA, b: providerB }}
|
|
currentProviderId="b"
|
|
appId="claude"
|
|
onSwitch={handleSwitch}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDelete}
|
|
onDuplicate={handleDuplicate}
|
|
onConfigureUsage={handleUsage}
|
|
onOpenWebsite={handleOpenWebsite}
|
|
onSetProxyTarget={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
// Verify sort order
|
|
expect(providerCardRenderSpy).toHaveBeenCalledTimes(2);
|
|
expect(providerCardRenderSpy.mock.calls[0][0].provider.id).toBe("b");
|
|
expect(providerCardRenderSpy.mock.calls[1][0].provider.id).toBe("a");
|
|
|
|
// Verify current provider marker
|
|
expect(providerCardRenderSpy.mock.calls[0][0].isCurrent).toBe(true);
|
|
|
|
// Drag attributes from useSortable
|
|
expect(
|
|
providerCardRenderSpy.mock.calls[0][0].dragHandleProps?.attributes[
|
|
"data-dnd-id"
|
|
],
|
|
).toBe("b");
|
|
expect(
|
|
providerCardRenderSpy.mock.calls[1][0].dragHandleProps?.attributes[
|
|
"data-dnd-id"
|
|
],
|
|
).toBe("a");
|
|
|
|
// Trigger action buttons
|
|
fireEvent.click(screen.getByTestId("switch-b"));
|
|
fireEvent.click(screen.getByTestId("edit-b"));
|
|
fireEvent.click(screen.getByTestId("duplicate-b"));
|
|
fireEvent.click(screen.getByTestId("usage-b"));
|
|
fireEvent.click(screen.getByTestId("delete-a"));
|
|
|
|
expect(handleSwitch).toHaveBeenCalledWith(providerB);
|
|
expect(handleEdit).toHaveBeenCalledWith(providerB);
|
|
expect(handleDuplicate).toHaveBeenCalledWith(providerB);
|
|
expect(handleUsage).toHaveBeenCalledWith(providerB);
|
|
expect(handleDelete).toHaveBeenCalledWith(providerA);
|
|
|
|
// Verify useDragSort call parameters
|
|
expect(useDragSortMock).toHaveBeenCalledWith(
|
|
{ a: providerA, b: providerB },
|
|
"claude",
|
|
);
|
|
});
|
|
});
|