Skip to content

Commit 8448f48

Browse files
committed
tests(feishu): inject client runtime seam
1 parent 3e8bf84 commit 8448f48

File tree

3 files changed

+103
-40
lines changed

3 files changed

+103
-40
lines changed

extensions/feishu/src/client.test.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
33

4+
const clientCtorMock = vi.hoisted(() => vi.fn());
45
const wsClientCtorMock = vi.hoisted(() =>
56
vi.fn(function wsClientCtor() {
67
return { connected: true };
@@ -22,29 +23,14 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
2223
head: vi.fn().mockResolvedValue({}),
2324
options: vi.fn().mockResolvedValue({}),
2425
}));
25-
26-
vi.mock("@larksuiteoapi/node-sdk", () => ({
27-
AppType: { SelfBuild: "self" },
28-
Domain: { Feishu: "https://open.feishu.cn", Lark: "https://open.larksuite.com" },
29-
LoggerLevel: { info: "info" },
30-
Client: vi.fn(),
31-
WSClient: wsClientCtorMock,
32-
EventDispatcher: vi.fn(),
33-
defaultHttpInstance: mockBaseHttpInstance,
34-
}));
35-
36-
vi.mock("https-proxy-agent", () => ({
37-
HttpsProxyAgent: httpsProxyAgentCtorMock,
38-
}));
39-
40-
import { Client as LarkClient } from "@larksuiteoapi/node-sdk";
4126
import {
4227
createFeishuClient,
4328
createFeishuWSClient,
4429
clearClientCache,
4530
FEISHU_HTTP_TIMEOUT_MS,
4631
FEISHU_HTTP_TIMEOUT_MAX_MS,
4732
FEISHU_HTTP_TIMEOUT_ENV_VAR,
33+
setFeishuClientRuntimeForTest,
4834
} from "./client.js";
4935

5036
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
@@ -78,6 +64,21 @@ beforeEach(() => {
7864
delete process.env[key];
7965
}
8066
vi.clearAllMocks();
67+
setFeishuClientRuntimeForTest({
68+
sdk: {
69+
AppType: { SelfBuild: "self" } as never,
70+
Domain: {
71+
Feishu: "https://open.feishu.cn",
72+
Lark: "https://open.larksuite.com",
73+
} as never,
74+
LoggerLevel: { info: "info" } as never,
75+
Client: clientCtorMock as never,
76+
WSClient: wsClientCtorMock as never,
77+
EventDispatcher: vi.fn() as never,
78+
defaultHttpInstance: mockBaseHttpInstance as never,
79+
},
80+
HttpsProxyAgent: httpsProxyAgentCtorMock as never,
81+
});
8182
});
8283

