1- import { describe , it , expect , vi } from "vitest" ;
2- import { handleFeishuCardAction , type FeishuCardActionEvent } from "./card-action.js" ;
1+ import { describe , it , expect , vi , beforeEach } from "vitest" ;
2+ import {
3+ handleFeishuCardAction ,
4+ resetProcessedFeishuCardActionTokensForTests ,
5+ type FeishuCardActionEvent ,
6+ } from "./card-action.js" ;
7+ import { createFeishuCardInteractionEnvelope } from "./card-interaction.js" ;
8+ import {
9+ FEISHU_APPROVAL_CANCEL_ACTION ,
10+ FEISHU_APPROVAL_CONFIRM_ACTION ,
11+ FEISHU_APPROVAL_REQUEST_ACTION ,
12+ } from "./card-ux-approval.js" ;
313
414// Mock resolveFeishuAccount
515vi . mock ( "./accounts.js" , ( ) => ( {
@@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({
1121 handleFeishuMessage : vi . fn ( ) ,
1222} ) ) ;
1323
24+ const sendCardFeishuMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
25+ const sendMessageFeishuMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
26+
27+ vi . mock ( "./send.js" , ( ) => ( {
28+ sendCardFeishu : sendCardFeishuMock ,
29+ sendMessageFeishu : sendMessageFeishuMock ,
30+ } ) ) ;
31+
1432import { handleFeishuMessage } from "./bot.js" ;
1533
1634describe ( "Feishu Card Action Handler" , ( ) => {
1735 const cfg = { } as any ; // Minimal mock
1836 const runtime = { log : vi . fn ( ) , error : vi . fn ( ) } as any ;
1937
38+ beforeEach ( ( ) => {
39+ vi . clearAllMocks ( ) ;
40+ resetProcessedFeishuCardActionTokensForTests ( ) ;
41+ } ) ;
42+
2043 it ( "handles card action with text payload" , async ( ) => {
2144 const event : FeishuCardActionEvent = {
2245 operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
@@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => {
6083 } ) ,
6184 ) ;
6285 } ) ;
86+
87+ it ( "routes quick command actions with operator and conversation context" , async ( ) => {
88+ const event : FeishuCardActionEvent = {
89+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
90+ token : "tok3" ,
91+ action : {
92+ value : createFeishuCardInteractionEnvelope ( {
93+ k : "quick" ,
94+ a : "feishu.quick_actions.help" ,
95+ q : "/help" ,
96+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
97+ } ) ,
98+ tag : "button" ,
99+ } ,
100+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
101+ } ;
102+
103+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
104+
105+ expect ( handleFeishuMessage ) . toHaveBeenCalledWith (
106+ expect . objectContaining ( {
107+ event : expect . objectContaining ( {
108+ sender : expect . objectContaining ( {
109+ sender_id : expect . objectContaining ( {
110+ open_id : "u123" ,
111+ user_id : "uid1" ,
112+ union_id : "un1" ,
113+ } ) ,
114+ } ) ,
115+ message : expect . objectContaining ( {
116+ chat_id : "chat1" ,
117+ content : '{"text":"/help"}' ,
118+ } ) ,
119+ } ) ,
120+ } ) ,
121+ ) ;
122+ } ) ;
123+
124+ it ( "opens an approval card for metadata actions" , async ( ) => {
125+ const event : FeishuCardActionEvent = {
126+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
127+ token : "tok4" ,
128+ action : {
129+ value : createFeishuCardInteractionEnvelope ( {
130+ k : "meta" ,
131+ a : FEISHU_APPROVAL_REQUEST_ACTION ,
132+ m : {
133+ command : "/new" ,
134+ prompt : "Start a fresh session?" ,
135+ } ,
136+ c : {
137+ u : "u123" ,
138+ h : "chat1" ,
139+ t : "group" ,
140+ s : "agent:codex:feishu:chat:chat1" ,
141+ e : Date . now ( ) + 60_000 ,
142+ } ,
143+ } ) ,
144+ tag : "button" ,
145+ } ,
146+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
147+ } ;
148+
149+ await handleFeishuCardAction ( { cfg, event, runtime, accountId : "main" } ) ;
150+
151+ expect ( sendCardFeishuMock ) . toHaveBeenCalledWith (
152+ expect . objectContaining ( {
153+ to : "chat:chat1" ,
154+ accountId : "main" ,
155+ card : expect . objectContaining ( {
156+ header : expect . objectContaining ( {
157+ title : expect . objectContaining ( { content : "Confirm action" } ) ,
158+ } ) ,
159+ body : expect . objectContaining ( {
160+ elements : expect . arrayContaining ( [
161+ expect . objectContaining ( {
162+ tag : "action" ,
163+ actions : expect . arrayContaining ( [
164+ expect . objectContaining ( {
165+ value : expect . objectContaining ( {
166+ c : expect . objectContaining ( {
167+ u : "u123" ,
168+ h : "chat1" ,
169+ t : "group" ,
170+ s : "agent:codex:feishu:chat:chat1" ,
171+ } ) ,
172+ } ) ,
173+ } ) ,
174+ ] ) ,
175+ } ) ,
176+ ] ) ,
177+ } ) ,
178+ } ) ,
179+ } ) ,
180+ ) ;
181+ expect ( handleFeishuMessage ) . not . toHaveBeenCalled ( ) ;
182+ } ) ;
183+
184+ it ( "runs approval confirmation through the normal message path" , async ( ) => {
185+ const event : FeishuCardActionEvent = {
186+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
187+ token : "tok5" ,
188+ action : {
189+ value : createFeishuCardInteractionEnvelope ( {
190+ k : "quick" ,
191+ a : FEISHU_APPROVAL_CONFIRM_ACTION ,
192+ q : "/new" ,
193+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
194+ } ) ,
195+ tag : "button" ,
196+ } ,
197+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
198+ } ;
199+
200+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
201+
202+ expect ( handleFeishuMessage ) . toHaveBeenCalledWith (
203+ expect . objectContaining ( {
204+ event : expect . objectContaining ( {
205+ message : expect . objectContaining ( {
206+ content : '{"text":"/new"}' ,
207+ } ) ,
208+ } ) ,
209+ } ) ,
210+ ) ;
211+ } ) ;
212+
213+ it ( "safely rejects stale structured actions" , async ( ) => {
214+ const event : FeishuCardActionEvent = {
215+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
216+ token : "tok6" ,
217+ action : {
218+ value : createFeishuCardInteractionEnvelope ( {
219+ k : "quick" ,
220+ a : "feishu.quick_actions.help" ,
221+ q : "/help" ,
222+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) - 1 } ,
223+ } ) ,
224+ tag : "button" ,
225+ } ,
226+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
227+ } ;
228+
229+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
230+
231+ expect ( sendMessageFeishuMock ) . toHaveBeenCalledWith (
232+ expect . objectContaining ( {
233+ to : "chat:chat1" ,
234+ text : expect . stringContaining ( "expired" ) ,
235+ } ) ,
236+ ) ;
237+ expect ( handleFeishuMessage ) . not . toHaveBeenCalled ( ) ;
238+ } ) ;
239+
240+ it ( "safely rejects wrong-user structured actions" , async ( ) => {
241+ const event : FeishuCardActionEvent = {
242+ operator : { open_id : "u999" , user_id : "uid1" , union_id : "un1" } ,
243+ token : "tok7" ,
244+ action : {
245+ value : createFeishuCardInteractionEnvelope ( {
246+ k : "quick" ,
247+ a : "feishu.quick_actions.help" ,
248+ q : "/help" ,
249+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
250+ } ) ,
251+ tag : "button" ,
252+ } ,
253+ context : { open_id : "u999" , user_id : "uid1" , chat_id : "chat1" } ,
254+ } ;
255+
256+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
257+
258+ expect ( sendMessageFeishuMock ) . toHaveBeenCalledWith (
259+ expect . objectContaining ( {
260+ text : expect . stringContaining ( "different user" ) ,
261+ } ) ,
262+ ) ;
263+ expect ( handleFeishuMessage ) . not . toHaveBeenCalled ( ) ;
264+ } ) ;
265+
266+ it ( "sends a lightweight cancellation notice" , async ( ) => {
267+ const event : FeishuCardActionEvent = {
268+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
269+ token : "tok8" ,
270+ action : {
271+ value : createFeishuCardInteractionEnvelope ( {
272+ k : "button" ,
273+ a : FEISHU_APPROVAL_CANCEL_ACTION ,
274+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
275+ } ) ,
276+ tag : "button" ,
277+ } ,
278+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
279+ } ;
280+
281+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
282+
283+ expect ( sendMessageFeishuMock ) . toHaveBeenCalledWith (
284+ expect . objectContaining ( {
285+ to : "chat:chat1" ,
286+ text : "Cancelled." ,
287+ } ) ,
288+ ) ;
289+ } ) ;
290+
291+ it ( "preserves p2p callbacks for DM quick actions" , async ( ) => {
292+ const event : FeishuCardActionEvent = {
293+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
294+ token : "tok9" ,
295+ action : {
296+ value : createFeishuCardInteractionEnvelope ( {
297+ k : "quick" ,
298+ a : "feishu.quick_actions.help" ,
299+ q : "/help" ,
300+ c : { u : "u123" , h : "p2p-chat-1" , t : "p2p" , e : Date . now ( ) + 60_000 } ,
301+ } ) ,
302+ tag : "button" ,
303+ } ,
304+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "p2p-chat-1" } ,
305+ } ;
306+
307+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
308+
309+ expect ( handleFeishuMessage ) . toHaveBeenCalledWith (
310+ expect . objectContaining ( {
311+ event : expect . objectContaining ( {
312+ message : expect . objectContaining ( {
313+ chat_id : "p2p-chat-1" ,
314+ chat_type : "p2p" ,
315+ } ) ,
316+ } ) ,
317+ } ) ,
318+ ) ;
319+ } ) ;
320+
321+ it ( "drops duplicate structured callback tokens" , async ( ) => {
322+ const event : FeishuCardActionEvent = {
323+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
324+ token : "tok10" ,
325+ action : {
326+ value : createFeishuCardInteractionEnvelope ( {
327+ k : "quick" ,
328+ a : "feishu.quick_actions.help" ,
329+ q : "/help" ,
330+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
331+ } ) ,
332+ tag : "button" ,
333+ } ,
334+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
335+ } ;
336+
337+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
338+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
339+
340+ expect ( handleFeishuMessage ) . toHaveBeenCalledTimes ( 1 ) ;
341+ } ) ;
342+
343+ it ( "releases a claimed token when dispatch fails so retries can succeed" , async ( ) => {
344+ const event : FeishuCardActionEvent = {
345+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
346+ token : "tok11" ,
347+ action : {
348+ value : createFeishuCardInteractionEnvelope ( {
349+ k : "quick" ,
350+ a : "feishu.quick_actions.help" ,
351+ q : "/help" ,
352+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
353+ } ) ,
354+ tag : "button" ,
355+ } ,
356+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
357+ } ;
358+ vi . mocked ( handleFeishuMessage )
359+ . mockRejectedValueOnce ( new Error ( "transient" ) )
360+ . mockResolvedValueOnce ( undefined as never ) ;
361+
362+ await expect ( handleFeishuCardAction ( { cfg, event, runtime } ) ) . rejects . toThrow ( "transient" ) ;
363+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
364+
365+ expect ( handleFeishuMessage ) . toHaveBeenCalledTimes ( 2 ) ;
366+ } ) ;
367+
368+ it ( "keeps an in-flight token claimed while a slow dispatch is still running" , async ( ) => {
369+ vi . useFakeTimers ( ) ;
370+ const event : FeishuCardActionEvent = {
371+ operator : { open_id : "u123" , user_id : "uid1" , union_id : "un1" } ,
372+ token : "tok12" ,
373+ action : {
374+ value : createFeishuCardInteractionEnvelope ( {
375+ k : "quick" ,
376+ a : "feishu.quick_actions.help" ,
377+ q : "/help" ,
378+ c : { u : "u123" , h : "chat1" , t : "group" , e : Date . now ( ) + 60_000 } ,
379+ } ) ,
380+ tag : "button" ,
381+ } ,
382+ context : { open_id : "u123" , user_id : "uid1" , chat_id : "chat1" } ,
383+ } ;
384+
385+ let resolveDispatch : ( ( ) => void ) | undefined ;
386+ vi . mocked ( handleFeishuMessage ) . mockImplementation (
387+ ( ) =>
388+ new Promise < void > ( ( resolve ) => {
389+ resolveDispatch = resolve ;
390+ } ) as never ,
391+ ) ;
392+
393+ const first = handleFeishuCardAction ( { cfg, event, runtime } ) ;
394+ await vi . advanceTimersByTimeAsync ( 61_000 ) ;
395+ await handleFeishuCardAction ( { cfg, event, runtime } ) ;
396+
397+ expect ( handleFeishuMessage ) . toHaveBeenCalledTimes ( 1 ) ;
398+
399+ resolveDispatch ?.( ) ;
400+ await first ;
401+ vi . useRealTimers ( ) ;
402+ } ) ;
63403} ) ;
0 commit comments