package objstore import ( "context" "errors" "testing" "time" ) // scannerContract is run against every Scanner implementation. Idempotence is // the property under test: every method is called twice and the second call // must be a success, because the sweep re-runs after a half-failure at any // point and a walk that only works once is not re-runnable. func scannerContract(t *testing.T, s Scanner, bucket string) { t.Helper() ctx := context.Background() for i := 0; i < 2; i++ { if err := s.EnsureEnabled(ctx, bucket); err != nil { t.Fatalf("EnsureEnabled call %d: %v", i+1, err) } } // An object nobody has scanned has no verdict, and that is a state rather // than an error — the same idiom Get uses for an absent record. v, err := s.Verdict(ctx, bucket, "absent.txt", "etag-1") if err != nil { t.Fatalf("Verdict on an unscanned object: %v", err) } if v != VerdictUnknown { t.Fatalf("Verdict on an unscanned object = %q, want %q", v, VerdictUnknown) } for i := 0; i < 2; i++ { if _, _, err := s.Scan(ctx, bucket, "a.txt", "etag-1", 10); err != nil { t.Fatalf("Scan call %d: %v", i+1, err) } } // A verdict is bound to the bytes it judged. The same key at a different // etag has no verdict, so the sweep rescans rather than letting a // re-uploaded object inherit its predecessor's result. if v, err := s.Verdict(ctx, bucket, "a.txt", "etag-2"); err != nil || v != VerdictUnknown { t.Fatalf("Verdict at a changed etag = (%q, %v), want (%q, nil)", v, err, VerdictUnknown) } for i := 0; i < 2; i++ { if err := s.EnsureDisabled(ctx, bucket); err != nil { t.Fatalf("EnsureDisabled call %d: %v", i+1, err) } } } func TestMemScannerContract(t *testing.T) { scannerContract(t, NewMemScanner(), "carlos-store-tito-bab-titogo") } // Status and Verdict answer DIFFERENT QUESTIONS, and the fake has to hold that // line as firmly as the real engine does — otherwise the drain's own re-issue // test passes against a fake that already re-issued for it, and the split is // unpinned on the only implementation the queue tests ever see. // // Verdict applies the sweep's retry policy: a stored `failed` or `denied` // becomes Unknown so the next sweep re-issues it. Status reports what the tag // says, because "what is known about this object" is not "what should the // sweep do next" — left the other way, a failed scan reads as pending forever. func TestMemScannerStatusReportsWhatIsStored(t *testing.T) { ctx := context.Background() s := NewMemScanner() const bucket, key = "b", "uploads/x" mod := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC) // An object the scanner has never heard of is GONE, not unknown: the // drain deletes the item rather than queueing an engine read. if _, _, err := s.Status(ctx, bucket, key); !errors.Is(err, ErrObjectGone) { t.Fatalf("Status on an absent object = %v, want ErrObjectGone", err) } s.PutObject(bucket, key, "etag-1", mod, 10) v, obj, err := s.Status(ctx, bucket, key) if err != nil { t.Fatal(err) } if v != VerdictUnknown { t.Fatalf("an unscanned object = %q, want unknown", v) } // The object comes back so the caller can Scan it without a head of its // own — the whole reason Status returns one. if obj.ETag != "etag-1" || obj.Size != 10 || obj.Version() == "" { t.Fatalf("Status returned %+v, want the object a listing would have described", obj) } for _, stored := range []Verdict{VerdictFailed, VerdictDenied} { s.Put(bucket, key, obj.Version(), stored) if v, _, err := s.Status(ctx, bucket, key); err != nil || v != stored { t.Fatalf("Status over a stored %q = (%q, %v) — Status is applying the re-issue rule", stored, v, err) } if v, err := s.Verdict(ctx, bucket, key, obj.Version()); err != nil || v != VerdictUnknown { t.Fatalf("Verdict over a stored %q = (%q, %v), want unknown so the sweep re-issues it", stored, v, err) } } // A terminal verdict is reported by both, unchanged. s.Put(bucket, key, obj.Version(), VerdictInfected) if v, _, _ := s.Status(ctx, bucket, key); v != VerdictInfected { t.Fatalf("Status over infected = %q", v) } if v, _ := s.Verdict(ctx, bucket, key, obj.Version()); v != VerdictInfected { t.Fatalf("Verdict over infected = %q", v) } } // The seeded-verdict path gets the same etag treatment as the scanned one, so // a fixture cannot accidentally set up a verdict that outlives its bytes. func TestMemScannerSeededVerdictIsETagBound(t *testing.T) { ctx := context.Background() s := NewMemScanner() s.Put("b", "k", "etag-1", VerdictInfected) if v, _ := s.Verdict(ctx, "b", "k", "etag-1"); v != VerdictInfected { t.Fatalf("same etag = %q, want %q", v, VerdictInfected) } if v, _ := s.Verdict(ctx, "b", "k", "etag-2"); v != VerdictUnknown { t.Fatalf("changed etag = %q, want %q", v, VerdictUnknown) } } // The failure hook arms exactly one failure, so a walk test can prove the // sweep survives a mid-flight engine error and does not retry forever. func TestMemScannerFailureIsOneShot(t *testing.T) { ctx := context.Background() s := NewMemScanner() s.Fail("EnsureEnabled", context.DeadlineExceeded) if err := s.EnsureEnabled(ctx, "b"); err == nil { t.Fatal("armed failure did not fire") } if err := s.EnsureEnabled(ctx, "b"); err != nil { t.Fatalf("failure fired twice: %v", err) } }