@@ -122,7 +122,10 @@ vi.mock("../src/lib/logger", () => ({
122122// Import the handler AFTER mocks
123123// ---------------------------------------------------------------------------
124124
125- import { coexistAttachmentDownload } from "../src/integration/handlers/coexist/attachment-download"
125+ import {
126+ coexistAttachmentDownload ,
127+ MAX_ATTACHMENT_BYTES ,
128+ } from "../src/integration/handlers/coexist/attachment-download"
126129
127130// ---------------------------------------------------------------------------
128131// Fixtures
@@ -358,30 +361,41 @@ describe("coexistAttachmentDownload", () => {
358361 } )
359362
360363 // ─────────────────────────────────────────────────────────────────────────
361- // SECURITY: size cap
364+ // SIZE CAP: an oversized attachment is a PERMANENT condition, so the handler
365+ // must skip it terminally (log a warning and return) rather than throw —
366+ // throwing would burn all BullMQ retry attempts on a job that can never
367+ // succeed. See AttachmentTooLargeError.
362368 // ─────────────────────────────────────────────────────────────────────────
363369
364- it ( "rejects Messenger response with content-length exceeding MAX_ATTACHMENT_BYTES " , async ( ) => {
370+ it ( "skips (no throw) a Messenger response whose content-length exceeds the cap " , async ( ) => {
365371 wireSelectChain ( {
366372 id : "att-001" ,
367373 originPath : "https://example.com/media/huge.mp4" ,
368374 mimeType : "video/mp4" ,
369375 } )
370376 wireIntegrationLookup ( )
371377
372- const OVER_LIMIT = String ( 51 * 1024 * 1024 ) // 51 MB
378+ const OVER_LIMIT = String ( MAX_ATTACHMENT_BYTES + 1 )
373379 const fetchMock = vi . fn ( ( ) =>
374380 Promise . resolve ( makeFetchResponse ( { contentLength : OVER_LIMIT } ) ) ,
375381 )
376382 vi . stubGlobal ( "fetch" , fetchMock )
377383
378- await expect ( coexistAttachmentDownload ( BASE_DATA ) ) . rejects . toThrow ( )
384+ // Must resolve (terminal skip), never reject — a reject would trigger retry.
385+ await expect ( coexistAttachmentDownload ( BASE_DATA ) ) . resolves . toBeUndefined ( )
379386 expect ( mockPutObject ) . not . toHaveBeenCalled ( )
387+ expect ( mockUpdateAttachment ) . not . toHaveBeenCalled ( )
388+ expect ( mockLoggerWarn ) . toHaveBeenCalledWith (
389+ expect . objectContaining ( { attachmentId : BASE_DATA . attachmentId } ) ,
390+ expect . stringContaining ( "exceeds size cap" ) ,
391+ )
392+ // Terminal skip must NOT be logged as an error (that reads as a failure).
393+ expect ( mockLoggerError ) . not . toHaveBeenCalled ( )
380394
381395 vi . unstubAllGlobals ( )
382396 } )
383397
384- it ( "rejects Messenger response whose body bytes exceed MAX_ATTACHMENT_BYTES (no content-length)" , async ( ) => {
398+ it ( "skips (no throw) a Messenger response whose streamed body exceeds the cap (no content-length)" , async ( ) => {
385399 wireSelectChain ( {
386400 id : "att-001" ,
387401 originPath : "https://example.com/media/huge.mp4" ,
@@ -393,34 +407,60 @@ describe("coexistAttachmentDownload", () => {
393407 Promise . resolve (
394408 makeFetchResponse ( {
395409 contentLength : undefined , // no header — must be caught by streaming cap
396- totalBytes : 51 * 1024 * 1024 , // 51 MB streamed
410+ totalBytes : MAX_ATTACHMENT_BYTES + 1 , // one byte past the cap
411+ chunkSize : 10 * 1024 * 1024 ,
397412 } ) ,
398413 ) ,
399414 )
400415 vi . stubGlobal ( "fetch" , fetchMock )
401416
402- await expect ( coexistAttachmentDownload ( BASE_DATA ) ) . rejects . toThrow ( )
417+ await expect ( coexistAttachmentDownload ( BASE_DATA ) ) . resolves . toBeUndefined ( )
403418 expect ( mockPutObject ) . not . toHaveBeenCalled ( )
419+ expect ( mockLoggerWarn ) . toHaveBeenCalledWith (
420+ expect . objectContaining ( { attachmentId : BASE_DATA . attachmentId } ) ,
421+ expect . stringContaining ( "exceeds size cap" ) ,
422+ )
404423
405424 vi . unstubAllGlobals ( )
406425 } )
407426
408- it ( "rejects WhatsApp response with content-length exceeding MAX_ATTACHMENT_BYTES " , async ( ) => {
427+ it ( "skips (no throw) a WhatsApp response whose content-length exceeds the cap " , async ( ) => {
409428 wireSelectChain ( {
410429 id : "att-001" ,
411430 originPath : "wa-media:media-id-xyz" ,
412431 mimeType : "video/mp4" ,
413432 } )
414433 wireIntegrationLookup ( { ...FAKE_INTEGRATION_ROW , channel : "whatsapp" } )
415434
416- const OVER_LIMIT = String ( 51 * 1024 * 1024 )
435+ const OVER_LIMIT = String ( MAX_ATTACHMENT_BYTES + 1 )
417436 const fetchMock = vi . fn ( ( ) =>
418437 Promise . resolve ( makeFetchResponse ( { contentLength : OVER_LIMIT } ) ) ,
419438 )
420439 vi . stubGlobal ( "fetch" , fetchMock )
421440
422- await expect ( coexistAttachmentDownload ( WA_DATA ) ) . rejects . toThrow ( )
441+ await expect ( coexistAttachmentDownload ( WA_DATA ) ) . resolves . toBeUndefined ( )
442+ expect ( mockPutObject ) . not . toHaveBeenCalled ( )
443+
444+ vi . unstubAllGlobals ( )
445+ } )
446+
447+ it ( "still throws (retryable) on a transient download failure — non-size errors keep retrying" , async ( ) => {
448+ wireSelectChain ( {
449+ id : "att-001" ,
450+ originPath : "https://example.com/media/photo.jpg" ,
451+ mimeType : "image/jpeg" ,
452+ } )
453+ wireIntegrationLookup ( )
454+
455+ // A 5xx is transient: the handler must rethrow so BullMQ retries it.
456+ const fetchMock = vi . fn ( ( ) =>
457+ Promise . resolve ( makeFetchResponse ( { ok : false } ) ) ,
458+ )
459+ vi . stubGlobal ( "fetch" , fetchMock )
460+
461+ await expect ( coexistAttachmentDownload ( BASE_DATA ) ) . rejects . toThrow ( )
423462 expect ( mockPutObject ) . not . toHaveBeenCalled ( )
463+ expect ( mockLoggerError ) . toHaveBeenCalled ( )
424464
425465 vi . unstubAllGlobals ( )
426466 } )
0 commit comments