@openclaw/feishu 2026.3.1 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
3
4
 
4
5
  const probeFeishuMock = vi.hoisted(() => vi.fn());
5
6
 
@@ -12,7 +13,22 @@ vi.mock("./client.js", () => ({
12
13
  createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
13
14
  }));
14
15
 
15
- import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
16
+ vi.mock("./runtime.js", () => ({
17
+ getFeishuRuntime: () => ({
18
+ channel: {
19
+ debounce: {
20
+ resolveInboundDebounceMs: () => 0,
21
+ createInboundDebouncer: () => ({
22
+ enqueue: async () => {},
23
+ flushKey: async () => {},
24
+ }),
25
+ },
26
+ text: {
27
+ hasControlCommand: () => false,
28
+ },
29
+ },
30
+ }),
31
+ }));
16
32
 
17
33
  function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
18
34
  return {
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveFeishuWebhookAnomalyDefaultsForTest,
4
+ resolveFeishuWebhookRateLimitDefaultsForTest,
5
+ } from "./monitor.state.js";
6
+
7
+ describe("feishu monitor state defaults", () => {
8
+ it("falls back to hard defaults when sdk defaults are missing", () => {
9
+ expect(resolveFeishuWebhookRateLimitDefaultsForTest(undefined)).toEqual({
10
+ windowMs: 60_000,
11
+ maxRequests: 120,
12
+ maxTrackedKeys: 4_096,
13
+ });
14
+ expect(resolveFeishuWebhookAnomalyDefaultsForTest(undefined)).toEqual({
15
+ maxTrackedKeys: 4_096,
16
+ ttlMs: 21_600_000,
17
+ logEvery: 25,
18
+ });
19
+ });
20
+
21
+ it("keeps valid sdk values and repairs invalid fields", () => {
22
+ expect(
23
+ resolveFeishuWebhookRateLimitDefaultsForTest({
24
+ windowMs: 45_000,
25
+ maxRequests: 0,
26
+ maxTrackedKeys: -1,
27
+ }),
28
+ ).toEqual({
29
+ windowMs: 45_000,
30
+ maxRequests: 120,
31
+ maxTrackedKeys: 4_096,
32
+ });
33
+
34
+ expect(
35
+ resolveFeishuWebhookAnomalyDefaultsForTest({
36
+ maxTrackedKeys: 2048,
37
+ ttlMs: Number.NaN,
38
+ logEvery: 10,
39
+ }),
40
+ ).toEqual({
41
+ maxTrackedKeys: 2048,
42
+ ttlMs: 21_600_000,
43
+ logEvery: 10,
44
+ });
45
+ });
46
+ });
@@ -4,8 +4,8 @@ import {
4
4
  createFixedWindowRateLimiter,
5
5
  createWebhookAnomalyTracker,
6
6
  type RuntimeEnv,
7
- WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
8
- WEBHOOK_RATE_LIMIT_DEFAULTS,
7
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
8
+ WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
9
9
  } from "openclaw/plugin-sdk";
10
10
 
11
11
  export const wsClients = new Map<string, Lark.WSClient>();
@@ -15,16 +15,92 @@ export const botOpenIds = new Map<string, string>();
15
15
  export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
16
16
  export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
17
17
 
