mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
d6f70a96cb
* fix(plugins): preserve selected command identity * test(telegram): use scoped command registries * test(telegram): isolate command runtime fixtures * test(telegram): warm native command runtime * refactor(plugins): keep command metadata private * fix(plugins): accept synchronous command handlers * fix(plugins): scope command drain bypass to live execution * test(telegram): use scoped command registry fixtures * test(telegram): isolate native menu runtime fixtures * test(telegram): isolate login session store * test(telegram): surface login flow failures * test(telegram): preload native login module * test(telegram): scope native command registries * fix(plugins): complete command dispatch contracts * fix(plugins): break command dispatch import cycles * fix(plugins): stabilize command dispatch contracts * fix(channels): keep plugin dispatch options internal * fix(plugins): keep command dispatch carrier opaque * test(channels): align delivery adapter fixtures * test(delivery): align custody ownership coverage * test(delivery): align latest queue reconciliation * test(channels): drop obsolete delivery wrappers * fix(plugins): rebind channel reload starts * fix(plugins): scope command catalog reloads * fix(ci): align current runtime contracts * chore(plugin-sdk): refresh API baseline
536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
// Tests Telegram native Codex login command behavior.
|
|
import {
|
|
createEmptyPluginRegistry,
|
|
withPluginRuntimeRegistryScope,
|
|
} from "openclaw/plugin-sdk/channel-test-helpers";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
|
import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime";
|
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createTelegramGroupCommandContext } from "./bot-native-commands.fixture-test-support.js";
|
|
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
|
import {
|
|
createCommandBot,
|
|
createNativeCommandTestParams,
|
|
createPrivateCommandContext,
|
|
resetNativeCommandMenuMocks,
|
|
} from "./bot-native-commands.menu-test-support.js";
|
|
import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js";
|
|
import { resetTelegramForumFlagCacheForTest } from "./bot/helpers.js";
|
|
|
|
vi.mock("./bot-native-commands.runtime.js", () => ({
|
|
ensureConfiguredBindingRouteReady: vi.fn(async () => ({ ok: true })),
|
|
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
|
getAgentScopedMediaLocalRoots: vi.fn(() => []),
|
|
getSessionEntry: vi.fn(() => undefined),
|
|
recordInboundSessionMetaSafe: vi.fn(async () => undefined),
|
|
resolveChunkMode: vi.fn(() => "length"),
|
|
resolveThreadSessionKeys: vi.fn(
|
|
({
|
|
baseSessionKey,
|
|
parentSessionKey,
|
|
}: {
|
|
baseSessionKey: string;
|
|
parentSessionKey?: string;
|
|
}) => ({
|
|
sessionKey: baseSessionKey,
|
|
parentSessionKey,
|
|
}),
|
|
),
|
|
}));
|
|
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({
|
|
formatSqliteSessionFileMarker: vi.fn(() => "sqlite:test"),
|
|
getSessionEntry: vi.fn(() => undefined),
|
|
resolveStorePath: vi.fn(() => "/tmp/openclaw-login-test.sqlite"),
|
|
updateSessionStoreEntry: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
type LoginFlowMock = ReturnType<typeof vi.fn>;
|
|
|
|
let loginAccountIndex = 0;
|
|
|
|
function registerLoginCommand(params: {
|
|
cfg: OpenClawConfig;
|
|
loginFlow: LoginFlowMock;
|
|
allowFrom?: string[];
|
|
abortSignal?: AbortSignal;
|
|
runtime?: RuntimeEnv;
|
|
}) {
|
|
const botHarness = createCommandBot();
|
|
const accountId = `login-test-${++loginAccountIndex}`;
|
|
const nativeParams = createNativeCommandTestParams(params.cfg, {
|
|
accountId,
|
|
bot: botHarness.bot,
|
|
allowFrom: params.allowFrom ?? ["200"],
|
|
...(params.abortSignal
|
|
? {
|
|
opts: {
|
|
token: "token",
|
|
accountAbortSignal: params.abortSignal,
|
|
},
|
|
}
|
|
: {}),
|
|
...(params.runtime ? { runtime: params.runtime } : {}),
|
|
});
|
|
const sendMessageTelegram = vi.fn(async (_to, text) => {
|
|
const result = await botHarness.bot.api.sendMessage(100, text, {});
|
|
return { messageId: String(result.message_id), chatId: "100" };
|
|
});
|
|
const nativeCommandCallbackDispatcher = withPluginRuntimeRegistryScope(
|
|
createEmptyPluginRegistry(),
|
|
() =>
|
|
registerTelegramNativeCommands({
|
|
...nativeParams,
|
|
telegramDeps: {
|
|
...nativeParams.telegramDeps,
|
|
runModelsAuthLoginFlow: params.loginFlow,
|
|
sendMessageTelegram,
|
|
} as never,
|
|
}),
|
|
);
|
|
const handler = botHarness.commandHandlers.get("login");
|
|
if (!handler) {
|
|
throw new Error("expected login command handler to be registered");
|
|
}
|
|
return {
|
|
...botHarness,
|
|
accountId,
|
|
handler,
|
|
nativeCommandCallbackDispatcher,
|
|
sendMessageTelegram,
|
|
};
|
|
}
|
|
|
|
describe("registerTelegramNativeCommands /login", () => {
|
|
beforeEach(() => {
|
|
resetTelegramForumFlagCacheForTest();
|
|
resetNativeCommandMenuMocks();
|
|
});
|
|
|
|
it("handles /login codex by sending the device code before login completes", async () => {
|
|
let loginParams: ModelsAuthLoginFlowOptions | undefined;
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
loginParams = params;
|
|
await params.prompter.deviceCode?.({
|
|
title: "OpenAI Codex device code",
|
|
code: "ABCD-EFGH",
|
|
expiresInMinutes: 15,
|
|
message: [
|
|
"Open this URL in your LOCAL browser and enter the code below.",
|
|
"URL: https://auth.openai.com/codex/device",
|
|
].join("\n"),
|
|
});
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { handler, sendMessage, setMyCommands } = registerLoginCommand({
|
|
cfg: {
|
|
commands: {
|
|
native: true,
|
|
ownerAllowFrom: ["200"],
|
|
},
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
|
|
expect(setMyCommands).toHaveBeenCalledOnce();
|
|
const registeredCommands = setMyCommands.mock.calls[0]?.[0];
|
|
expect(registeredCommands).toContainEqual({
|
|
command: "login",
|
|
description: "Pair Codex login.",
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
expect(loginParams).toMatchObject({ provider: "openai", method: "device-code", agent: "main" });
|
|
await vi.waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(2), { timeout: 5_000 });
|
|
|
|
const texts = sendMessage.mock.calls.map((call) => String(call[1]));
|
|
expect(texts[0]).toContain("URL: https://auth.openai.com/codex/device");
|
|
expect(texts[0]).toContain("Code: <code>ABCD-EFGH</code>");
|
|
expect(texts[0]).toContain("Never share it.");
|
|
expect(sendMessage.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
|
|
expect(texts.at(-1)).toContain("Codex login complete. Try your request again now.");
|
|
});
|
|
|
|
it("releases the chat lane only after structured device-code delivery", async () => {
|
|
const allowDeviceCode = createDeferred<void>();
|
|
const finishLogin = createDeferred<void>();
|
|
let loginCompleted = false;
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.note("Preparing Codex login…");
|
|
await allowDeviceCode.promise;
|
|
if (!params.prompter.deviceCode) {
|
|
throw new Error("expected structured device-code delivery");
|
|
}
|
|
await params.prompter.deviceCode({
|
|
title: "OpenAI Codex device code",
|
|
code: "PENDING-CODE",
|
|
expiresInMinutes: 15,
|
|
message: "URL: https://auth.openai.com/codex/device",
|
|
});
|
|
await finishLogin.promise;
|
|
loginCompleted = true;
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
|
|
let handlerReturned = false;
|
|
const handlerTask = handler(createPrivateCommandContext({ match: "codex", userId: 200 })).then(
|
|
() => {
|
|
handlerReturned = true;
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"Preparing Codex login…",
|
|
),
|
|
);
|
|
expect(handlerReturned).toBe(false);
|
|
|
|
allowDeviceCode.resolve();
|
|
await handlerTask;
|
|
|
|
expect(loginCompleted).toBe(false);
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
100,
|
|
expect.stringContaining("Code: <code>PENDING-CODE</code>"),
|
|
expect.objectContaining({ parse_mode: "HTML" }),
|
|
);
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).not.toContain(
|
|
"Codex login complete. Try your request again now.",
|
|
);
|
|
|
|
finishLogin.resolve();
|
|
await vi.waitFor(() => expect(loginCompleted).toBe(true));
|
|
await vi.waitFor(() =>
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"Codex login complete. Try your request again now.",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("routes the login button through the non-blocking native login flow", async () => {
|
|
const finishLogin = createDeferred<void>();
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.deviceCode?.({
|
|
title: "OpenAI Codex device code",
|
|
code: "BUTTON-CODE",
|
|
expiresInMinutes: 15,
|
|
message: "URL: https://auth.openai.com/codex/device",
|
|
});
|
|
await finishLogin.promise;
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { nativeCommandCallbackDispatcher, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
if (!nativeCommandCallbackDispatcher) {
|
|
throw new Error("expected login callback dispatcher to be registered");
|
|
}
|
|
const callbackQuery = {
|
|
id: "login-button",
|
|
chat_instance: "login-button-chat",
|
|
data: "tgcmd:/login codex",
|
|
from: { id: 200, is_bot: false, first_name: "Bob", username: "bob" },
|
|
message: {
|
|
message_id: 10,
|
|
date: Math.floor(Date.now() / 1000),
|
|
chat: { id: 100, type: "private" as const, first_name: "Owner" },
|
|
reply_markup: {
|
|
inline_keyboard: [[{ text: "Log in to Codex", callback_data: "tgcmd:/login codex" }]],
|
|
},
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
nativeCommandCallbackDispatcher({
|
|
commandText: "/login codex",
|
|
botUser: telegramBotInfoForTest,
|
|
callbackQuery,
|
|
}),
|
|
).resolves.toEqual({ handled: true, clearButtons: true });
|
|
|
|
expect(loginFlow).toHaveBeenCalledOnce();
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
100,
|
|
expect.stringContaining("Code: <code>BUTTON-CODE</code>"),
|
|
expect.objectContaining({ parse_mode: "HTML" }),
|
|
);
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).not.toContain(
|
|
"Codex login complete. Try your request again now.",
|
|
);
|
|
|
|
finishLogin.resolve();
|
|
await vi.waitFor(() =>
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"Codex login complete. Try your request again now.",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("rejects group /login codex without sending the device code publicly", async () => {
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.note("URL: https://auth.openai.com/codex/device\nCode: SECRET");
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: {
|
|
native: true,
|
|
ownerAllowFrom: ["200"],
|
|
},
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
allowFrom: ["200"],
|
|
});
|
|
|
|
await handler(createTelegramGroupCommandContext({ match: "codex", userId: 200 }));
|
|
|
|
expect(loginFlow).not.toHaveBeenCalled();
|
|
const texts = sendMessage.mock.calls.map((call) => String(call[1]));
|
|
expect(texts).toContain(
|
|
"For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.",
|
|
);
|
|
expect(texts.join("\n")).not.toContain("SECRET");
|
|
expect(texts.join("\n")).not.toContain("https://auth.openai.com/codex/device");
|
|
});
|
|
|
|
it("rejects /login for authorized senders who are not owners", async () => {
|
|
const loginFlow = vi.fn(async () => ({
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [],
|
|
}));
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: {
|
|
native: true,
|
|
allowFrom: { telegram: ["200"] },
|
|
ownerAllowFrom: ["999"],
|
|
},
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
|
|
expect(loginFlow).not.toHaveBeenCalled();
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"Only a configured OpenClaw owner can start Codex login from Telegram.",
|
|
);
|
|
});
|
|
|
|
it("dedupes active /login flows for the same Telegram thread", async () => {
|
|
const deferred = createDeferred<void>();
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.deviceCode?.({
|
|
title: "OpenAI Codex device code",
|
|
code: "FIRST-CODE",
|
|
expiresInMinutes: 15,
|
|
message: "URL: https://auth.openai.com/codex/device",
|
|
});
|
|
await deferred.promise;
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: {
|
|
native: true,
|
|
ownerAllowFrom: ["200"],
|
|
},
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
deferred.resolve();
|
|
await vi.waitFor(() =>
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"Codex login complete. Try your request again now.",
|
|
),
|
|
);
|
|
|
|
expect(loginFlow).toHaveBeenCalledTimes(1);
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain(
|
|
"A Codex login code is already active for this Telegram chat. Complete it, or wait for it to expire before requesting a new one.",
|
|
);
|
|
});
|
|
|
|
it("releases a failed flow before any device code is delivered", async () => {
|
|
const loginFlow = vi.fn(async () => {
|
|
throw new Error("device-code request failed");
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
|
|
expect(loginFlow).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
sendMessage.mock.calls.filter(
|
|
(call) =>
|
|
call[1] === "Codex login did not complete. Send `/login codex` to request a new code.",
|
|
),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it("does not report auth failure when only the terminal notification fails", async () => {
|
|
const runtime: RuntimeEnv = { error: vi.fn(), exit: vi.fn(), log: vi.fn() };
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.deviceCode?.({ title: "Codex login", code: "SUCCESS-CODE" });
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
runtime,
|
|
});
|
|
sendMessage.mockResolvedValueOnce({ message_id: 999 });
|
|
sendMessage.mockRejectedValueOnce(new Error("Telegram unavailable"));
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
await vi.waitFor(() =>
|
|
expect(runtime.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("result notification failed"),
|
|
),
|
|
);
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).not.toContain(
|
|
"Codex login did not complete. Send `/login codex` to request a new code.",
|
|
);
|
|
});
|
|
|
|
it("blocks provider prompts and terminal messages after Telegram stops", async () => {
|
|
const shutdown = new AbortController();
|
|
let loginSettled = false;
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
await params.prompter.deviceCode?.({ title: "Codex login", code: "ABORT-CODE" });
|
|
if (!params.signal) {
|
|
throw new Error("expected login owner signal");
|
|
}
|
|
const signal = params.signal;
|
|
try {
|
|
await new Promise<void>((_resolve, reject) => {
|
|
signal.addEventListener(
|
|
"abort",
|
|
() =>
|
|
reject(
|
|
signal.reason instanceof Error ? signal.reason : new Error("Telegram stopped"),
|
|
),
|
|
{ once: true },
|
|
);
|
|
});
|
|
throw new Error("unreachable");
|
|
} catch {
|
|
await params.prompter.note("Trouble with device code login?", "OAuth help");
|
|
} finally {
|
|
loginSettled = true;
|
|
}
|
|
});
|
|
const { handler, sendMessage } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
abortSignal: shutdown.signal,
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
shutdown.abort(new Error("Telegram stopped"));
|
|
await vi.waitFor(() => expect(loginSettled).toBe(true));
|
|
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1]))).toHaveLength(1);
|
|
expect(sendMessage.mock.calls[0]?.[1]).toContain("ABORT-CODE");
|
|
});
|
|
|
|
it("keeps pending login alive across a polling-cycle restart", async () => {
|
|
const account = new AbortController();
|
|
const pollingCycle = new AbortController();
|
|
const finishLogin = createDeferred<void>();
|
|
let loginSignal: AbortSignal | undefined;
|
|
const loginFlow = vi.fn(async (params: ModelsAuthLoginFlowOptions) => {
|
|
loginSignal = params.signal;
|
|
await params.prompter.deviceCode?.({ title: "Codex login", code: "RESTART-CODE" });
|
|
await finishLogin.promise;
|
|
return {
|
|
providerId: "openai",
|
|
methodId: "device-code",
|
|
profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }],
|
|
};
|
|
});
|
|
const { accountId, handler, sendMessage, sendMessageTelegram } = registerLoginCommand({
|
|
cfg: {
|
|
commands: { native: true, ownerAllowFrom: ["200"] },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig,
|
|
loginFlow,
|
|
abortSignal: account.signal,
|
|
});
|
|
|
|
await handler(createPrivateCommandContext({ match: "codex", userId: 200 }));
|
|
pollingCycle.abort(new Error("recoverable polling restart"));
|
|
sendMessage.mockRejectedValue(new Error("retired polling bot"));
|
|
sendMessageTelegram.mockResolvedValueOnce({ messageId: "1000", chatId: "100" });
|
|
|
|
expect(loginSignal?.aborted).toBe(false);
|
|
finishLogin.resolve();
|
|
await vi.waitFor(() =>
|
|
expect(sendMessageTelegram).toHaveBeenCalledWith(
|
|
"telegram:100",
|
|
"Codex login complete. Try your request again now.",
|
|
expect.objectContaining({ accountId, token: "token" }),
|
|
),
|
|
);
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|