@@ -136,3 +136,113 @@ describe('the enveloped storage responses match their declared return types (#36
136136 expect ( res . data . eTag ) . toBe ( '"abc"' ) ;
137137 } ) ;
138138} ) ;
139+
140+ /**
141+ * `resumeUpload` short-circuits on the expiry the progress poll already told it
142+ * about (#7870).
143+ *
144+ * Since #7667 a chunked session past its own `expires_at` is durably stamped
145+ * `expired`, `GET .../progress` reports that status, and a chunk PUT against it
146+ * answers 410 `UPLOAD_SESSION_EXPIRED`. `resumeUpload` polls progress FIRST but
147+ * read only the chunk counters off it, so it uploaded a full chunk into a
148+ * session the poll had already declared dead and learned that from the 410.
149+ *
150+ * What these pin is the pair a caller actually branches on — `code` AND
151+ * `httpStatus` — not merely that something threw: the point of the fix is that
152+ * the early exit is INDISTINGUISHABLE from the server's own refusal, so a
153+ * `catch` written against the 410 keeps matching. Asserting only "it throws"
154+ * would stay green if the short-circuit raised a bare `Error`, which is the one
155+ * outcome that would break every such caller.
156+ */
157+ describe ( 'storage.resumeUpload exits on an expired session before uploading (#7870)' , ( ) => {
158+ /** The progress body the server sends for a session past its deadline. */
159+ const expiredProgress = {
160+ success : true ,
161+ data : {
162+ uploadId : 'up1' ,
163+ fileId : 'f1' ,
164+ filename : 'big.bin' ,
165+ totalSize : 16 ,
166+ uploadedSize : 8 ,
167+ totalChunks : 2 ,
168+ uploadedChunks : 1 ,
169+ percentComplete : 50 ,
170+ status : 'expired' ,
171+ startedAt : '2026-01-01T00:00:00.000Z' ,
172+ expiresAt : '2026-01-01T01:00:00.000Z' ,
173+ } ,
174+ } ;
175+
176+ it ( 'throws the registered code and status the server answers a dead session with' , async ( ) => {
177+ const { client } = clientReturning ( expiredProgress ) ;
178+
179+ // `.rejects.toThrow()` alone cannot see the difference between this and a
180+ // bare Error, so the envelope is asserted off the caught object.
181+ const err = await client . storage
182+ . resumeUpload ( 'up1' , new ArrayBuffer ( 16 ) , 8 , 'rtok' )
183+ . then ( ( ) => null , ( e : any ) => e ) ;
184+
185+ expect ( err ) . toBeInstanceOf ( Error ) ;
186+ expect ( err . code ) . toBe ( 'UPLOAD_SESSION_EXPIRED' ) ;
187+ expect ( err . httpStatus ) . toBe ( 410 ) ;
188+ // The expiry instant is what tells a caller/operator WHICH deadline passed,
189+ // so it survives into both the human message and `details`.
190+ expect ( err . message ) . toContain ( '2026-01-01T01:00:00.000Z' ) ;
191+ expect ( err . details ) . toMatchObject ( { uploadId : 'up1' , expiresAt : '2026-01-01T01:00:00.000Z' } ) ;
192+ } ) ;
193+
194+ it ( 'sends no chunk PUT and no complete — the poll is the only request made' , async ( ) => {
195+ // The whole point of the card: the bytes never leave. If this regresses,
196+ // the throw above would still pass while the client had already spent a
197+ // chunk upload against a dead session.
198+ const { client, fetchMock } = clientReturning ( expiredProgress ) ;
199+
200+ await client . storage . resumeUpload ( 'up1' , new ArrayBuffer ( 16 ) , 8 , 'rtok' ) . catch ( ( ) => { } ) ;
201+
202+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
203+ expect ( fetchMock . mock . calls [ 0 ] [ 0 ] ) . toContain ( '/upload/chunked/up1/progress' ) ;
204+ } ) ;
205+
206+ it ( 'still resumes normally when the session is live' , async ( ) => {
207+ // The reverse direction: `=== 'expired'` must not swallow the happy path.
208+ // One mock, routed by URL — resume makes three hops (progress, chunk PUT,
209+ // complete) and they answer different bodies.
210+ const fetchMock = vi . fn ( async ( url : string ) => {
211+ const body = url . includes ( '/progress' )
212+ ? { success : true , data : { ...expiredProgress . data , status : 'in_progress' } }
213+ : url . includes ( '/complete' )
214+ ? { success : true , data : { fileId : 'f1' , size : 16 } }
215+ : { success : true , data : { chunkIndex : 1 , eTag : '"e2"' , bytesReceived : 8 } } ;
216+ return { ok : true , status : 200 , statusText : 'OK' , json : async ( ) => body , headers : new Headers ( ) } ;
217+ } ) ;
218+ const client = new ObjectStackClient ( { baseUrl : 'http://localhost:3000' , fetch : fetchMock as any } ) ;
219+
220+ const res = await client . storage . resumeUpload ( 'up1' , new ArrayBuffer ( 16 ) , 8 , 'rtok' ) ;
221+
222+ expect ( res . success ) . toBe ( true ) ;
223+ expect ( fetchMock . mock . calls . map ( ( c ) => String ( c [ 0 ] ) ) ) . toEqual ( [
224+ expect . stringContaining ( '/upload/chunked/up1/progress' ) ,
225+ expect . stringContaining ( '/upload/chunked/up1/chunk/1' ) ,
226+ expect . stringContaining ( '/upload/chunked/up1/complete' ) ,
227+ ] ) ;
228+ } ) ;
229+
230+ it ( 'does not misfire when a server or fixture omits status entirely' , async ( ) => {
231+ // `status` is declared required, but the client is published separately
232+ // from the server it meets and the SDK's own URL-conformance fixture drives
233+ // this method with a counters-only body. An absent status must resume, not
234+ // abort — which is why the guard compares against 'expired' rather than
235+ // testing truthiness or absence.
236+ const fetchMock = vi . fn ( async ( url : string ) => {
237+ const body = url . includes ( '/progress' )
238+ ? { success : true , data : { totalChunks : 1 , uploadedChunks : 0 } }
239+ : { success : true , data : { chunkIndex : 0 , eTag : '"e1"' , bytesReceived : 8 } } ;
240+ return { ok : true , status : 200 , statusText : 'OK' , json : async ( ) => body , headers : new Headers ( ) } ;
241+ } ) ;
242+ const client = new ObjectStackClient ( { baseUrl : 'http://localhost:3000' , fetch : fetchMock as any } ) ;
243+
244+ await expect (
245+ client . storage . resumeUpload ( 'up1' , new ArrayBuffer ( 8 ) , 8 , 'rtok' ) ,
246+ ) . resolves . toBeDefined ( ) ;
247+ } ) ;
248+ } ) ;
0 commit comments