18
+ type WebhookRateLimitDefaults = {
19
+ windowMs: number;
20
+ maxRequests: number;
21
+ maxTrackedKeys: number;
22
+ };
23
+
24
+ type WebhookAnomalyDefaults = {
25
+ maxTrackedKeys: number;
26
+ ttlMs: number;
27
+ logEvery: number;
28
+ };
29
+
30
+ const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = {
31
+ windowMs: 60_000,
32
+ maxRequests: 120,
33
+ maxTrackedKeys: 4_096,
34
+ };
35
+
36
+ const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = {
37
+ maxTrackedKeys: 4_096,
38
+ ttlMs: 6 * 60 * 60_000,
39
+ logEvery: 25,
40
+ };
41
+
42
+ function coercePositiveInt(value: unknown, fallback: number): number {
43
+ if (typeof value !== "number" || !Number.isFinite(value)) {
44
+ return fallback;
45
+ }
46
+ const normalized = Math.floor(value);
47
+ return normalized > 0 ? normalized : fallback;
48
+ }
49
+
50
+ export function resolveFeishuWebhookRateLimitDefaultsForTest(
51
+ defaults: unknown,
52
+ ): WebhookRateLimitDefaults {
53
+ const resolved = defaults as Partial<WebhookRateLimitDefaults> | null | undefined;
54
+ return {
55
+ windowMs: coercePositiveInt(
56
+ resolved?.windowMs,
57
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs,
58
+ ),
59
+ maxRequests: coercePositiveInt(
60
+ resolved?.maxRequests,
61
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests,
62
+ ),
63
+ maxTrackedKeys: coercePositiveInt(
64
+ resolved?.maxTrackedKeys,
65
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys,
66
+ ),
67
+ };
68
+ }
69
+
70
+ export function resolveFeishuWebhookAnomalyDefaultsForTest(
71
+ defaults: unknown,
72
+ ): WebhookAnomalyDefaults {
73
+ const resolved = defaults as Partial<WebhookAnomalyDefaults> | null | undefined;
74
+ return {
75
+ maxTrackedKeys: coercePositiveInt(
76
+ resolved?.maxTrackedKeys,
77
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys,
78
+ ),
79
+ ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs),
80
+ logEvery: coercePositiveInt(
81
+ resolved?.logEvery,
82
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery,
83
+ ),
84
+ };
85
+ }
86
+
87
+ const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaultsForTest(
88
+ WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
89
+ );
90
+ const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaultsForTest(
91
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
92
+ );
93
+
18
94
  export const feishuWebhookRateLimiter = createFixedWindowRateLimiter({
19
- windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
20
- maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
21
- maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
95
+ windowMs: feishuWebhookRateLimitDefaults.windowMs,
96
+ maxRequests: feishuWebhookRateLimitDefaults.maxRequests,
97
+ maxTrackedKeys: feishuWebhookRateLimitDefaults.maxTrackedKeys,
22
98
  });
23
99
 
24
100
  const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
25
- maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
26
- ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
27
- logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
101
+ maxTrackedKeys: feishuWebhookAnomalyDefaults.maxTrackedKeys,
102
+ ttlMs: feishuWebhookAnomalyDefaults.ttlMs,
103
+ logEvery: feishuWebhookAnomalyDefaults.logEvery,
28
104
  });
29
105
 
30
106
  export function clearFeishuWebhookRateLimitStateForTest(): void {
@@ -0,0 +1,12 @@
1
+ import { vi } from "vitest";
2
+
3
+ export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.fn();
4
+
5
+ vi.mock("./probe.js", () => ({
6
+ probeFeishu: probeFeishuMock,
7
+ }));
8
+
9
+ vi.mock("./client.js", () => ({
10
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
11
+ createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
12
+ }));
@@ -5,15 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const probeFeishuMock = vi.hoisted(() => vi.fn());
7
7
 
8
- vi.mock("@larksuiteoapi/node-sdk", () => ({
9
- adaptDefault: vi.fn(
10
- () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
11
- res.statusCode = 200;
12
- res.end("ok");
13
- },
14
- ),
15
- }));
16
-
17
8
  vi.mock("./probe.js", () => ({
18
9
  probeFeishu: probeFeishuMock,
19
10
  }));
@@ -23,6 +14,32 @@ vi.mock("./client.js", () => ({
23
14
  createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
24
15
  }));
25
16
 
17
+ vi.mock("./runtime.js", () => ({
18
+ getFeishuRuntime: () => ({
19
+ channel: {
20
+ debounce: {
21
+ resolveInboundDebounceMs: () => 0,
22
+ createInboundDebouncer: () => ({
23
+ enqueue: async () => {},
24
+ flushKey: async () => {},
25
+ }),
26
+ },
27
+ text: {
28
+ hasControlCommand: () => false,
29
+ },
30
+ },
31
+ }),
32
+ }));
33
+
34
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
35
+ adaptDefault: vi.fn(
36
+ () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
37
+ res.statusCode = 200;
38
+ res.end("ok");
39
+ },
40
+ ),
41
+ }));
42
+
26
43
  import {
27
44
  clearFeishuWebhookRateLimitStateForTest,
28
45
  getFeishuWebhookRateLimitStateSizeForTest,
@@ -0,0 +1,25 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it } from "vitest";
3
+ import { feishuOnboardingAdapter } from "./onboarding.js";
4
+
5
+ describe("feishu onboarding status", () => {
6
+ it("treats SecretRef appSecret as configured when appId is present", async () => {
7
+ const status = await feishuOnboardingAdapter.getStatus({
8
+ cfg: {
9
+ channels: {
10
+ feishu: {
11
+ appId: "cli_a123456",
12
+ appSecret: {
13
+ source: "env",
14
+ provider: "default",
15
+ id: "FEISHU_APP_SECRET",
16
+ },
17
+ },
18
+ },
19
+ } as OpenClawConfig,
20
+ accountOverrides: {},
21
+ });
22
+
23
+ expect(status.configured).toBe(true);
24
+ });
25
+ });
package/src/onboarding.ts CHANGED
@@ -3,9 +3,16 @@ import type {
3
3
  ChannelOnboardingDmPolicy,
4
4
  ClawdbotConfig,
5
5
  DmPolicy,
6
+ SecretInput,
6
7
  WizardPrompter,
7
8
  } from "openclaw/plugin-sdk";
