| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- import { beforeEach, describe, expect, it, vi } from "vitest";
- import type { ModelPrice, ModelPriceData } from "@/types/model-price";
- // Mock dependencies
- const getSessionMock = vi.fn();
- const revalidatePathMock = vi.fn();
- // Repository mocks
- const findLatestPriceByModelMock = vi.fn();
- const findAllLatestPricesMock = vi.fn();
- const createModelPriceMock = vi.fn();
- const upsertModelPriceMock = vi.fn();
- const deleteModelPriceByNameMock = vi.fn();
- const findAllManualPricesMock = vi.fn();
- // Price sync mock
- const fetchCloudPriceTableTomlMock = vi.fn();
- vi.mock("@/lib/auth", () => ({
- getSession: () => getSessionMock(),
- }));
- vi.mock("next/cache", () => ({
- revalidatePath: () => revalidatePathMock(),
- }));
- vi.mock("@/lib/logger", () => ({
- logger: {
- trace: vi.fn(),
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- },
- }));
- vi.mock("@/repository/model-price", () => ({
- findLatestPriceByModel: () => findLatestPriceByModelMock(),
- createModelPrice: (...args: unknown[]) => createModelPriceMock(...args),
- upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args),
- deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args),
- findAllManualPrices: () => findAllManualPricesMock(),
- findAllLatestPrices: () => findAllLatestPricesMock(),
- findAllLatestPricesPaginated: vi.fn(async () => ({
- data: [],
- total: 0,
- page: 1,
- pageSize: 50,
- totalPages: 0,
- })),
- hasAnyPriceRecords: vi.fn(async () => false),
- }));
- vi.mock("@/lib/price-sync/cloud-price-table", async (importOriginal) => {
- const actual = await importOriginal<typeof import("@/lib/price-sync/cloud-price-table")>();
- return {
- ...actual,
- fetchCloudPriceTableToml: (...args: unknown[]) => fetchCloudPriceTableTomlMock(...args),
- };
- });
- // Helper to create mock ModelPrice
- function makeMockPrice(
- modelName: string,
- priceData: Partial<ModelPriceData>,
- source: "litellm" | "manual" = "manual"
- ): ModelPrice {
- const now = new Date();
- return {
- id: Math.floor(Math.random() * 1000),
- modelName,
- priceData: {
- mode: "chat",
- input_cost_per_token: 0.000001,
- output_cost_per_token: 0.000002,
- ...priceData,
- },
- source,
- createdAt: now,
- updatedAt: now,
- };
- }
- describe("Model Price Actions", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- // Default: admin session
- getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
- findAllLatestPricesMock.mockResolvedValue([]);
- });
- describe("upsertSingleModelPrice", () => {
- it("should create a new model price for admin", async () => {
- const mockResult = makeMockPrice("gpt-5.2-codex", {
- mode: "chat",
- input_cost_per_token: 0.000015,
- output_cost_per_token: 0.00006,
- });
- upsertModelPriceMock.mockResolvedValue(mockResult);
- const { upsertSingleModelPrice } = await import("@/actions/model-prices");
- const result = await upsertSingleModelPrice({
- modelName: "gpt-5.2-codex",
- mode: "chat",
- litellmProvider: "openai",
- inputCostPerToken: 0.000015,
- outputCostPerToken: 0.00006,
- });
- expect(result.ok).toBe(true);
- expect(result.data?.modelName).toBe("gpt-5.2-codex");
- expect(upsertModelPriceMock).toHaveBeenCalledWith(
- "gpt-5.2-codex",
- expect.objectContaining({
- mode: "chat",
- litellm_provider: "openai",
- input_cost_per_token: 0.000015,
- output_cost_per_token: 0.00006,
- })
- );
- });
- it("should reject empty model name", async () => {
- const { upsertSingleModelPrice } = await import("@/actions/model-prices");
- const result = await upsertSingleModelPrice({
- modelName: " ",
- mode: "chat",
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain("模型名称");
- expect(upsertModelPriceMock).not.toHaveBeenCalled();
- });
- it("should reject non-admin users", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } });
- const { upsertSingleModelPrice } = await import("@/actions/model-prices");
- const result = await upsertSingleModelPrice({
- modelName: "test-model",
- mode: "chat",
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain("无权限");
- expect(upsertModelPriceMock).not.toHaveBeenCalled();
- });
- it("should handle image generation mode", async () => {
- const mockResult = makeMockPrice("dall-e-3", {
- mode: "image_generation",
- output_cost_per_image: 0.04,
- });
- upsertModelPriceMock.mockResolvedValue(mockResult);
- const { upsertSingleModelPrice } = await import("@/actions/model-prices");
- const result = await upsertSingleModelPrice({
- modelName: "dall-e-3",
- mode: "image_generation",
- litellmProvider: "openai",
- outputCostPerImage: 0.04,
- });
- expect(result.ok).toBe(true);
- expect(upsertModelPriceMock).toHaveBeenCalledWith(
- "dall-e-3",
- expect.objectContaining({
- mode: "image_generation",
- output_cost_per_image: 0.04,
- })
- );
- });
- it("should handle repository errors gracefully", async () => {
- upsertModelPriceMock.mockRejectedValue(new Error("Database error"));
- const { upsertSingleModelPrice } = await import("@/actions/model-prices");
- const result = await upsertSingleModelPrice({
- modelName: "test-model",
- mode: "chat",
- });
- expect(result.ok).toBe(false);
- expect(result.error).toBeDefined();
- });
- });
- describe("deleteSingleModelPrice", () => {
- it("should delete a model price for admin", async () => {
- deleteModelPriceByNameMock.mockResolvedValue(undefined);
- const { deleteSingleModelPrice } = await import("@/actions/model-prices");
- const result = await deleteSingleModelPrice("gpt-5.2-codex");
- expect(result.ok).toBe(true);
- expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("gpt-5.2-codex");
- });
- it("should reject empty model name", async () => {
- const { deleteSingleModelPrice } = await import("@/actions/model-prices");
- const result = await deleteSingleModelPrice("");
- expect(result.ok).toBe(false);
- expect(result.error).toContain("模型名称");
- expect(deleteModelPriceByNameMock).not.toHaveBeenCalled();
- });
- it("should reject non-admin users", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } });
- const { deleteSingleModelPrice } = await import("@/actions/model-prices");
- const result = await deleteSingleModelPrice("test-model");
- expect(result.ok).toBe(false);
- expect(result.error).toContain("无权限");
- expect(deleteModelPriceByNameMock).not.toHaveBeenCalled();
- });
- it("should handle repository errors gracefully", async () => {
- deleteModelPriceByNameMock.mockRejectedValue(new Error("Database error"));
- const { deleteSingleModelPrice } = await import("@/actions/model-prices");
- const result = await deleteSingleModelPrice("test-model");
- expect(result.ok).toBe(false);
- expect(result.error).toBeDefined();
- });
- });
- describe("checkLiteLLMSyncConflicts", () => {
- it("should return no conflicts when no manual prices exist", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- fetchCloudPriceTableTomlMock.mockResolvedValue({
- ok: true,
- data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join(
- "\n"
- ),
- });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(true);
- expect(result.data?.hasConflicts).toBe(false);
- expect(result.data?.conflicts).toHaveLength(0);
- });
- it("should detect conflicts when manual prices exist in LiteLLM", async () => {
- const manualPrice = makeMockPrice("claude-3-opus", {
- mode: "chat",
- input_cost_per_token: 0.00001,
- output_cost_per_token: 0.00002,
- });
- findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]]));
- fetchCloudPriceTableTomlMock.mockResolvedValue({
- ok: true,
- data: [
- '[models."claude-3-opus"]',
- 'mode = "chat"',
- "input_cost_per_token = 0.000015",
- "output_cost_per_token = 0.00006",
- ].join("\n"),
- });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(true);
- expect(result.data?.hasConflicts).toBe(true);
- expect(result.data?.conflicts).toHaveLength(1);
- expect(result.data?.conflicts[0]?.modelName).toBe("claude-3-opus");
- });
- it("should not report conflicts for manual prices not in LiteLLM", async () => {
- const manualPrice = makeMockPrice("custom-model", {
- mode: "chat",
- input_cost_per_token: 0.00001,
- });
- findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]]));
- fetchCloudPriceTableTomlMock.mockResolvedValue({
- ok: true,
- data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join(
- "\n"
- ),
- });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(true);
- expect(result.data?.hasConflicts).toBe(false);
- expect(result.data?.conflicts).toHaveLength(0);
- });
- it("should reject non-admin users", async () => {
- getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(false);
- expect(result.error).toContain("无权限");
- });
- it("should handle network errors gracefully", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- fetchCloudPriceTableTomlMock.mockResolvedValue({
- ok: false,
- error: "云端价格表拉取失败:mock",
- });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(false);
- expect(result.error).toContain("云端");
- });
- it("should handle invalid TOML gracefully", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- fetchCloudPriceTableTomlMock.mockResolvedValue({
- ok: true,
- data: "[models\ninvalid = true",
- });
- const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices");
- const result = await checkLiteLLMSyncConflicts();
- expect(result.ok).toBe(false);
- expect(result.error).toContain("TOML");
- });
- });
- describe("processPriceTableInternal - source handling", () => {
- it("should skip manual prices during sync by default", async () => {
- const manualPrice = makeMockPrice("custom-model", {
- mode: "chat",
- input_cost_per_token: 0.00001,
- });
- findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]]));
- findAllLatestPricesMock.mockResolvedValue([manualPrice]);
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- "custom-model": {
- mode: "chat",
- input_cost_per_token: 0.000015,
- },
- })
- );
- expect(result.ok).toBe(true);
- expect(result.data?.skippedConflicts).toContain("custom-model");
- expect(result.data?.unchanged).toContain("custom-model");
- expect(createModelPriceMock).not.toHaveBeenCalled();
- });
- it("should overwrite manual prices when specified", async () => {
- const manualPrice = makeMockPrice("custom-model", {
- mode: "chat",
- input_cost_per_token: 0.00001,
- });
- findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]]));
- findAllLatestPricesMock.mockResolvedValue([manualPrice]);
- deleteModelPriceByNameMock.mockResolvedValue(undefined);
- createModelPriceMock.mockResolvedValue(
- makeMockPrice(
- "custom-model",
- {
- mode: "chat",
- input_cost_per_token: 0.000015,
- },
- "litellm"
- )
- );
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- "custom-model": {
- mode: "chat",
- input_cost_per_token: 0.000015,
- },
- }),
- ["custom-model"] // Overwrite list
- );
- expect(result.ok).toBe(true);
- expect(result.data?.updated).toContain("custom-model");
- expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("custom-model");
- expect(createModelPriceMock).toHaveBeenCalled();
- });
- it("should add new models with litellm source", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- findAllLatestPricesMock.mockResolvedValue([]);
- createModelPriceMock.mockResolvedValue(
- makeMockPrice(
- "new-model",
- {
- mode: "chat",
- },
- "litellm"
- )
- );
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- "new-model": {
- mode: "chat",
- input_cost_per_token: 0.000001,
- },
- })
- );
- expect(result.ok).toBe(true);
- expect(result.data?.added).toContain("new-model");
- expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "litellm");
- });
- it("should skip metadata fields like sample_spec", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- findAllLatestPricesMock.mockResolvedValue([]);
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- sample_spec: { description: "This is metadata" },
- "real-model": { mode: "chat", input_cost_per_token: 0.000001 },
- })
- );
- expect(result.ok).toBe(true);
- expect(result.data?.total).toBe(1); // Only real-model
- expect(result.data?.failed).not.toContain("sample_spec");
- });
- it("should skip entries without mode field", async () => {
- findAllManualPricesMock.mockResolvedValue(new Map());
- findAllLatestPricesMock.mockResolvedValue([]);
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- "invalid-model": { input_cost_per_token: 0.000001 }, // No mode
- "valid-model": { mode: "chat", input_cost_per_token: 0.000001 },
- })
- );
- expect(result.ok).toBe(true);
- expect(result.data?.failed).toContain("invalid-model");
- });
- it("should ignore dangerous keys when comparing price data", async () => {
- const existing = makeMockPrice(
- "safe-model",
- {
- mode: "chat",
- input_cost_per_token: 0.000001,
- output_cost_per_token: 0.000002,
- },
- "litellm"
- );
- findAllManualPricesMock.mockResolvedValue(new Map());
- findAllLatestPricesMock.mockResolvedValue([existing]);
- const { processPriceTableInternal } = await import("@/actions/model-prices");
- const result = await processPriceTableInternal(
- JSON.stringify({
- "safe-model": {
- mode: "chat",
- input_cost_per_token: 0.000001,
- output_cost_per_token: 0.000002,
- constructor: { prototype: { polluted: true } },
- },
- })
- );
- expect(result.ok).toBe(true);
- expect(result.data?.unchanged).toContain("safe-model");
- expect(createModelPriceMock).not.toHaveBeenCalled();
- });
- });
- });
|