@@ -32,6 +32,8 @@ vi.mock("openclaw/plugin-sdk/media-store", () => ({
3232} ) ) ;
3333
3434let downloadLineMedia : typeof import ( "./download.js" ) . downloadLineMedia ;
35+ let LineMediaDownloadTimeoutError : typeof import ( "./download.js" ) . LineMediaDownloadTimeoutError ;
36+ let LINE_DOWNLOAD_IDLE_TIMEOUT_MS : typeof import ( "./download.js" ) . LINE_DOWNLOAD_IDLE_TIMEOUT_MS ;
3537
3638async function * chunks ( parts : Buffer [ ] ) : AsyncGenerator < Buffer > {
3739 for ( const part of parts ) {
@@ -59,7 +61,8 @@ function detectMockContentType(buffer: Buffer, contentType?: string): string | u
5961
6062describe ( "downloadLineMedia" , ( ) => {
6163 beforeAll ( async ( ) => {
62- ( { downloadLineMedia } = await import ( "./download.js" ) ) ;
64+ ( { downloadLineMedia, LineMediaDownloadTimeoutError, LINE_DOWNLOAD_IDLE_TIMEOUT_MS } =
65+ await import ( "./download.js" ) ) ;
6366 } ) ;
6467
6568 afterAll ( ( ) => {
@@ -161,4 +164,109 @@ describe("downloadLineMedia", () => {
161164
162165 await expect ( downloadLineMedia ( "mid-bad" , "token" ) ) . rejects . toThrow ( / M e d i a e x c e e d s / i) ;
163166 } ) ;
167+
168+ describe ( "chunk-idle timeout" , ( ) => {
169+ function neverYieldingStream ( ) : AsyncIterable < Buffer > {
170+ return {
171+ [ Symbol . asyncIterator ] ( ) {
172+ return {
173+ next ( ) : Promise < IteratorResult < Buffer > > {
174+ return new Promise < IteratorResult < Buffer > > ( ( ) => { } ) ;
175+ } ,
176+ } ;
177+ } ,
178+ } ;
179+ }
180+
181+ function delayedStream ( payload : Buffer , delayMs : number ) : AsyncIterable < Buffer > {
182+ let yielded = false ;
183+ return {
184+ [ Symbol . asyncIterator ] ( ) {
185+ return {
186+ async next ( ) : Promise < IteratorResult < Buffer > > {
187+ if ( yielded ) {
188+ return { value : undefined as unknown as Buffer , done : true } ;
189+ }
190+ await new Promise ( ( r ) => setTimeout ( r , delayMs ) ) ;
191+ yielded = true ;
192+ return { value : payload , done : false } ;
193+ } ,
194+ } ;
195+ } ,
196+ } ;
197+ }
198+
199+ it ( "rejects with LineMediaDownloadTimeoutError when the stream stalls past chunkTimeoutMs" , async ( ) => {
200+ getMessageContentMock . mockResolvedValueOnce ( neverYieldingStream ( ) ) ;
201+ const promise = downloadLineMedia ( "mid-stall" , "token" , 1024 * 1024 , {
202+ chunkTimeoutMs : 50 ,
203+ } ) ;
204+ await expect ( promise ) . rejects . toBeInstanceOf ( LineMediaDownloadTimeoutError ) ;
205+ await expect ( promise . catch ( ( e ) => e . chunkTimeoutMs ) ) . resolves . toBe ( 50 ) ;
206+ } ) ;
207+
208+ it ( "rejects when getMessageContent headers never arrive" , async ( ) => {
209+ getMessageContentMock . mockReturnValueOnce ( new Promise ( ( ) => { } ) ) ;
210+ const promise = downloadLineMedia ( "mid-stall-headers" , "token" , 1024 * 1024 , {
211+ chunkTimeoutMs : 50 ,
212+ } ) ;
213+ await expect ( promise ) . rejects . toBeInstanceOf ( LineMediaDownloadTimeoutError ) ;
214+ } ) ;
215+
216+ it ( "does not reject when chunks arrive within chunkTimeoutMs" , async ( ) => {
217+ const jpeg = Buffer . from ( [ 0xff , 0xd8 , 0xff , 0x00 ] ) ;
218+ getMessageContentMock . mockResolvedValueOnce ( delayedStream ( jpeg , 10 ) ) ;
219+ const result = await downloadLineMedia ( "mid-slow-but-progressing" , "token" , 1024 * 1024 , {
220+ chunkTimeoutMs : 500 ,
221+ } ) ;
222+ expect ( result . contentType ) . toBe ( "image/jpeg" ) ;
223+ } ) ;
224+
225+ it ( "exposes LINE_DOWNLOAD_IDLE_TIMEOUT_MS = 30s aligned with TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS" , ( ) => {
226+ expect ( LINE_DOWNLOAD_IDLE_TIMEOUT_MS ) . toBe ( 30_000 ) ;
227+ } ) ;
228+
229+ it ( "calls iterator.return() exactly once on timeout so the upstream Readable is destroyed" , async ( ) => {
230+ const returnSpy = vi . fn ( async ( ) => ( { value : undefined as unknown as Buffer , done : true } ) ) ;
231+ const stream : AsyncIterable < Buffer > = {
232+ [ Symbol . asyncIterator ] ( ) {
233+ return {
234+ next ( ) : Promise < IteratorResult < Buffer > > {
235+ return new Promise < IteratorResult < Buffer > > ( ( ) => { } ) ;
236+ } ,
237+ return : returnSpy as ( ) => Promise < IteratorResult < Buffer > > ,
238+ } ;
239+ } ,
240+ } ;
241+ getMessageContentMock . mockResolvedValueOnce ( stream ) ;
242+ await expect (
243+ downloadLineMedia ( "mid-return-spy" , "token" , 1024 * 1024 , { chunkTimeoutMs : 50 } ) ,
244+ ) . rejects . toBeInstanceOf ( LineMediaDownloadTimeoutError ) ;
245+ expect ( returnSpy ) . toHaveBeenCalledTimes ( 1 ) ;
246+ } ) ;
247+
248+ it ( "rejects when a second chunk stalls after a successful first chunk (partial-then-stall)" , async ( ) => {
249+ const jpegPart = Buffer . from ( [ 0xff , 0xd8 , 0xff , 0x00 ] ) ;
250+ let yieldedFirst = false ;
251+ const stream : AsyncIterable < Buffer > = {
252+ [ Symbol . asyncIterator ] ( ) {
253+ return {
254+ async next ( ) : Promise < IteratorResult < Buffer > > {
255+ if ( ! yieldedFirst ) {
256+ yieldedFirst = true ;
257+ return { value : jpegPart , done : false } ;
258+ }
259+ return new Promise < IteratorResult < Buffer > > ( ( ) => { } ) ;
260+ } ,
261+ } ;
262+ } ,
263+ } ;
264+ getMessageContentMock . mockResolvedValueOnce ( stream ) ;
265+ const promise = downloadLineMedia ( "mid-partial-stall" , "token" , 1024 * 1024 , {
266+ chunkTimeoutMs : 50 ,
267+ } ) ;
268+ await expect ( promise ) . rejects . toBeInstanceOf ( LineMediaDownloadTimeoutError ) ;
269+ await expect ( promise . catch ( ( e ) => e . chunkTimeoutMs ) ) . resolves . toBe ( 50 ) ;
270+ } ) ;
271+ } ) ;
164272} ) ;
0 commit comments