mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
feat(usage): add automatic models.dev pricing sync (#5734)
* feat(usage): persist model pricing in local config * feat(usage): sync selected models.dev pricing on startup * fix(usage): address models.dev sync review feedback * fix(usage): harden local pricing synchronization
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getModelsDevSyncConfig,
|
||||
saveModelsDevSyncConfig,
|
||||
getModelPricing,
|
||||
openAppConfigFolder,
|
||||
syncModelsDevPricing,
|
||||
} = vi.hoisted(() => ({
|
||||
getModelsDevSyncConfig: vi.fn(),
|
||||
saveModelsDevSyncConfig: vi.fn(),
|
||||
getModelPricing: vi.fn(),
|
||||
openAppConfigFolder: vi.fn(),
|
||||
syncModelsDevPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number }) =>
|
||||
options?.count == null ? key : `${key}:${options.count}`,
|
||||
i18n: { resolvedLanguage: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/usage", () => ({
|
||||
usageApi: {
|
||||
getModelsDevSyncConfig,
|
||||
saveModelsDevSyncConfig,
|
||||
getModelPricing,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/settings", () => ({
|
||||
settingsApi: { openAppConfigFolder },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modelsDevAutoSync", () => ({
|
||||
MODELS_DEV_SYNC_CONFIG_QUERY_KEY: ["models-dev-sync-config"],
|
||||
syncModelsDevPricing,
|
||||
}));
|
||||
|
||||
import { ModelsDevAutoSyncPanel } from "@/components/usage/ModelsDevAutoSyncPanel";
|
||||
|
||||
const state = {
|
||||
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||
config: {
|
||||
autoSyncEnabled: false,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: [],
|
||||
excludedCommonModelKeys: [],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelsDevAutoSyncPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ModelsDevAutoSyncPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||
saveModelsDevSyncConfig.mockResolvedValue(undefined);
|
||||
getModelPricing.mockResolvedValue([]);
|
||||
openAppConfigFolder.mockResolvedValue(undefined);
|
||||
syncModelsDevPricing.mockResolvedValue({
|
||||
skipped: false,
|
||||
selected: 2,
|
||||
imported: 2,
|
||||
changed: 1,
|
||||
syncedAt: Date.now(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
name: "DeepSeek",
|
||||
models: {
|
||||
"deepseek-chat": {
|
||||
name: "DeepSeek Chat",
|
||||
release_date: "2025-12-01",
|
||||
cost: { input: 0.3, output: 1.2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads automatic sync as disabled by default", async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(state.configPath)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists disabling without showing the overwrite warning", async () => {
|
||||
const enabledState = {
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: true },
|
||||
};
|
||||
getModelsDevSyncConfig.mockResolvedValue(enabledState);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
await waitFor(() =>
|
||||
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||
...enabledState.config,
|
||||
autoSyncEnabled: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about price overwrites before enabling automatic sync", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.enableConfirmAction",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||
...state.config,
|
||||
autoSyncEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps automatic sync disabled when the overwrite warning is cancelled", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "common.cancel" }),
|
||||
);
|
||||
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("reloads the automatic sync config after reading the local pricing file", async () => {
|
||||
const initialState = {
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: true },
|
||||
};
|
||||
getModelsDevSyncConfig
|
||||
.mockResolvedValueOnce(initialState)
|
||||
.mockResolvedValue(state);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.reloadLocalFile",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getModelsDevSyncConfig).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(getModelPricing).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
|
||||
});
|
||||
|
||||
it("opens the searchable multi-select dialog with common models selected", async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.configure",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.configureTitle"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("GPT-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("DeepSeek Chat")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("usage.modelsDevAutoSync.selectedCount:2"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText("usage.modelsDevAutoSync.commonBadge"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
formatPrice,
|
||||
normalizeModelIdForPricing,
|
||||
} from "@/components/usage/ModelsDevPickerDialog";
|
||||
import {
|
||||
getCommonModelKeys,
|
||||
resolveModelsDevSelection,
|
||||
toModelPricing,
|
||||
} from "@/lib/modelsDevPricing";
|
||||
|
||||
describe("normalizeModelIdForPricing", () => {
|
||||
it("keeps already-normalized ids unchanged", () => {
|
||||
@@ -141,4 +146,183 @@ describe("flattenModels", () => {
|
||||
// 完全没有定价的模型被过滤
|
||||
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters deprecated and non-text output models while keeping multimodal input models", () => {
|
||||
const entries = flattenModels({
|
||||
acme: {
|
||||
models: {
|
||||
"multimodal-chat": {
|
||||
name: "Multimodal Chat",
|
||||
modalities: {
|
||||
input: ["text", "image", "audio", "video"],
|
||||
output: ["text"],
|
||||
},
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"legacy-chat": {
|
||||
status: "deprecated",
|
||||
modalities: { output: ["text"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"speech-model": {
|
||||
modalities: { output: ["audio"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"mixed-output-model": {
|
||||
modalities: { output: ["text", "audio"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"movie-generator": {
|
||||
modalities: { output: ["video"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"fallback-video-model": {
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.modelId)).toEqual(["multimodal-chat"]);
|
||||
});
|
||||
|
||||
it("selects a bounded canonical set of common model families", () => {
|
||||
const openAiModels = Object.fromEntries(
|
||||
Array.from({ length: 7 }, (_, index) => {
|
||||
const version = index + 1;
|
||||
return [
|
||||
`gpt-${version}`,
|
||||
{
|
||||
name: `GPT ${version}`,
|
||||
release_date: `2025-0${version}-01`,
|
||||
cost: { input: version, output: version * 2 },
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const entries = flattenModels({
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
...openAiModels,
|
||||
"gpt-image-1": {
|
||||
release_date: "2026-01-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
aggregator: {
|
||||
name: "Aggregator",
|
||||
models: {
|
||||
"gpt-7": {
|
||||
release_date: "2026-02-01",
|
||||
cost: { input: 9, output: 18 },
|
||||
},
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
name: "Anthropic",
|
||||
models: {
|
||||
"claude-sonnet-5": {
|
||||
release_date: "2026-06-01",
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
name: "DeepSeek",
|
||||
models: {
|
||||
"deepseek-chat": {
|
||||
release_date: "2025-12-01",
|
||||
cost: { input: 0.3, output: 1.2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
xiaomi: {
|
||||
name: "Xiaomi",
|
||||
models: {
|
||||
"mimo-v2.5": {
|
||||
release_date: "2026-04-01",
|
||||
cost: { input: 0.2, output: 1 },
|
||||
},
|
||||
"mimo-v2.5-tts": {
|
||||
release_date: "2026-05-01",
|
||||
cost: { input: 0.1, output: 0.5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
longcat: {
|
||||
name: "LongCat",
|
||||
models: {
|
||||
"LongCat-2.0": {
|
||||
release_date: "2026-03-01",
|
||||
cost: { input: 0.4, output: 1.6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const common = getCommonModelKeys(entries);
|
||||
expect(common.has("openai/gpt-image-1")).toBe(false);
|
||||
expect(common.has("aggregator/gpt-7")).toBe(false);
|
||||
expect(common.has("openai/gpt-7")).toBe(true);
|
||||
expect(common.has("openai/gpt-1")).toBe(false);
|
||||
expect(common.has("anthropic/claude-sonnet-5")).toBe(true);
|
||||
expect(common.has("deepseek/deepseek-chat")).toBe(true);
|
||||
expect(common.has("xiaomi/mimo-v2.5")).toBe(true);
|
||||
expect(common.has("xiaomi/mimo-v2.5-tts")).toBe(false);
|
||||
expect(common.has("longcat/LongCat-2.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("combines common and explicit selections and deduplicates normalized ids", () => {
|
||||
const entries = flattenModels({
|
||||
openai: {
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5 Official",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
relay: {
|
||||
models: {
|
||||
"vendor/GPT-5": {
|
||||
name: "GPT-5 Relay",
|
||||
release_date: "2025-07-01",
|
||||
cost: { input: 9, output: 18 },
|
||||
},
|
||||
"custom-model": {
|
||||
name: "Custom",
|
||||
release_date: "2025-06-01",
|
||||
cost: { input: 0.5, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const selected = resolveModelsDevSelection(entries, {
|
||||
autoSyncEnabled: true,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: ["relay/vendor/GPT-5", "relay/custom-model"],
|
||||
excludedCommonModelKeys: ["openai/gpt-5"],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
});
|
||||
|
||||
expect(selected.map((entry) => entry.key)).toEqual([
|
||||
"relay/vendor/GPT-5",
|
||||
"relay/custom-model",
|
||||
]);
|
||||
|
||||
const pricing = toModelPricing([
|
||||
entries.find((entry) => entry.key === "openai/gpt-5")!,
|
||||
entries.find((entry) => entry.key === "relay/vendor/GPT-5")!,
|
||||
]);
|
||||
expect(pricing).toHaveLength(1);
|
||||
expect(pricing[0]).toMatchObject({
|
||||
modelId: "gpt-5",
|
||||
displayName: "GPT-5 Official",
|
||||
inputCostPerMillion: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getModelsDevSyncConfig,
|
||||
updateModelPricingBatch,
|
||||
recordModelsDevSyncResult,
|
||||
} = vi.hoisted(() => ({
|
||||
getModelsDevSyncConfig: vi.fn(),
|
||||
updateModelPricingBatch: vi.fn(),
|
||||
recordModelsDevSyncResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/usage", () => ({
|
||||
usageApi: {
|
||||
getModelsDevSyncConfig,
|
||||
updateModelPricingBatch,
|
||||
recordModelsDevSyncResult,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS,
|
||||
syncModelsDevPricing,
|
||||
} from "@/lib/modelsDevAutoSync";
|
||||
|
||||
const state = {
|
||||
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||
config: {
|
||||
autoSyncEnabled: true,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
excludedCommonModelKeys: [],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("syncModelsDevPricing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateModelPricingBatch.mockResolvedValue(2);
|
||||
recordModelsDevSyncResult.mockResolvedValue(undefined);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
openai: {
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
relay: {
|
||||
models: {
|
||||
"custom-model": {
|
||||
name: "Custom Model",
|
||||
release_date: "2025-07-01",
|
||||
cost: { input: 0.5, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips network access when automatic sync is disabled", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValue({
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: false },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips startup network access when pricing synced within the interval", async () => {
|
||||
const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1;
|
||||
getModelsDevSyncConfig.mockResolvedValue({
|
||||
...state,
|
||||
config: { ...state.config, lastSyncAt },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result).toEqual({
|
||||
skipped: true,
|
||||
selected: 0,
|
||||
imported: 0,
|
||||
changed: 0,
|
||||
syncedAt: lastSyncAt,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports common and explicitly selected models in one batch", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledTimes(1);
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "gpt-5", inputCostPerMillion: "1" }),
|
||||
expect.objectContaining({
|
||||
modelId: "custom-model",
|
||||
inputCostPerMillion: "0.5",
|
||||
}),
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
skipped: false,
|
||||
selected: 2,
|
||||
imported: 2,
|
||||
changed: 2,
|
||||
});
|
||||
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
null,
|
||||
);
|
||||
const fetchOptions = vi.mocked(fetch).mock.calls[0]?.[1];
|
||||
expect(fetchOptions).toEqual({ signal: expect.any(AbortSignal) });
|
||||
expect(fetchOptions).not.toHaveProperty("cache");
|
||||
});
|
||||
|
||||
it("stops a startup sync when automatic sync is disabled during download", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: false, lastSyncAt: 123 },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result).toEqual({
|
||||
skipped: true,
|
||||
selected: 0,
|
||||
imported: 0,
|
||||
changed: 0,
|
||||
syncedAt: 123,
|
||||
});
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
expect(recordModelsDevSyncResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the latest model selection after the download completes", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
includeCommonModels: false,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
},
|
||||
});
|
||||
|
||||
await syncModelsDevPricing();
|
||||
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "custom-model" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the latest selection for a forced sync even when automatic sync is disabled", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce({
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
autoSyncEnabled: false,
|
||||
includeCommonModels: false,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
lastSyncAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing(state, true);
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "custom-model" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the last error without replacing the previous success time", async () => {
|
||||
const previous = { ...state, config: { ...state.config, lastSyncAt: 123 } };
|
||||
getModelsDevSyncConfig.mockResolvedValue(previous);
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
await expect(syncModelsDevPricing()).rejects.toThrow("offline");
|
||||
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(null, "offline");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user