8
- import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
9
+ import {
10
+ addWildcardAllowFrom,
11
+ DEFAULT_ACCOUNT_ID,
12
+ formatDocsLink,
13
+ hasConfiguredSecretInput,
14
+ promptSingleChannelSecretInput,
15
+ } from "openclaw/plugin-sdk";
9
16
  import { resolveFeishuCredentials } from "./accounts.js";
10
17
  import { probeFeishu } from "./probe.js";
11
18
  import type { FeishuConfig } from "./types.js";
@@ -104,23 +111,18 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
104
111
  );
105
112
  }
106
113
 
107
- async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
108
- appId: string;
109
- appSecret: string;
110
- }> {
114
+ async function promptFeishuAppId(params: {
115
+ prompter: WizardPrompter;
116
+ initialValue?: string;
117
+ }): Promise<string> {
111
118
  const appId = String(
112
- await prompter.text({
119
+ await params.prompter.text({
113
120
  message: "Enter Feishu App ID",
121
+ initialValue: params.initialValue,
114
122
  validate: (value) => (value?.trim() ? undefined : "Required"),
115
123
  }),
116
124
  ).trim();
117
- const appSecret = String(
118
- await prompter.text({
119
- message: "Enter Feishu App Secret",
120
- validate: (value) => (value?.trim() ? undefined : "Required"),
121
- }),
122
- ).trim();
123
- return { appId, appSecret };
125
+ return appId;
124
126
  }
125
127
 
126
128
  function setFeishuGroupPolicy(
@@ -167,13 +169,30 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
167
169
  channel,
168
170
  getStatus: async ({ cfg }) => {
169
171
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
170
- const configured = Boolean(resolveFeishuCredentials(feishuCfg));
172
+ const topLevelConfigured = Boolean(
173
+ feishuCfg?.appId?.trim() && hasConfiguredSecretInput(feishuCfg?.appSecret),
174
+ );
175
+ const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
176
+ if (!account || typeof account !== "object") {
177
+ return false;
178
+ }
179
+ const accountAppId =
180
+ typeof account.appId === "string" ? account.appId.trim() : feishuCfg?.appId?.trim();
181
+ const accountSecretConfigured =
182
+ hasConfiguredSecretInput(account.appSecret) ||
183
+ hasConfiguredSecretInput(feishuCfg?.appSecret);
184
+ return Boolean(accountAppId && accountSecretConfigured);
185
+ });
186
+ const configured = topLevelConfigured || accountConfigured;
187
+ const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
188
+ allowUnresolvedSecretRef: true,
189
+ });
171
190
 
172
191
  // Try to probe if configured
173
192
  let probeResult = null;
174
- if (configured && feishuCfg) {
193
+ if (configured && resolvedCredentials) {
175
194
  try {
176
- probeResult = await probeFeishu(feishuCfg);
195
+ probeResult = await probeFeishu(resolvedCredentials);
177
196
  } catch {
178
197
  // Ignore probe errors
179
198
  }
@@ -201,52 +220,53 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
201
220
 
202
221
  configure: async ({ cfg, prompter }) => {
203
222
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
204
- const resolved = resolveFeishuCredentials(feishuCfg);
205
- const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
223
+ const resolved = resolveFeishuCredentials(feishuCfg, {
224
+ allowUnresolvedSecretRef: true,
225
+ });
226
+ const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
227
+ const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && hasConfigSecret);
206
228
  const canUseEnv = Boolean(
207
229
  !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
208
230
  );
209
231
 
210
232
  let next = cfg;
211
233
  let appId: string | null = null;
212
- let appSecret: string | null = null;
234
+ let appSecret: SecretInput | null = null;
235
+ let appSecretProbeValue: string | null = null;
213
236
 
214
237
  if (!resolved) {
215
238
  await noteFeishuCredentialHelp(prompter);
216
239
  }
217
240
 
218
- if (canUseEnv) {
219
- const keepEnv = await prompter.confirm({
220
- message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
221
- initialValue: true,
222
- });
223
- if (keepEnv) {
224
- next = {
225
- ...next,
226
- channels: {
227
- ...next.channels,
228
- feishu: { ...next.channels?.feishu, enabled: true },
229
- },
230
- };
231
- } else {
232
- const entered = await promptFeishuCredentials(prompter);
233
- appId = entered.appId;
234
- appSecret = entered.appSecret;
235
- }
236
- } else if (hasConfigCreds) {
237
- const keep = await prompter.confirm({
238
- message: "Feishu credentials already configured. Keep them?",
239
- initialValue: true,
241
+ const appSecretResult = await promptSingleChannelSecretInput({
242
+ cfg: next,
243
+ prompter,
244
+ providerHint: "feishu",
245
+ credentialLabel: "App Secret",
246
+ accountConfigured: Boolean(resolved),
247
+ canUseEnv,
248
+ hasConfigToken: hasConfigSecret,
249
+ envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
250
+ keepPrompt: "Feishu App Secret already configured. Keep it?",
251
+ inputPrompt: "Enter Feishu App Secret",
252
+ preferredEnvVar: "FEISHU_APP_SECRET",
253
+ });
254
+
255
+ if (appSecretResult.action === "use-env") {
256
+ next = {
257
+ ...next,
258
+ channels: {
259
+ ...next.channels,
260
+ feishu: { ...next.channels?.feishu, enabled: true },
261
+ },
262
+ };
263
+ } else if (appSecretResult.action === "set") {
264
+ appSecret = appSecretResult.value;
265
+ appSecretProbeValue = appSecretResult.resolvedValue;
266
+ appId = await promptFeishuAppId({
267
+ prompter,
268
+ initialValue: feishuCfg?.appId?.trim() || process.env.FEISHU_APP_ID?.trim(),
240
269
  });
241
- if (!keep) {
242
- const entered = await promptFeishuCredentials(prompter);
243
- appId = entered.appId;
244
- appSecret = entered.appSecret;
245
- }
246
- } else {
247
- const entered = await promptFeishuCredentials(prompter);
248
- appId = entered.appId;
249
- appSecret = entered.appSecret;
250
270
  }
251
271
 
252
272
  if (appId && appSecret) {
@@ -264,9 +284,12 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
264
284
  };
265
285
 
266
286
  // Test connection
267
- const testCfg = next.channels?.feishu as FeishuConfig;
268
287
  try {
269
- const probe = await probeFeishu(testCfg);
288
+ const probe = await probeFeishu({
289
+ appId,
290
+ appSecret: appSecretProbeValue ?? undefined,
291
+ domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain,
292
+ });
270
293
  if (probe.ok) {
271
294
  await prompter.note(
272
295
  `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
@@ -283,6 +306,75 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
283
306
  }
284
307
  }
285
308
 
309
+ const currentMode =
310
+ (next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket";
311
+ const connectionMode = (await prompter.select({
312
+ message: "Feishu connection mode",
313
+ options: [
314
+ { value: "websocket", label: "WebSocket (default)" },
315
+ { value: "webhook", label: "Webhook" },
316
+ ],
317
+ initialValue: currentMode,
318
+ })) as "websocket" | "webhook";
319
+ next = {
320
+ ...next,
321
+ channels: {
322
+ ...next.channels,
323
+ feishu: {
324
+ ...next.channels?.feishu,
325
+ connectionMode,
326
+ },
327
+ },
328
+ };
329
+
330
+ if (connectionMode === "webhook") {
331
+ const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
332
+ ?.verificationToken;
333
+ const verificationTokenResult = await promptSingleChannelSecretInput({
334
+ cfg: next,
335
+ prompter,
336
+ providerHint: "feishu-webhook",
337
+ credentialLabel: "verification token",
338
+ accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
339
+ canUseEnv: false,
340
+ hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
341
+ envPrompt: "",
342
+ keepPrompt: "Feishu verification token already configured. Keep it?",
343
+ inputPrompt: "Enter Feishu verification token",
344
+ preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
345
+ });
346
+ if (verificationTokenResult.action === "set") {
347
+ next = {
348
+ ...next,
349
+ channels: {
350
+ ...next.channels,
351
+ feishu: {
352
+ ...next.channels?.feishu,
353
+ verificationToken: verificationTokenResult.value,
354
+ },
355
+ },
356
+ };
357
+ }
358
+ const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
359
+ const webhookPath = String(
360
+ await prompter.text({
361
+ message: "Feishu webhook path",
362
+ initialValue: currentWebhookPath ?? "/feishu/events",
363
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
364
+ }),
365
+ ).trim();
366
+ next = {
367
+ ...next,
368
+ channels: {
369
+ ...next.channels,
370
+ feishu: {
371
+ ...next.channels?.feishu,
372
+ webhookPath,
373
+ },
374
+ },
375
+ };
376
+ }
377
+
286
378
  // Domain selection
287
379
  const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
288
380
  const domain = await prompter.select({
package/src/probe.test.ts CHANGED
@@ -59,7 +59,7 @@ describe("probeFeishu", () => {
59
59
  expect(requestFn).toHaveBeenCalledTimes(1);
60
60
  });
61
61
 
62
- it("uses explicit timeout for bot info request", async () => {
62
+ it("passes the probe timeout to the Feishu request", async () => {
63
63
  const requestFn = setupClient({
64
64
  code: 0,
65
65
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
@@ -105,7 +105,6 @@ describe("probeFeishu", () => {
105
105
  expect(result).toMatchObject({ ok: false, error: "probe aborted" });
106
106
  expect(createFeishuClientMock).not.toHaveBeenCalled();
107
107
  });
108
-
109
108
  it("returns cached result on subsequent calls within TTL", async () => {
110
109
  const requestFn = setupClient({
111
110
  code: 0,
@@ -133,7 +132,7 @@ describe("probeFeishu", () => {
133
132
  await probeFeishu(creds);
134
133
  expect(requestFn).toHaveBeenCalledTimes(1);
135
134
 
136
- // Advance time past the 10-minute TTL
135
+ // Advance time past the success TTL
137
136
  vi.advanceTimersByTime(10 * 60 * 1000 + 1);
138
137
 
139
138
  await probeFeishu(creds);
@@ -143,29 +142,48 @@ describe("probeFeishu", () => {
143
142
  }
144
143
  });
145
144
 
146
- it("does not cache failed probe results (API error)", async () => {
147
- const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
148
- createFeishuClientMock.mockReturnValue({ request: requestFn });
145
+ it("caches failed probe results (API error) for the error TTL", async () => {
146
+ vi.useFakeTimers();
147
+ try {
148
+ const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
149
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
149
150
 
150
- const creds = { appId: "cli_123", appSecret: "secret" };
151
- const first = await probeFeishu(creds);
152
- expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
151
+ const creds = { appId: "cli_123", appSecret: "secret" };
152
+ const first = await probeFeishu(creds);
153
+ const second = await probeFeishu(creds);
154
+ expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
155
+ expect(second).toMatchObject({ ok: false, error: "API error: token expired" });
156
+ expect(requestFn).toHaveBeenCalledTimes(1);
153
157
 
154
- // Second call should make a fresh request since failures are not cached
155
- await probeFeishu(creds);
156
- expect(requestFn).toHaveBeenCalledTimes(2);
158
+ vi.advanceTimersByTime(60 * 1000 + 1);
159
+
160
+ await probeFeishu(creds);
161
+ expect(requestFn).toHaveBeenCalledTimes(2);
162
+ } finally {
163
+ vi.useRealTimers();
164
+ }
157
165
  });
158
166
 
159
- it("does not cache results when request throws", async () => {
160
- const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
161
- createFeishuClientMock.mockReturnValue({ request: requestFn });
167
+ it("caches thrown request errors for the error TTL", async () => {
168
+ vi.useFakeTimers();
169
+ try {
170
+ const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
171
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
162
172
 
163
- const creds = { appId: "cli_123", appSecret: "secret" };
164
- const first = await probeFeishu(creds);
165
- expect(first).toMatchObject({ ok: false, error: "network error" });
173
+ const creds = { appId: "cli_123", appSecret: "secret" };
174
+ const first = await probeFeishu(creds);
175
+ const second = await probeFeishu(creds);
176
+ expect(first).toMatchObject({ ok: false, error: "network error" });
177
+ expect(second).toMatchObject({ ok: false, error: "network error" });
178
+ expect(requestFn).toHaveBeenCalledTimes(1);
166
179
 
167
- await probeFeishu(creds);
168
- expect(requestFn).toHaveBeenCalledTimes(2);
180
+ vi.advanceTimersByTime(60 * 1000 + 1);
181
+
182
+ await probeFeishu(creds);
183
+ expect(requestFn).toHaveBeenCalledTimes(2);
184
+ } finally {
185
+ vi.useRealTimers();
186
+ }
169
187
  });
170
188
 
171
189
  it("caches per account independently", async () => {