8384
afterEach(() => {
@@ -94,6 +95,7 @@ afterEach(() => {
9495
} else {
9596
process.env[FEISHU_HTTP_TIMEOUT_ENV_VAR] = priorFeishuTimeoutEnv;
9697
}
98+
setFeishuClientRuntimeForTest();
9799
});
98100

99101
describe("createFeishuClient HTTP timeout", () => {
@@ -102,7 +104,7 @@ describe("createFeishuClient HTTP timeout", () => {
102104
});
103105

104106
const getLastClientHttpInstance = () => {
105-
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
107+
const calls = clientCtorMock.mock.calls;
106108
const lastCall = calls[calls.length - 1]?.[0] as
107109
| { httpInstance?: { get: (...args: unknown[]) => Promise<unknown> } }
108110
| undefined;
@@ -122,15 +124,15 @@ describe("createFeishuClient HTTP timeout", () => {
122124
it("passes a custom httpInstance with default timeout to Lark.Client", () => {
123125
createFeishuClient({ appId: "app_1", appSecret: "secret_1", accountId: "timeout-test" }); // pragma: allowlist secret
124126

125-
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
127+
const calls = clientCtorMock.mock.calls;
126128
const lastCall = calls[calls.length - 1][0] as { httpInstance?: unknown };
127129
expect(lastCall.httpInstance).toBeDefined();
128130
});
129131

130132
it("injects default timeout into HTTP request options", async () => {
131133
createFeishuClient({ appId: "app_2", appSecret: "secret_2", accountId: "timeout-inject" }); // pragma: allowlist secret
132134

133-
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
135+
const calls = clientCtorMock.mock.calls;
134136
const lastCall = calls[calls.length - 1][0] as {
135137
httpInstance: { post: (...args: unknown[]) => Promise<unknown> };
136138
};
@@ -152,7 +154,7 @@ describe("createFeishuClient HTTP timeout", () => {
152154
it("allows explicit timeout override per-request", async () => {
153155
createFeishuClient({ appId: "app_3", appSecret: "secret_3", accountId: "timeout-override" }); // pragma: allowlist secret
154156

155-
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
157+
const calls = clientCtorMock.mock.calls;
156158
const lastCall = calls[calls.length - 1][0] as {
157159
httpInstance: { get: (...args: unknown[]) => Promise<unknown> };
158160
};
@@ -241,7 +243,7 @@ describe("createFeishuClient HTTP timeout", () => {
241243
config: { httpTimeoutMs: 45_000 },
242244
});
243245

244-
const calls = (LarkClient as unknown as ReturnType<typeof vi.fn>).mock.calls;
246+
const calls = clientCtorMock.mock.calls;
245247
expect(calls.length).toBe(2);
246248

247249
const lastCall = calls[calls.length - 1][0] as {

extensions/feishu/src/client.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@ import * as Lark from "@larksuiteoapi/node-sdk";
22
import { HttpsProxyAgent } from "https-proxy-agent";
33
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
44

5+
type FeishuClientSdk = Pick<
6+
typeof Lark,
7+
| "AppType"
8+
| "Client"
9+
| "defaultHttpInstance"
10+
| "Domain"
11+
| "EventDispatcher"
12+
| "LoggerLevel"
13+
| "WSClient"
14+
>;
15+
16+
const defaultFeishuClientSdk: FeishuClientSdk = {
17+
AppType: Lark.AppType,
18+
Client: Lark.Client,
19+
defaultHttpInstance: Lark.defaultHttpInstance,
20+
Domain: Lark.Domain,
21+
EventDispatcher: Lark.EventDispatcher,
22+
LoggerLevel: Lark.LoggerLevel,
23+
WSClient: Lark.WSClient,
24+
};
25+
26+
let feishuClientSdk: FeishuClientSdk = defaultFeishuClientSdk;
27+
let httpsProxyAgentCtor: typeof HttpsProxyAgent = HttpsProxyAgent;
28+
529
/** Default HTTP timeout for Feishu API requests (30 seconds). */
630
export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
731
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
@@ -14,7 +38,7 @@ function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
1438
process.env.http_proxy ||
1539
process.env.HTTP_PROXY;
1640
if (!proxyUrl) return undefined;
17-
return new HttpsProxyAgent(proxyUrl);
41+
return new httpsProxyAgentCtor(proxyUrl);
1842
}
1943

2044
// Multi-account client cache
@@ -28,10 +52,10 @@ const clientCache = new Map<
2852

2953
function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
3054
if (domain === "lark") {
31-
return Lark.Domain.Lark;
55+
return feishuClientSdk.Domain.Lark;
3256
}
3357
if (domain === "feishu" || !domain) {
34-
return Lark.Domain.Feishu;
58+
return feishuClientSdk.Domain.Feishu;
3559
}
3660
return domain.replace(/\/+$/, ""); // Custom URL for private deployment
3761
}
@@ -42,7 +66,8 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
4266
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
4367
*/
4468
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
45-
const base: Lark.HttpInstance = Lark.defaultHttpInstance as unknown as Lark.HttpInstance;
69+
const base: Lark.HttpInstance =
70+
feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance;
4671

4772
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
4873
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;
@@ -129,10 +154,10 @@ export function createFeishuClient(creds: FeishuClientCredentials): Lark.Client
129154
}
130155

131156
// Create new client with timeout-aware HTTP instance
132-
const client = new Lark.Client({
157+
const client = new feishuClientSdk.Client({
133158
appId,
134159
appSecret,
135-
appType: Lark.AppType.SelfBuild,
160+
appType: feishuClientSdk.AppType.SelfBuild,
136161
domain: resolveDomain(domain),
137162
httpInstance: createTimeoutHttpInstance(defaultHttpTimeoutMs),
138163
});
@@ -158,11 +183,11 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
158183
}
159184

160185
const agent = getWsProxyAgent();
161-
return new Lark.WSClient({
186+
return new feishuClientSdk.WSClient({
162187
appId,
163188
appSecret,
164189
domain: resolveDomain(domain),
165-
loggerLevel: Lark.LoggerLevel.info,
190+
loggerLevel: feishuClientSdk.LoggerLevel.info,
166191
...(agent ? { agent } : {}),
167192
});
168193
}
@@ -171,7 +196,7 @@ export function createFeishuWSClient(account: ResolvedFeishuAccount): Lark.WSCli
171196
* Create an event dispatcher for an account.
172197
*/
173198
export function createEventDispatcher(account: ResolvedFeishuAccount): Lark.EventDispatcher {
174-
return new Lark.EventDispatcher({
199+
return new feishuClientSdk.EventDispatcher({
175200
encryptKey: account.encryptKey,
176201
verificationToken: account.verificationToken,
177202
});
@@ -194,3 +219,13 @@ export function clearClientCache(accountId?: string): void {
194219
clientCache.clear();
195220
}
196221
}
222+
223+
export function setFeishuClientRuntimeForTest(overrides?: {
224+
sdk?: Partial<FeishuClientSdk>;
225+
HttpsProxyAgent?: typeof HttpsProxyAgent;
226+
}): void {
227+
feishuClientSdk = overrides?.sdk
228+
? { ...defaultFeishuClientSdk, ...overrides.sdk }
229+
: defaultFeishuClientSdk;
230+
httpsProxyAgentCtor = overrides?.HttpsProxyAgent ?? HttpsProxyAgent;
231+
}

extensions/feishu/src/probe.test.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const createFeishuClientMock = vi.hoisted(() => vi.fn());
4-
5-
vi.mock("./client.js", () => ({
6-
createFeishuClient: createFeishuClientMock,
3+
const clientCtorMock = vi.hoisted(() => vi.fn());
4+
const mockBaseHttpInstance = vi.hoisted(() => ({
5+
request: vi.fn().mockResolvedValue({}),
6+
get: vi.fn().mockResolvedValue({}),
7+
post: vi.fn().mockResolvedValue({}),
8+
put: vi.fn().mockResolvedValue({}),
9+
patch: vi.fn().mockResolvedValue({}),
10+
delete: vi.fn().mockResolvedValue({}),
11+
head: vi.fn().mockResolvedValue({}),
12+
options: vi.fn().mockResolvedValue({}),
713
}));
814

15+
import { clearClientCache, setFeishuClientRuntimeForTest } from "./client.js";
916
import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
1017

1118
const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
@@ -28,9 +35,15 @@ function makeRequestFn(response: Record<string, unknown>) {
2835
return vi.fn().mockResolvedValue(response);
2936
}
3037

38+
function installClientCtor(requestFn: unknown) {
39+
clientCtorMock.mockImplementation(function MockFeishuClient(this: { request: unknown }) {
40+
this.request = requestFn;
41+
} as never);
42+
}
43+
3144
function setupClient(response: Record<string, unknown>) {
3245
const requestFn = makeRequestFn(response);
33-
createFeishuClientMock.mockReturnValue({ request: requestFn });
46+
installClientCtor(requestFn);
3447
return requestFn;
3548
}
3649

@@ -60,7 +73,7 @@ async function expectErrorResultCached(params: {
6073
expectedError: string;
6174
ttlMs: number;
6275
}) {
63-
createFeishuClientMock.mockReturnValue({ request: params.requestFn });
76+
installClientCtor(params.requestFn);
6477

6578
const first = await probeFeishu(DEFAULT_CREDS);
6679
const second = await probeFeishu(DEFAULT_CREDS);
@@ -95,11 +108,25 @@ async function readSequentialDefaultProbePair() {
95108
describe("probeFeishu", () => {
96109
beforeEach(() => {
97110
clearProbeCache();
98-
vi.restoreAllMocks();
111+
clearClientCache();
112+
vi.clearAllMocks();
113+
setFeishuClientRuntimeForTest({
114+
sdk: {
115+
AppType: { SelfBuild: "self" } as never,
116+
Domain: {
117+
Feishu: "https://open.feishu.cn",
118+
Lark: "https://open.larksuite.com",
119+
} as never,
120+
Client: clientCtorMock as never,
121+
defaultHttpInstance: mockBaseHttpInstance as never,
122+
},
123+
});
99124
});
100125

101126
afterEach(() => {
102127
clearProbeCache();
128+
clearClientCache();
129+
setFeishuClientRuntimeForTest();
103130
});
104131

105132
it("returns error when credentials are missing", async () => {
@@ -141,7 +168,7 @@ describe("probeFeishu", () => {
141168
it("returns timeout error when request exceeds timeout", async () => {
142169
await withFakeTimers(async () => {
143170
const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
144-
createFeishuClientMock.mockReturnValue({ request: requestFn });
171+
installClientCtor(requestFn);
145172

146173
const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
147174
await vi.advanceTimersByTimeAsync(1_000);
@@ -152,7 +179,6 @@ describe("probeFeishu", () => {
152179
});
153180

154181
it("returns aborted when abort signal is already aborted", async () => {
155-
createFeishuClientMock.mockClear();
156182
const abortController = new AbortController();
157183
abortController.abort();
158184

@@ -162,7 +188,7 @@ describe("probeFeishu", () => {
162188
);
163189

164190
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
165-
expect(createFeishuClientMock).not.toHaveBeenCalled();
191+
expect(clientCtorMock).not.toHaveBeenCalled();
166192
});
167193
it("returns cached result on subsequent calls within TTL", async () => {
168194
const requestFn = setupSuccessClient();

0 commit comments

Comments
 (0)