11// Imessage tests cover approval reaction poller plugin behavior.
22import { beforeEach , describe , expect , it , vi } from "vitest" ;
3- import { pollPendingIMessageApprovalReactions } from "./approval-reaction-poller.js" ;
3+ import {
4+ clearIMessageApprovalReactionPollerStateForTest ,
5+ pollPendingIMessageApprovalReactions ,
6+ } from "./approval-reaction-poller.js" ;
47import {
58 clearIMessageApprovalReactionTargetsForTest ,
69 registerIMessageApprovalReactionTarget ,
@@ -24,6 +27,7 @@ function createClient(request: ReturnType<typeof vi.fn>): IMessageRpcClient {
2427describe ( "iMessage approval reaction poller" , ( ) => {
2528 beforeEach ( ( ) => {
2629 clearIMessageApprovalReactionTargetsForTest ( ) ;
30+ clearIMessageApprovalReactionPollerStateForTest ( ) ;
2731 resolverMocks . resolveIMessageApproval . mockReset ( ) ;
2832 resolverMocks . resolveIMessageApproval . mockResolvedValue ( undefined ) ;
2933 resolverMocks . isApprovalNotFoundError . mockReset ( ) ;
@@ -116,6 +120,221 @@ describe("iMessage approval reaction poller", () => {
116120 } ) ;
117121 } ) ;
118122
123+ it ( "bounds no-target recent-chat discovery to one pass per account" , async ( ) => {
124+ const request = vi . fn ( async ( method : string ) => {
125+ if ( method === "chats.list" ) {
126+ return { chats : [ { id : 42 } ] } ;
127+ }
128+ if ( method === "messages.history" ) {
129+ return { messages : [ ] } ;
130+ }
131+ throw new Error ( `unexpected method ${ method } ` ) ;
132+ } ) ;
133+
134+ const pollParams = {
135+ client : createClient ( request ) ,
136+ cfg : { channels : { imessage : { allowFrom : [ "+15551230000" ] } } } ,
137+ accountId : "default" ,
138+ allowRecentChatDiscovery : true ,
139+ } ;
140+
141+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
142+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
143+
144+ expect ( request ) . toHaveBeenCalledTimes ( 2 ) ;
145+ expect ( request ) . toHaveBeenCalledWith ( "chats.list" , { limit : 50 } , { timeoutMs : 10_000 } ) ;
146+ expect ( request ) . toHaveBeenCalledWith (
147+ "messages.history" ,
148+ { chat_id : 42 , limit : 30 } ,
149+ { timeoutMs : 10_000 } ,
150+ ) ;
151+ } ) ;
152+
153+ it ( "bounds no-target discovery after resolving an observed reaction" , async ( ) => {
154+ const request = vi . fn ( async ( method : string , payload ?: { chat_id ?: number } ) => {
155+ if ( method === "chats.list" ) {
156+ return { chats : [ { id : 42 } , { id : 99 } ] } ;
157+ }
158+ if ( method === "messages.history" && payload ?. chat_id === 42 ) {
159+ return {
160+ messages : [
161+ {
162+ guid : "msg-1" ,
163+ chat_id : 42 ,
164+ chat_guid : "SMS;-;+15551230000" ,
165+ chat_identifier : "+15551230000" ,
166+ is_from_me : true ,
167+ sender : "+15551230000" ,
168+ text : [
169+ "Exec approval required" ,
170+ "ID: exec-1" ,
171+ "" ,
172+ "Reply with: /approve exec-1 allow-once|deny" ,
173+ ] . join ( "\n" ) ,
174+ reactions : [
175+ {
176+ id : 7 ,
177+ sender : "+15551230000" ,
178+ is_from_me : true ,
179+ type : "like" ,
180+ emoji : "👍" ,
181+ created_at : "2026-05-27T21:00:00.000Z" ,
182+ } ,
183+ ] ,
184+ } ,
185+ ] ,
186+ } ;
187+ }
188+ if ( method === "messages.history" && payload ?. chat_id === 99 ) {
189+ return { messages : [ ] } ;
190+ }
191+ throw new Error ( `unexpected request ${ method } ${ JSON . stringify ( payload ) } ` ) ;
192+ } ) ;
193+
194+ const pollParams = {
195+ client : createClient ( request ) ,
196+ cfg : { channels : { imessage : { allowFrom : [ "+15551230000" ] } } } ,
197+ accountId : "default" ,
198+ allowRecentChatDiscovery : true ,
199+ } ;
200+
201+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
202+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
203+
204+ expect ( resolverMocks . resolveIMessageApproval ) . toHaveBeenCalledTimes ( 1 ) ;
205+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "chats.list" ) ) . toHaveLength ( 1 ) ;
206+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "messages.history" ) ) . toHaveLength ( 2 ) ;
207+ expect ( request ) . toHaveBeenCalledWith (
208+ "messages.history" ,
209+ { chat_id : 99 , limit : 30 } ,
210+ { timeoutMs : 10_000 } ,
211+ ) ;
212+ } ) ;
213+
214+ it ( "retries no-target discovery after resolver failures expire observed targets" , async ( ) => {
215+ resolverMocks . resolveIMessageApproval . mockRejectedValue ( new Error ( "gateway down" ) ) ;
216+ const request = vi . fn ( async ( method : string ) => {
217+ if ( method === "chats.list" ) {
218+ return { chats : [ { id : 42 } ] } ;
219+ }
220+ if ( method === "messages.history" ) {
221+ return {
222+ messages : [
223+ {
224+ guid : "msg-1" ,
225+ chat_id : 42 ,
226+ chat_guid : "SMS;-;+15551230000" ,
227+ chat_identifier : "+15551230000" ,
228+ is_from_me : true ,
229+ sender : "+15551230000" ,
230+ text : [
231+ "Exec approval required" ,
232+ "ID: exec-1" ,
233+ "" ,
234+ "Reply with: /approve exec-1 allow-once|deny" ,
235+ ] . join ( "\n" ) ,
236+ reactions : [
237+ {
238+ id : 7 ,
239+ sender : "+15551230000" ,
240+ is_from_me : true ,
241+ type : "like" ,
242+ emoji : "👍" ,
243+ created_at : "2026-05-27T21:00:00.000Z" ,
244+ } ,
245+ ] ,
246+ } ,
247+ ] ,
248+ } ;
249+ }
250+ throw new Error ( `unexpected method ${ method } ` ) ;
251+ } ) ;
252+ const dateNow = vi . spyOn ( Date , "now" ) . mockReturnValue ( 1_800_000_000_000 ) ;
253+
254+ try {
255+ const pollParams = {
256+ client : createClient ( request ) ,
257+ cfg : { channels : { imessage : { allowFrom : [ "+15551230000" ] } } } ,
258+ accountId : "default" ,
259+ allowRecentChatDiscovery : true ,
260+ } ;
261+
262+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
263+ dateNow . mockReturnValue ( 1_800_000_301_000 ) ;
264+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
265+ } finally {
266+ dateNow . mockRestore ( ) ;
267+ }
268+
269+ expect ( resolverMocks . resolveIMessageApproval ) . toHaveBeenCalledTimes ( 2 ) ;
270+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "chats.list" ) ) . toHaveLength ( 2 ) ;
271+ } ) ;
272+
273+ it ( "retries no-target recent-chat discovery after the first chat list fails" , async ( ) => {
274+ const request = vi . fn ( async ( method : string ) => {
275+ if ( method === "chats.list" ) {
276+ const chatListCalls = request . mock . calls . filter (
277+ ( [ calledMethod ] ) => calledMethod === "chats.list" ,
278+ ) ;
279+ if ( chatListCalls . length === 1 ) {
280+ throw new Error ( "temporary imsg failure" ) ;
281+ }
282+ return { chats : [ { id : 42 } ] } ;
283+ }
284+ if ( method === "messages.history" ) {
285+ return { messages : [ ] } ;
286+ }
287+ throw new Error ( `unexpected method ${ method } ` ) ;
288+ } ) ;
289+
290+ const pollParams = {
291+ client : createClient ( request ) ,
292+ cfg : { channels : { imessage : { allowFrom : [ "+15551230000" ] } } } ,
293+ accountId : "default" ,
294+ allowRecentChatDiscovery : true ,
295+ } ;
296+
297+ await expect ( pollPendingIMessageApprovalReactions ( pollParams ) ) . rejects . toThrow (
298+ "temporary imsg failure" ,
299+ ) ;
300+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
301+
302+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "chats.list" ) ) . toHaveLength ( 2 ) ;
303+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "messages.history" ) ) . toHaveLength ( 1 ) ;
304+ } ) ;
305+
306+ it ( "retries no-target recent-chat discovery after the first history fetch fails" , async ( ) => {
307+ const request = vi . fn ( async ( method : string ) => {
308+ if ( method === "chats.list" ) {
309+ return { chats : [ { id : 42 } ] } ;
310+ }
311+ if ( method === "messages.history" ) {
312+ const historyCalls = request . mock . calls . filter (
313+ ( [ calledMethod ] ) => calledMethod === "messages.history" ,
314+ ) ;
315+ if ( historyCalls . length === 1 ) {
316+ throw new Error ( "temporary history failure" ) ;
317+ }
318+ return { messages : [ ] } ;
319+ }
320+ throw new Error ( `unexpected method ${ method } ` ) ;
321+ } ) ;
322+
323+ const pollParams = {
324+ client : createClient ( request ) ,
325+ cfg : { channels : { imessage : { allowFrom : [ "+15551230000" ] } } } ,
326+ accountId : "default" ,
327+ allowRecentChatDiscovery : true ,
328+ } ;
329+
330+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
331+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
332+ await pollPendingIMessageApprovalReactions ( pollParams ) ;
333+
334+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "chats.list" ) ) . toHaveLength ( 2 ) ;
335+ expect ( request . mock . calls . filter ( ( [ method ] ) => method === "messages.history" ) ) . toHaveLength ( 2 ) ;
336+ } ) ;
337+
119338 it ( "does not bind observed approval prompts when the process clock is invalid" , async ( ) => {
120339 const request = vi . fn ( async ( method : string ) => {
121340 if ( method === "chats.list" ) {
0 commit comments