11// Googlechat tests cover targets plugin behavior.
22import { afterAll , afterEach , describe , expect , it , vi } from "vitest" ;
33import type { ResolvedGoogleChatAccount } from "./accounts.js" ;
4- import { downloadGoogleChatMedia , sendGoogleChatMessage , updateGoogleChatMessage } from "./api.js" ;
4+ import {
5+ downloadGoogleChatMedia ,
6+ sendGoogleChatMessage ,
7+ updateGoogleChatMessage ,
8+ uploadGoogleChatAttachment ,
9+ } from "./api.js" ;
510import {
611 clearGoogleChatApprovalCardBindingsForTest ,
712 registerGoogleChatManualApprovalFollowupSuppression ,
@@ -20,10 +25,12 @@ const mocks = vi.hoisted(() => ({
2025 buildHostnameAllowlistPolicyFromSuffixAllowlist : vi . fn ( ( hosts : string [ ] ) => ( {
2126 hostnameAllowlist : hosts ,
2227 } ) ) ,
23- fetchWithSsrFGuard : vi . fn ( async ( params : { url : string ; init ?: RequestInit } ) => ( {
24- response : await fetch ( params . url , params . init ) ,
25- release : async ( ) => { } ,
26- } ) ) ,
28+ fetchWithSsrFGuard : vi . fn (
29+ async ( params : { url : string ; init ?: RequestInit ; timeoutMs ?: number } ) => ( {
30+ response : await fetch ( params . url , params . init ) ,
31+ release : async ( ) => { } ,
32+ } ) ,
33+ ) ,
2734 googleAuthCtor : vi . fn ( ) ,
2835 gaxiosCtor : vi . fn ( ) ,
2936 getAccessToken : vi . fn ( ) . mockResolvedValue ( { token : "access-token" } ) ,
@@ -115,6 +122,15 @@ function stubSuccessfulSend(name: string, threadName?: string) {
115122 return fetchMock ;
116123}
117124
125+ function createStalledResponse ( status = 200 ) : Response {
126+ return new Response (
127+ new ReadableStream ( {
128+ start ( ) { } ,
129+ } ) ,
130+ { status } ,
131+ ) ;
132+ }
133+
118134async function expectDownloadToRejectForResponse (
119135 response : Response ,
120136 expected : string | RegExp = / m a x b y t e s / i,
@@ -133,6 +149,14 @@ function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0
133149 return call [ argIndex ] ;
134150}
135151
152+ function lastGuardedFetchOptions ( ) : { timeoutMs ?: number } {
153+ const call = mocks . fetchWithSsrFGuard . mock . calls . at ( - 1 ) ;
154+ if ( ! call ) {
155+ throw new Error ( "Expected guarded fetch call" ) ;
156+ }
157+ return call [ 0 ] as { timeoutMs ?: number } ;
158+ }
159+
136160describe ( "normalizeGoogleChatTarget" , ( ) => {
137161 it ( "normalizes provider prefixes" , ( ) => {
138162 expect ( normalizeGoogleChatTarget ( "googlechat:users/123" ) ) . toBe ( "users/123" ) ;
@@ -265,6 +289,7 @@ describe("downloadGoogleChatMedia", () => {
265289 authTesting . resetGoogleChatAuthForTests ( ) ;
266290 mocks . fetchWithSsrFGuard . mockClear ( ) ;
267291 vi . unstubAllGlobals ( ) ;
292+ vi . useRealTimers ( ) ;
268293 } ) ;
269294
270295 it ( "rejects when content-length exceeds max bytes" , async ( ) => {
@@ -279,6 +304,7 @@ describe("downloadGoogleChatMedia", () => {
279304 headers : { "content-length" : "50" , "content-type" : "application/octet-stream" } ,
280305 } ) ;
281306 await expectDownloadToRejectForResponse ( response ) ;
307+ expect ( lastGuardedFetchOptions ( ) . timeoutMs ) . toBe ( 30_001 ) ;
282308 } ) ;
283309
284310 it ( "rejects malformed content-length before reading media" , async ( ) => {
@@ -315,6 +341,73 @@ describe("downloadGoogleChatMedia", () => {
315341 } ) ;
316342 await expectDownloadToRejectForResponse ( response ) ;
317343 } ) ;
344+
345+ it ( "cancels a media body that stops producing chunks" , async ( ) => {
346+ vi . useFakeTimers ( ) ;
347+ vi . stubGlobal ( "fetch" , vi . fn ( ) . mockResolvedValue ( createStalledResponse ( ) ) ) ;
348+
349+ const result = expect (
350+ downloadGoogleChatMedia ( { account, resourceName : "media/123" , maxBytes : 10 } ) ,
351+ ) . rejects . toThrow ( "Media download stalled: no data received for 30000ms" ) ;
352+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
353+ await result ;
354+ } ) ;
355+
356+ it ( "cancels a stalled media error body" , async ( ) => {
357+ vi . useFakeTimers ( ) ;
358+ vi . stubGlobal ( "fetch" , vi . fn ( ) . mockResolvedValue ( createStalledResponse ( 500 ) ) ) ;
359+
360+ const result = expect (
361+ downloadGoogleChatMedia ( { account, resourceName : "media/123" , maxBytes : 10 } ) ,
362+ ) . rejects . toThrow ( "Google Chat API error response stalled after 30000ms" ) ;
363+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
364+ await result ;
365+ } ) ;
366+ } ) ;
367+
368+ describe ( "uploadGoogleChatAttachment" , ( ) => {
369+ afterEach ( ( ) => {
370+ authTesting . resetGoogleChatAuthForTests ( ) ;
371+ mocks . fetchWithSsrFGuard . mockClear ( ) ;
372+ vi . unstubAllGlobals ( ) ;
373+ vi . useRealTimers ( ) ;
374+ } ) ;
375+
376+ it ( "derives a bounded transfer deadline from the payload size" , async ( ) => {
377+ vi . stubGlobal (
378+ "fetch" ,
379+ vi . fn ( ) . mockResolvedValue (
380+ new Response ( JSON . stringify ( { attachmentDataRef : { attachmentUploadToken : "token" } } ) , {
381+ status : 200 ,
382+ } ) ,
383+ ) ,
384+ ) ;
385+
386+ await uploadGoogleChatAttachment ( {
387+ account,
388+ space : "spaces/AAA" ,
389+ filename : "recording.wav" ,
390+ buffer : Buffer . alloc ( 1024 * 1024 ) ,
391+ } ) ;
392+
393+ expect ( lastGuardedFetchOptions ( ) . timeoutMs ) . toBeGreaterThan ( 34_000 ) ;
394+ } ) ;
395+
396+ it ( "cancels a stalled upload response body" , async ( ) => {
397+ vi . useFakeTimers ( ) ;
398+ vi . stubGlobal ( "fetch" , vi . fn ( ) . mockResolvedValue ( createStalledResponse ( ) ) ) ;
399+
400+ const result = expect (
401+ uploadGoogleChatAttachment ( {
402+ account,
403+ space : "spaces/AAA" ,
404+ filename : "recording.wav" ,
405+ buffer : Buffer . alloc ( 1024 ) ,
406+ } ) ,
407+ ) . rejects . toThrow ( "Google Chat upload failed: response body stalled after 30000ms" ) ;
408+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
409+ await result ;
410+ } ) ;
318411} ) ;
319412
320413describe ( "sendGoogleChatMessage" , ( ) => {
@@ -350,6 +443,7 @@ describe("sendGoogleChatMessage", () => {
350443 messageName : "spaces/AAA/messages/123" ,
351444 threadName : "spaces/AAA/threads/xyz" ,
352445 } ) ;
446+ expect ( lastGuardedFetchOptions ( ) . timeoutMs ) . toBe ( 30_000 ) ;
353447 } ) ;
354448
355449 it ( "does not set messageReplyOption for non-thread sends" , async ( ) => {
0 commit comments