From c4abbf8743d4306a04fa18613cd174b1de326712 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 11:09:18 +0700 Subject: [PATCH] fix(verification): recover signed-session bootstrap failures --- go_backend/exports_extensions.go | 54 +++++--- go_backend/extension_signed_session.go | 121 +++++++++++++----- go_backend/extension_signed_session_test.go | 119 +++++++++++++++++ lib/providers/download_queue_provider.dart | 29 ++--- .../download_verification_retry_guard.dart | 32 +++++ ...ownload_verification_retry_guard_test.dart | 48 +++++++ 6 files changed, 340 insertions(+), 63 deletions(-) create mode 100644 lib/providers/download_verification_retry_guard.dart create mode 100644 test/download_verification_retry_guard_test.dart diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go index c03ccf35..4ace8f71 100644 --- a/go_backend/exports_extensions.go +++ b/go_backend/exports_extensions.go @@ -512,7 +512,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { preflightStartedAt := time.Now() verificationRequired, preflightErr := preflightExtensionDownloadSession(sessionProvider) if preflightErr != nil { - GoLog("[DownloadWithExtensions] Signed-session preflight for %s was inconclusive after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr) + message := fmt.Sprintf("Could not start verification for %s: %v", sessionProvider, preflightErr) + GoLog("[DownloadWithExtensions] Signed-session preflight for %s failed after %s: %v\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond), preflightErr) + if req.ItemID != "" { + RemoveItemProgress(req.ItemID) + } + return marshalJSONString(&DownloadResponse{ + Success: false, + Error: message, + ErrorType: classifyDownloadErrorType(message), + Service: sessionProvider, + }) } else if verificationRequired { GoLog("[DownloadWithExtensions] Signed-session verification required for %s after %s; skipping metadata preparation\n", sessionProvider, time.Since(preflightStartedAt).Round(time.Millisecond)) cacheUnpreparedDownloadRequest(downloadPreparationKey(req), req) @@ -566,7 +576,10 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { } func GetExtensionPendingAuthJSON(extensionID string) (string, error) { - req := ensureExtensionPendingAuthRequest(extensionID) + req, err := ensureExtensionPendingAuthRequest(extensionID) + if err != nil { + return "", err + } if req == nil { return "", nil } @@ -580,15 +593,15 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) { return marshalJSONString(result) } -func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest { +func ensureExtensionPendingAuthRequest(extensionID string) (*PendingAuthRequest, error) { extensionID = strings.TrimSpace(extensionID) if extensionID == "" { - return nil + return nil, nil } if req := GetPendingAuthRequest(extensionID); req != nil { if time.Since(req.CreatedAt) < pendingAuthRequestTTL { - return req + return req, nil } // The cached challenge is stale (e.g. verification was requested // while the app was backgrounded and never completed); serving it @@ -599,25 +612,34 @@ func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil { - return nil + return nil, nil } - if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil { - return nil + if err := ext.ensureRuntimeReady(); err != nil { + return nil, err + } + if ext.runtime == nil { + return nil, fmt.Errorf("extension '%s' runtime is unavailable", extensionID) } config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession) if config.Namespace == "" || config.BaseURL == "" { - return nil + return nil, nil } - if record, err := ext.runtime.loadSignedSession(config); err == nil { - record.SessionID = "" - record.SessionSecret = "" - record.ExpiresAt = "" - _ = ext.runtime.saveSignedSession(config, record) + record, err := ext.runtime.loadSignedSession(config) + if err != nil { + return nil, err } - ext.runtime.startSignedSessionVerification(config, "pending-auth-request") - return GetPendingAuthRequest(extensionID) + record.SessionID = "" + record.SessionSecret = "" + record.ExpiresAt = "" + if err := ext.runtime.saveSignedSession(config, record); err != nil { + return nil, err + } + if _, err := ext.runtime.startSignedSessionVerification(config, "pending-auth-request"); err != nil { + return nil, err + } + return GetPendingAuthRequest(extensionID), nil } func SetExtensionAuthCodeByID(extensionID, authCode string) { diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 4cab0564..57b329c1 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -214,8 +215,8 @@ func signedSessionRecordIsUsable(record *signedSessionRecord) bool { // preflightSignedSession prepares a signed session before download metadata // enrichment starts. A fresh pending challenge is reused, while bootstrap // responses that can issue a session silently are accepted without prompting -// the user. Bootstrap failures remain non-fatal to the caller so the normal -// provider path can still surface its more specific error. +// the user. Bootstrap failures are returned so callers do not continue into +// the provider and accidentally issue the same failing bootstrap repeatedly. func (r *extensionRuntime) preflightSignedSession() (bool, error) { if r == nil || r.manifest == nil || r.manifest.SignedSession == nil { return false, nil @@ -242,7 +243,9 @@ func (r *extensionRuntime) preflightSignedSession() (bool, error) { ClearPendingAuthRequest(r.extensionID) } - if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" { + if authURL, err := r.startSignedSessionVerification(config, "download-preflight"); err != nil { + return false, err + } else if authURL != "" { return true, nil } @@ -394,8 +397,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value record, err := r.ensureSignedSession(config) if err != nil { - if authURL := r.startSignedSessionVerification(config, ""); authURL != "" { + if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch"); authURL != "" { return r.signedSessionVerificationRequiredValue(authURL) + } else if verificationErr != nil { + return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()}) } @@ -409,8 +414,10 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value record.SessionSecret = "" record.ExpiresAt = "" _ = r.saveSignedSession(config, record) - if authURL := r.startSignedSessionVerification(config, ""); authURL != "" { + if authURL, verificationErr := r.startSignedSessionVerification(config, "signed-fetch-reauth"); authURL != "" { return r.signedSessionVerificationRequiredValue(authURL) + } else if verificationErr != nil { + return r.vm.ToValue(map[string]any{"ok": false, "error": verificationErr.Error()}) } } return r.vm.ToValue(map[string]any{ @@ -490,45 +497,94 @@ func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, reco return nil } -func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string { +func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) (string, error) { record, err := r.loadSignedSession(config) if err != nil { - return "" + return "", fmt.Errorf("load signed-session bootstrap state: %w", err) } bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap) if err != nil { - return "" + return "", fmt.Errorf("build signed-session bootstrap URL: %w", err) + } + parsed, err := url.Parse(bootstrapURL) + if err != nil { + return "", fmt.Errorf("parse signed-session bootstrap URL: %w", err) } - parsed, _ := url.Parse(bootstrapURL) query := parsed.Query() query.Set("app_version", config.AppVersion) query.Set("install_id", record.InstallID) parsed.RawQuery = query.Encode() - req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) - if err != nil { - return "" + if r.httpClient == nil { + return "", fmt.Errorf("signed-session bootstrap HTTP client is unavailable") + } + + var resp *http.Response + for attempt := 0; attempt < 2; attempt++ { + req, requestErr := http.NewRequest(http.MethodGet, parsed.String(), nil) + if requestErr != nil { + return "", fmt.Errorf("build signed-session bootstrap request: %w", requestErr) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) + resp, err = r.httpClient.Do(req) + if err == nil { + break + } + if resp != nil && resp.Body != nil { + resp.Body.Close() + resp = nil + } + if attempt == 0 { + // Android can retain pooled connections across a Wi-Fi/cellular + // transition. Rebuild the GET once after dropping those sockets. + r.httpClient.CloseIdleConnections() + } } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion) - resp, err := r.httpClient.Do(req) if err != nil { - return "" + var urlErr *url.Error + if errors.As(err, &urlErr) && urlErr.Err != nil { + err = urlErr.Err + } + bootstrapErr := fmt.Errorf( + "signed-session bootstrap network request to %s failed: %v", + parsed.Host, + err, + ) + LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr) + return "", bootstrapErr } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes)) - if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "" + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Drain a bounded error response so the transport can reuse the + // connection without exposing response bodies that may contain secrets. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + message := fmt.Sprintf("signed-session bootstrap returned HTTP %d", resp.StatusCode) + if resp.StatusCode >= 500 { + message = fmt.Sprintf("signed-session bootstrap network request returned HTTP %d", resp.StatusCode) + } + if retryAfter := signedSessionRetryAfterSeconds(resp); retryAfter > 0 { + message += fmt.Sprintf("; retry-after seconds: %d", retryAfter) + } + bootstrapErr := errors.New(message) + LogWarn("SignedSession", "Bootstrap failed for extension %s (%s): %v", r.extensionID, reason, bootstrapErr) + return "", bootstrapErr + } + body, err := readExtensionHTTPResponseBody(resp) + if err != nil { + return "", fmt.Errorf("read signed-session bootstrap response: %w", err) } var boot signedSessionExchangeResponse if err := json.Unmarshal(body, &boot); err != nil { - return "" + return "", fmt.Errorf("decode signed-session bootstrap response: %w", err) } if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" { record.SessionID = boot.SessionID record.SessionSecret = boot.SessionSecret record.ExpiresAt = boot.ExpiresAt - _ = r.saveSignedSession(config, record) - return "" + if err := r.saveSignedSession(config, record); err != nil { + return "", fmt.Errorf("save bootstrapped signed session: %w", err) + } + return "", nil } authURL := boot.AuthURL if authURL == "" && boot.ChallengeURL != "" { @@ -537,17 +593,18 @@ func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionCo if authURL == "" && boot.ChallengeID != "" { authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID) } - if authURL != "" { - pendingAuthRequestsMu.Lock() - pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ - ExtensionID: r.extensionID, - AuthURL: authURL, - CallbackURL: config.CallbackURL, - CreatedAt: time.Now(), - } - pendingAuthRequestsMu.Unlock() + if authURL == "" { + return "", fmt.Errorf("signed-session bootstrap did not return a session or verification challenge") } - return authURL + pendingAuthRequestsMu.Lock() + pendingAuthRequests[r.extensionID] = &PendingAuthRequest{ + ExtensionID: r.extensionID, + AuthURL: authURL, + CallbackURL: config.CallbackURL, + CreatedAt: time.Now(), + } + pendingAuthRequestsMu.Unlock() + return authURL, nil } func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string { diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index f5427190..9577661a 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -245,6 +245,60 @@ func TestPreflightSignedSession(t *testing.T) { t.Fatalf("bootstrapped session = %#v error:%v", record, err) } }) + + t.Run("retries a transport failure once and surfaces the cause", func(t *testing.T) { + calls := 0 + runtime := newSignedSessionTestRuntime(t, "preflight-network", roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable") + })) + runtime.manifest.SignedSession = &SignedSessionConfig{ + Namespace: "preflight-network", + BaseURL: "https://auth.example.com", + } + + verificationRequired, err := runtime.preflightSignedSession() + if err == nil || verificationRequired { + t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err) + } + if calls != 2 { + t.Fatalf("bootstrap calls = %d, want one initial attempt and one retry", calls) + } + if !strings.Contains(err.Error(), "network request") || !strings.Contains(err.Error(), "Wi-Fi route unavailable") { + t.Fatalf("bootstrap error did not preserve the transport cause: %v", err) + } + if strings.Contains(err.Error(), "install_id=") { + t.Fatalf("bootstrap error leaked the install identifier: %v", err) + } + }) + + t.Run("surfaces HTTP status and retry-after without inventing a challenge", func(t *testing.T) { + calls := 0 + runtime := newSignedSessionTestRuntime(t, "preflight-rate-limit", roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"17"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"limited"}`)), + Request: req, + }, nil + })) + runtime.manifest.SignedSession = &SignedSessionConfig{ + Namespace: "preflight-rate-limit", + BaseURL: "https://auth.example.com", + } + + verificationRequired, err := runtime.preflightSignedSession() + if err == nil || verificationRequired { + t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err) + } + if calls != 1 { + t.Fatalf("rate-limited bootstrap calls = %d, want 1", calls) + } + if !strings.Contains(err.Error(), "HTTP 429") || !strings.Contains(err.Error(), "retry-after seconds: 17") { + t.Fatalf("rate-limit details missing from bootstrap error: %v", err) + } + }) } func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) { @@ -316,6 +370,71 @@ func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) } } +func TestDownloadWithExtensionsStopsAfterFailedSignedSessionPreflight(t *testing.T) { + extensionID := "preflight-network-failure" + itemID := "preflight-network-item" + RemoveItemProgress(itemID) + t.Cleanup(func() { RemoveItemProgress(itemID) }) + + calls := 0 + runtime := newSignedSessionTestRuntime(t, extensionID, roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("dial tcp: Wi-Fi route unavailable") + })) + manifest := &ExtensionManifest{ + Name: extensionID, + Types: []ExtensionType{ExtensionTypeDownloadProvider}, + SignedSession: &SignedSessionConfig{ + Namespace: extensionID, + BaseURL: "https://auth.example.com", + }, + } + runtime.manifest = manifest + ext := &loadedExtension{ + ID: extensionID, + Manifest: manifest, + VM: runtime.vm, + runtime: runtime, + initialized: true, + Enabled: true, + DataDir: runtime.dataDir, + } + + manager := getExtensionManager() + manager.mu.Lock() + previous, hadPrevious := manager.extensions[extensionID] + manager.extensions[extensionID] = ext + manager.mu.Unlock() + t.Cleanup(func() { + manager.mu.Lock() + if hadPrevious { + manager.extensions[extensionID] = previous + } else { + delete(manager.extensions, extensionID) + } + manager.mu.Unlock() + }) + + requestJSON := `{"service":"preflight-network-failure","item_id":"preflight-network-item","isrc":"USRC17607839"}` + responseJSON, err := DownloadWithExtensionsJSON(requestJSON) + if err != nil { + t.Fatalf("DownloadWithExtensionsJSON: %v", err) + } + var response DownloadResponse + if err := json.Unmarshal([]byte(responseJSON), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.ErrorType != "network" || response.Service != extensionID || !strings.Contains(response.Error, "Could not start verification") { + t.Fatalf("response = %#v", response) + } + if calls != 2 { + t.Fatalf("bootstrap calls = %d, want only the initial attempt and its transport retry", calls) + } + if got := GetItemProgress(itemID); got != "{}" { + t.Fatalf("failed preflight left stale progress: %s", got) + } +} + func TestSignedSessionURL(t *testing.T) { base := SignedSessionConfig{BaseURL: "https://auth.example.com/api"} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 75d8178d..ddda39e7 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/download_verification_retry_guard.dart'; import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -173,7 +174,8 @@ class DownloadQueueNotifier extends Notifier { int _lastNotifQueueCount = -1; final Set _locallyCancelledItemIds = {}; final Set _pausePendingItemIds = {}; - final Set _verificationRetriedItemIds = {}; + final DownloadVerificationRetryGuard _verificationRetryGuard = + DownloadVerificationRetryGuard(); final Map> _verificationFlowsByExtension = {}; final Set _rateLimitRetriedItemIds = {}; String? _activeNativeWorkerRunId; @@ -185,9 +187,6 @@ class DownloadQueueNotifier extends Notifier { // then computes and writes album gain/peak to every track in the album. final Map _albumRgData = {}; - String _verificationRetryKey(String itemId, String service) => - '$itemId::${service.trim().toLowerCase()}'; - double _normalizeProgressForUi(double value) { final clamped = value.clamp(0.0, 1.0).toDouble(); if (clamped <= 0) return 0; @@ -424,8 +423,7 @@ class DownloadQueueNotifier extends Notifier { final targetService = (verificationService ?? '').trim().isNotEmpty ? verificationService!.trim() : item.service; - final verificationRetryKey = _verificationRetryKey(item.id, targetService); - if (_verificationRetriedItemIds.contains(verificationRetryKey)) { + if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) { _log.e( 'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge', ); @@ -438,8 +436,6 @@ class DownloadQueueNotifier extends Notifier { _failedInSession++; return true; } - _verificationRetriedItemIds.add(verificationRetryKey); - _log.i( 'Download for ${item.track.name} requires verification; waiting for $targetService grant', ); @@ -457,6 +453,14 @@ class DownloadQueueNotifier extends Notifier { return true; } + // Only a completed grant consumes the automatic retry. If bootstrap or + // browser launch failed before a challenge opened, a later attempt must + // still be allowed after the network changes. + _verificationRetryGuard.recordVerificationResult( + item.id, + targetService, + granted: verified, + ); if (verified) { _log.i( 'Verification complete for $targetService; retrying ${item.track.name}', @@ -1586,9 +1590,7 @@ class DownloadQueueNotifier extends Notifier { }), ); _locallyCancelledItemIds.remove(id); - _verificationRetriedItemIds.removeWhere( - (retryKey) => retryKey == id || retryKey.startsWith('$id::'), - ); + _verificationRetryGuard.clearItem(id); _rateLimitRetriedItemIds.remove(id); // Purge stale ReplayGain entry for this track so a re-scan doesn't @@ -2295,10 +2297,7 @@ class DownloadQueueNotifier extends Notifier { final remainingIds = state.items.map((item) => item.id).toSet(); _locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id)); _pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id)); - _verificationRetriedItemIds.removeWhere((retryKey) { - final itemId = retryKey.split('::').first; - return !remainingIds.contains(itemId); - }); + _verificationRetryGuard.retainItems(remainingIds); _rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id)); } diff --git a/lib/providers/download_verification_retry_guard.dart b/lib/providers/download_verification_retry_guard.dart new file mode 100644 index 00000000..80a0ea40 --- /dev/null +++ b/lib/providers/download_verification_retry_guard.dart @@ -0,0 +1,32 @@ +class DownloadVerificationRetryGuard { + final Set _grantedRetryKeys = {}; + + bool hasRetriedAfterGrant(String itemId, String service) => + _grantedRetryKeys.contains(_key(itemId, service)); + + void recordVerificationResult( + String itemId, + String service, { + required bool granted, + }) { + if (granted) { + _grantedRetryKeys.add(_key(itemId, service)); + } + } + + void clearItem(String itemId) { + _grantedRetryKeys.removeWhere( + (retryKey) => retryKey == itemId || retryKey.startsWith('$itemId::'), + ); + } + + void retainItems(Set itemIds) { + _grantedRetryKeys.removeWhere((retryKey) { + final itemId = retryKey.split('::').first; + return !itemIds.contains(itemId); + }); + } + + String _key(String itemId, String service) => + '$itemId::${service.trim().toLowerCase()}'; +} diff --git a/test/download_verification_retry_guard_test.dart b/test/download_verification_retry_guard_test.dart new file mode 100644 index 00000000..70ade08f --- /dev/null +++ b/test/download_verification_retry_guard_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/download_verification_retry_guard.dart'; + +void main() { + group('DownloadVerificationRetryGuard', () { + test('failed challenge attempts do not consume the granted retry', () { + final guard = DownloadVerificationRetryGuard(); + + guard.recordVerificationResult('item-1', 'tidal-web', granted: false); + + expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse); + }); + + test('completed grants allow only one automatic retry per service', () { + final guard = DownloadVerificationRetryGuard(); + + guard.recordVerificationResult('item-1', ' TIDAL-WEB ', granted: true); + + expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isTrue); + expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse); + expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isFalse); + }); + + test('manual retry clears every service marker for an item', () { + final guard = DownloadVerificationRetryGuard() + ..recordVerificationResult('item-1', 'tidal-web', granted: true) + ..recordVerificationResult('item-1', 'qobuz-web', granted: true) + ..recordVerificationResult('item-2', 'tidal-web', granted: true); + + guard.clearItem('item-1'); + + expect(guard.hasRetriedAfterGrant('item-1', 'tidal-web'), isFalse); + expect(guard.hasRetriedAfterGrant('item-1', 'qobuz-web'), isFalse); + expect(guard.hasRetriedAfterGrant('item-2', 'tidal-web'), isTrue); + }); + + test('queue cleanup retains markers only for remaining items', () { + final guard = DownloadVerificationRetryGuard() + ..recordVerificationResult('removed', 'tidal-web', granted: true) + ..recordVerificationResult('remaining', 'tidal-web', granted: true); + + guard.retainItems({'remaining'}); + + expect(guard.hasRetriedAfterGrant('removed', 'tidal-web'), isFalse); + expect(guard.hasRetriedAfterGrant('remaining', 'tidal-web'), isTrue); + }); + }); +}