77 "crypto/md5"
88 "encoding/hex"
99 "encoding/json"
10+ "errors"
1011 "io"
1112 "mime/multipart"
1213 "net/http"
@@ -1351,6 +1352,9 @@ func TestResourceService_DownloadResourceScenarios(t *testing.T) {
13511352 assert .Contains (t , w .Header ().Get ("Content-Disposition" ), tt .wantDispContains )
13521353 }
13531354 if tt .wantZipEntries != nil {
1355+ // A streamed ZIP download must not buffer the whole archive, so it
1356+ // must not carry a Content-Length computed from a full buffer.
1357+ assert .Empty (t , w .Header ().Get ("Content-Length" ))
13541358 zr , err := zip .NewReader (bytes .NewReader (w .Body .Bytes ()), int64 (w .Body .Len ()))
13551359 require .NoError (t , err )
13561360 got := map [string ]string {}
@@ -1370,6 +1374,91 @@ func TestResourceService_DownloadResourceScenarios(t *testing.T) {
13701374 }
13711375}
13721376
1377+ // TestStreamZipArchive verifies the shared streaming helper writes a valid ZIP
1378+ // straight to the response writer without buffering the whole archive (no
1379+ // Content-Length is emitted) and that a build error mid-stream propagates to the
1380+ // caller and aborts the request.
1381+ func TestStreamZipArchive (t * testing.T ) {
1382+ gin .SetMode (gin .TestMode )
1383+
1384+ t .Run ("streams archive without content-length" , func (t * testing.T ) {
1385+ w := httptest .NewRecorder ()
1386+ c , _ := gin .CreateTestContext (w )
1387+ c .Request = httptest .NewRequest (http .MethodGet , "/download" , nil )
1388+
1389+ err := streamZipArchive (c , "out.zip" , func (zw io.Writer ) error {
1390+ z := zip .NewWriter (zw )
1391+ f , createErr := z .Create ("hello.txt" )
1392+ require .NoError (t , createErr )
1393+ if _ , writeErr := f .Write ([]byte ("hello world" )); writeErr != nil {
1394+ return writeErr
1395+ }
1396+ return z .Close ()
1397+ })
1398+
1399+ require .NoError (t , err )
1400+ assert .False (t , c .IsAborted ())
1401+ assert .Equal (t , "application/zip" , w .Header ().Get ("Content-Type" ))
1402+ assert .Contains (t , w .Header ().Get ("Content-Disposition" ), "out.zip" )
1403+ // The whole archive was never buffered, so no buffer-derived length exists.
1404+ assert .Empty (t , w .Header ().Get ("Content-Length" ))
1405+
1406+ zr , err := zip .NewReader (bytes .NewReader (w .Body .Bytes ()), int64 (w .Body .Len ()))
1407+ require .NoError (t , err )
1408+ require .Len (t , zr .File , 1 )
1409+ assert .Equal (t , "hello.txt" , zr .File [0 ].Name )
1410+ rc , err := zr .File [0 ].Open ()
1411+ require .NoError (t , err )
1412+ data , err := io .ReadAll (rc )
1413+ require .NoError (t , err )
1414+ require .NoError (t , rc .Close ())
1415+ assert .Equal (t , "hello world" , string (data ))
1416+ })
1417+
1418+ t .Run ("propagates build error after partial stream and aborts" , func (t * testing.T ) {
1419+ w := httptest .NewRecorder ()
1420+ c , _ := gin .CreateTestContext (w )
1421+ c .Request = httptest .NewRequest (http .MethodGet , "/download" , nil )
1422+
1423+ sentinel := errors .New ("source reader failed mid-stream" )
1424+ err := streamZipArchive (c , "out.zip" , func (zw io.Writer ) error {
1425+ z := zip .NewWriter (zw )
1426+ f , createErr := z .Create ("partial.txt" )
1427+ require .NoError (t , createErr )
1428+ if _ , writeErr := f .Write ([]byte ("partial data" )); writeErr != nil {
1429+ return writeErr
1430+ }
1431+ // Flush bytes to the response, then fail as a slow/erroring source would.
1432+ if flushErr := z .Flush (); flushErr != nil {
1433+ return flushErr
1434+ }
1435+ return sentinel
1436+ })
1437+
1438+ require .ErrorIs (t , err , sentinel )
1439+ assert .True (t , c .IsAborted ())
1440+ })
1441+
1442+ t .Run ("returns structured error when build fails before writing" , func (t * testing.T ) {
1443+ w := httptest .NewRecorder ()
1444+ c , _ := gin .CreateTestContext (w )
1445+ c .Request = httptest .NewRequest (http .MethodGet , "/download" , nil )
1446+
1447+ sentinel := errors .New ("blob open failed before any write" )
1448+ err := streamZipArchive (c , "out.zip" , func (zw io.Writer ) error {
1449+ // Fail immediately, before writing any archive bytes.
1450+ return sentinel
1451+ })
1452+
1453+ require .ErrorIs (t , err , sentinel )
1454+ // Nothing was streamed, so a normal (non-200, non-zip) error response is
1455+ // sent instead of a truncated 200.
1456+ assert .Equal (t , http .StatusInternalServerError , w .Code )
1457+ assert .NotEqual (t , "application/zip" , w .Header ().Get ("Content-Type" ))
1458+ assert .True (t , c .IsAborted ())
1459+ })
1460+ }
1461+
13731462func TestResourceService_DeleteResourceScenarios (t * testing.T ) {
13741463 type seed struct {
13751464 userID uint64
0 commit comments