@@ -79,6 +79,7 @@ const mailbox: GmailMessage[] = Array.from({ length: 12 }, (_, index) => ({
7979 body : { data : Buffer . from ( `Synthetic evidence ${ index } .` ) . toString ( 'base64url' ) } ,
8080 } ,
8181} ) )
82+ const messageFailures = new Map < string , { status : number ; format : string } > ( )
8283const mediaStarted = createDeferred < void > ( )
8384const mediaClosed = createDeferred < void > ( )
8485const server = http . createServer ( ( request , response ) => {
@@ -92,6 +93,14 @@ const server = http.createServer((request, response) => {
9293 const row = mailbox . find ( ( message ) => message . id === id )
9394 const isThread = url . pathname . startsWith ( '/gmail/v1/users/me/threads/' )
9495 const isAttachment = url . pathname . includes ( '/attachments/' )
96+ const failure = ! isThread ? messageFailures . get ( id ) : undefined
97+ if ( failure && url . searchParams . get ( 'format' ) === failure . format ) {
98+ requests . push ( { id, resource : 'message' , status : failure . status } )
99+ response
100+ . writeHead ( failure . status , { 'Content-Type' : 'application/json' } )
101+ . end ( JSON . stringify ( { error : { code : failure . status } } ) )
102+ return
103+ }
95104 const threadMessages = isThread ? mailbox . filter ( ( message ) => message . threadId === id ) : [ ]
96105 const metadata = ( message : ( typeof mailbox ) [ number ] ) => ( {
97106 ...message ,
@@ -284,6 +293,81 @@ try {
284293 server . listen ( 0 , '127.0.0.1' , ( ) => started . resolve ( ) )
285294 await started . promise
286295 origin = `http://127.0.0.1:${ ( server . address ( ) as AddressInfo ) . port } `
296+ for ( const { name, failedId, status, expectedStatus } of [
297+ { name : 'healthy conversation' , failedId : '' , status : 200 } ,
298+ { name : 'missing sibling' , failedId : 'reply' , status : 404 } ,
299+ { name : 'missing anchor' , failedId : 'anchor' , status : 404 , expectedStatus : 'unavailable' } ,
300+ { name : 'expired credentials' , failedId : 'reply' , status : 401 , expectedStatus : 'reconnect' } ,
301+ { name : 'denied credentials' , failedId : 'reply' , status : 403 , expectedStatus : 'reconnect' } ,
302+ { name : 'rate limit' , failedId : 'reply' , status : 429 , expectedStatus : 'rate_limited' } ,
303+ { name : 'server failure' , failedId : 'reply' , status : 500 , expectedStatus : 'unavailable' } ,
304+ ] ) {
305+ for ( const format of failedId === 'reply' ? [ 'metadata' , 'full' ] : [ 'full' ] ) {
306+ await check (
307+ `Gmail conversation handles ${ name } during ${ format } reads without hiding unrelated failures` ,
308+ async ( ) => {
309+ const before = mailbox . length
310+ mailbox . push (
311+ ...[ 'anchor' , 'retained' , 'reply' ] . map ( ( id , index ) => ( {
312+ id,
313+ threadId : 'failure-conversation' ,
314+ labelIds : [ 'INBOX' ] ,
315+ internalDate : String ( Date . UTC ( 2026 , 0 , 1 , 12 , index ) ) ,
316+ payload : {
317+ mimeType : 'text/plain' ,
318+ body : { data : Buffer . from ( `Synthetic ${ id } evidence.` ) . toString ( 'base64url' ) } ,
319+ } ,
320+ } ) )
321+ )
322+ if ( failedId ) messageFailures . set ( failedId , { status, format } )
323+ try {
324+ const signal = AbortSignal . timeout ( 5000 )
325+ const api = createNativeClient ( { origin, accessToken : TOKEN , signal } )
326+ const policy = {
327+ ...defaultLiveSearchPolicy ( 'gmail' ) ,
328+ mode : 'selected' as const ,
329+ included : [ 'INBOX' ] ,
330+ }
331+ const verify = createPolicyVerifier ( 'gmail' , policy , api , origin )
332+ assert . ok ( await verify ( { id : 'anchor' } ) )
333+ const reading = readGmail ( api , 'anchor' , {
334+ policy,
335+ signal,
336+ verify : ( reference ) =>
337+ verify ( reference , format === 'full' ? reference . accessMetadata : undefined ) ,
338+ } )
339+ if ( expectedStatus ) {
340+ await assert . rejects (
341+ reading ,
342+ ( error : unknown ) =>
343+ error instanceof NativeSearchError && error . status === expectedStatus
344+ )
345+ return
346+ }
347+ const document = await reading
348+ assert . equal ( document . id , 'anchor' )
349+ assert . ok ( document . content . includes ( 'Synthetic anchor evidence.' ) )
350+ assert . ok ( document . content . includes ( 'Synthetic retained evidence.' ) )
351+ assert . equal ( document . content . includes ( 'Synthetic reply evidence.' ) , status === 200 )
352+ assert . equal ( document . content . includes ( 'Coverage incomplete:' ) , status === 404 )
353+ assert . deepEqual (
354+ document . accessDependencies ,
355+ status === 404 ? [ { id : 'retained' } ] : [ { id : 'retained' } , { id : 'reply' } ]
356+ )
357+ const current = createPolicyVerifier ( 'gmail' , policy , api , origin , undefined , {
358+ fresh : true ,
359+ } )
360+ assert . ok ( await current ( { id : document . id } ) )
361+ for ( const dependency of document . accessDependencies ?? [ ] )
362+ assert . ok ( await current ( dependency ) )
363+ } finally {
364+ mailbox . splice ( before )
365+ messageFailures . clear ( )
366+ }
367+ }
368+ )
369+ }
370+ }
287371 await check (
288372 'Gmail conversation and fresh selected-label checks fit one native request budget' ,
289373 async ( ) => {
0 commit comments