@@ -11,6 +11,9 @@ import (
1111 "gamarr/internal/config"
1212 "gamarr/internal/db"
1313 "gamarr/internal/models"
14+ "os"
15+ "path/filepath"
16+ "time"
1417)
1518
1619// ── Wishlist CRUD ──────────────────────────────────────────────────────────────
@@ -272,39 +275,104 @@ func TestRetryJob(t *testing.T) {
272275 env := newTestEnv (t , nil )
273276 env .jobs .Set ("job-err" , map [string ]interface {}{"status" : "error" , "title" : "Broken" })
274277
275- t .Run ("failed job requeued" , func (t * testing.T ) {
278+ // A job with no torrent recorded has nothing to import again, and the row
279+ // stays where the UI still offers a button. It used to report success and
280+ // park at "queued", which nothing consumed and nothing could act on.
281+ t .Run ("job with no torrent reports failure" , func (t * testing.T ) {
276282 rr := env .do ("POST" , "/api/downloads/job-err/retry" , "" )
277- wantStatus (t , rr , 200 )
278- if m := decodeMap (t , rr ); m ["success" ] != true {
279- t .Errorf ("retry failed: %v" , m )
283+ // A refusal is a client error, and every non-browser consumer reads the
284+ // status before it reads the body.
285+ wantStatus (t , rr , 400 )
286+ m := decodeMap (t , rr )
287+ if m ["success" ] != false {
288+ t .Errorf ("retry reported success with no torrent to import: %v" , m )
289+ }
290+ if msg , _ := m ["error" ].(string ); ! strings .Contains (msg , "no torrent recorded" ) {
291+ t .Errorf ("error = %q, want it to say why" , msg )
280292 }
281293 data , _ := env .jobs .Get ("job-err" )
282- if data ["status" ] != "queued " {
283- t .Errorf ("status after retry = %v, want queued " , data ["status" ])
294+ if data ["status" ] != "error " {
295+ t .Errorf ("status after a refused retry = %v, want error " , data ["status" ])
284296 }
285297 })
286298
287299 t .Run ("missing job reports failure" , func (t * testing.T ) {
288300 rr := env .do ("POST" , "/api/downloads/missing/retry" , "" )
289- wantStatus (t , rr , 200 )
301+ wantStatus (t , rr , 400 )
290302 if m := decodeMap (t , rr ); m ["success" ] != false {
291303 t .Errorf ("expected success=false for missing job, got %v" , m )
292304 }
293305 })
294306}
295307
296308func TestBulkRetryAndCancel (t * testing.T ) {
297- env := newTestEnv (t , nil )
298- env .jobs .Set ("f1" , map [string ]interface {}{"status" : "error" , "title" : "F1" })
309+ // One fixture records a torrent the client actually holds, so the bulk path
310+ // reaches the retry rather than short-circuiting on a missing hash. Without
311+ // it every request returns at the first check and the endpoint stays green
312+ // under a full revert.
313+ content := t .TempDir ()
314+ if err := os .WriteFile (filepath .Join (content , "setup.exe" ), []byte ("installer" ), 0644 ); err != nil {
315+ t .Fatalf ("stage content: %v" , err )
316+ }
317+ qb := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
318+ switch r .URL .Path {
319+ case "/api/v2/auth/login" :
320+ w .Write ([]byte ("Ok." ))
321+ case "/api/v2/torrents/info" :
322+ json .NewEncoder (w ).Encode ([]map [string ]interface {}{{
323+ "name" : "F1" , "hash" : "f1-hash" , "progress" : 1.0 ,
324+ "save_path" : content , "content_path" : content ,
325+ }})
326+ default :
327+ w .WriteHeader (200 )
328+ }
329+ }))
330+ defer qb .Close ()
331+
332+ env := newTestEnv (t , func (c * config.Config ) {
333+ c .QBURL = qb .URL
334+ // Without these the import destination is relative and resolves against
335+ // the package source directory.
336+ c .QBSavePath , c .GamesVaultPath , c .GamesRomsPath = t .TempDir (), t .TempDir (), t .TempDir ()
337+ })
338+ env .jobs .Set ("f1" , map [string ]interface {}{
339+ "status" : "error" , "title" : "F1" , "info_hash" : "f1-hash" , "is_pc" : true ,
340+ })
299341 env .jobs .Set ("f2" , map [string ]interface {}{"status" : "dead_letter" , "title" : "F2" })
300342 env .jobs .Set ("a1" , map [string ]interface {}{"status" : "downloading" , "title" : "A1" })
301343
302344 t .Run ("retry all failed with empty body" , func (t * testing.T ) {
303345 rr := env .do ("POST" , "/api/admin/bulk/retry" , "" )
304346 wantStatus (t , rr , 200 )
305347 m := decodeMap (t , rr )
306- if m ["requested" ] != float64 (2 ) || m ["succeeded" ] != float64 (2 ) {
307- t .Errorf ("bulk retry = %v, want requested=2 succeeded=2" , m )
348+ // f1 records a torrent the client holds, so its retry starts; f2 records
349+ // none, so it is refused with a reason.
350+ if m ["requested" ] != float64 (2 ) || m ["succeeded" ] != float64 (1 ) {
351+ t .Errorf ("bulk retry = %v, want requested=2 succeeded=1" , m )
352+ }
353+ results , _ := m ["results" ].([]interface {})
354+ if len (results ) != 2 {
355+ t .Fatalf ("results = %v, want one per requested job" , m ["results" ])
356+ }
357+ // The retry runs in a goroutine, so wait for it rather than returning
358+ // while it is still writing.
359+ deadline := time .Now ().Add (10 * time .Second )
360+ for time .Now ().Before (deadline ) {
361+ if job , ok := env .jobs .Get ("f1" ); ok {
362+ if s , _ := job ["status" ].(string ); s == "completed" {
363+ break
364+ }
365+ }
366+ time .Sleep (5 * time .Millisecond )
367+ }
368+ if job , _ := env .jobs .Get ("f1" ); job ["status" ] != "completed" {
369+ t .Errorf ("f1 status = %v, want completed" , job ["status" ])
370+ }
371+ for _ , r := range results {
372+ row , _ := r .(map [string ]interface {})
373+ if msg , _ := row ["message" ].(string ); msg == "" {
374+ t .Errorf ("result %v carries no reason" , row )
375+ }
308376 }
309377 })
310378
@@ -1020,3 +1088,58 @@ func TestAdminDashboard(t *testing.T) {
10201088 t .Error ("dashboard missing system section" )
10211089 }
10221090}
1091+
1092+ // The Retry button is gated on the payload saying the job has a torrent to
1093+ // resolve. `hash` cannot answer that: it is a LIVE torrent matched to the row by
1094+ // fuzzy title, so a release whose name differs from the job title - which is any
1095+ // title carrying a colon, apostrophe or ampersand - loses the button while the
1096+ // backend would retry it fine.
1097+ func TestDownloadsReportTheJobsOwnInfoHash (t * testing.T ) {
1098+ qb := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
1099+ switch r .URL .Path {
1100+ case "/api/v2/auth/login" :
1101+ w .Write ([]byte ("Ok." ))
1102+ case "/api/v2/torrents/info" :
1103+ json .NewEncoder (w ).Encode ([]map [string ]interface {}{
1104+ {"name" : "Assassins.Creed.Valhalla.REPACK-KaOs" , "hash" : "ac-hash" , "progress" : 1.0 },
1105+ {"name" : "Plain Game" , "hash" : "pg-hash" , "progress" : 1.0 },
1106+ })
1107+ default :
1108+ w .WriteHeader (200 )
1109+ }
1110+ }))
1111+ defer qb .Close ()
1112+
1113+ env := newTestEnv (t , func (c * config.Config ) { c .QBURL = qb .URL })
1114+ // The first title does not fuzzy-match its torrent name, so it lands in the
1115+ // unmatched branch; the second does, so it lands in the matched one. Both
1116+ // have to carry the hash or one of the two branches silently drops it.
1117+ env .jobs .Set ("ac" , map [string ]interface {}{
1118+ "status" : "error" , "title" : "Assassin's Creed: Valhalla" , "info_hash" : "ac-hash" ,
1119+ })
1120+ env .jobs .Set ("pg" , map [string ]interface {}{
1121+ "status" : "error" , "title" : "Plain Game" , "info_hash" : "pg-hash" ,
1122+ })
1123+
1124+ rr := env .do ("GET" , "/api/downloads" , "" )
1125+ wantStatus (t , rr , 200 )
1126+ entries , _ := decodeMap (t , rr )["downloads" ].([]interface {})
1127+
1128+ rows := map [string ]map [string ]interface {}{}
1129+ for _ , e := range entries {
1130+ m , _ := e .(map [string ]interface {})
1131+ if id , _ := m ["job_id" ].(string ); id != "" {
1132+ rows [id ] = m
1133+ }
1134+ }
1135+ for jobID , want := range map [string ]string {"ac" : "ac-hash" , "pg" : "pg-hash" } {
1136+ row , ok := rows [jobID ]
1137+ if ! ok {
1138+ t .Errorf ("no entry for job %s: %v" , jobID , entries )
1139+ continue
1140+ }
1141+ if got , _ := row ["info_hash" ].(string ); got != want {
1142+ t .Errorf ("job %s info_hash = %q, want %q" , jobID , got , want )
1143+ }
1144+ }
1145+ }
0 commit comments