Files

190 lines
7.3 KiB
Go
Raw Permalink Normal View History

package tasks
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/metadata"
)
type fakeSettingsStore struct {
values map[string]string
getErr error
}
func (f *fakeSettingsStore) Get(_ context.Context, key string) (string, error) {
if f.getErr != nil {
return "", f.getErr
}
return f.values[key], nil
}
func (f *fakeSettingsStore) Set(_ context.Context, key, value string) error {
if f.values == nil {
f.values = map[string]string{}
}
f.values[key] = value
return nil
}
type fakeReconcileRunner struct {
stats metadata.ArtworkReconcileStats
err error
runs int
}
func (f *fakeReconcileRunner) Run(context.Context, func(float64, string)) (metadata.ArtworkReconcileStats, error) {
f.runs++
return f.stats, f.err
}
type fakeBrandingReconciler struct {
checked int
cleared int
err error
}
func (f *fakeBrandingReconciler) ReconcileMissingAssets(context.Context) (int, int, error) {
return f.checked, f.cleared, f.err
}
type fakeProgress struct {
lastMessage string
resultData json.RawMessage
}
func (f *fakeProgress) Report(_ float64, message string) { f.lastMessage = message }
func (f *fakeProgress) SetResultData(data json.RawMessage) { f.resultData = data }
func TestArtworkStorageIdentityNormalizes(t *testing.T) {
// Endpoint and bucket are case-insensitive; whitespace is trimmed.
a := ArtworkStorageIdentity(" https://S3.Example.com ", "Assets", "silo/prod")
b := ArtworkStorageIdentity("https://s3.example.com", "assets", "silo/prod")
if a != b {
t.Fatalf("identity not normalized: %q != %q", a, b)
}
// The key prefix is slash-insensitive (the s3client trims slashes, so
// 'art' and '/art/' are the same storage location)...
if ArtworkStorageIdentity("e", "b", "art") != ArtworkStorageIdentity("e", "b", " /art/ ") {
t.Fatal("slash-only prefix differences must not change the identity")
}
// ...but case-SENSITIVE: S3 object keys are case-sensitive, so a
// case-only prefix edit is a real storage move and must reconcile.
if ArtworkStorageIdentity("e", "b", "Art") == ArtworkStorageIdentity("e", "b", "art") {
t.Fatal("case-only prefix differences are real storage moves and must change the identity")
}
if a == ArtworkStorageIdentity("https://s3.example.com", "assets", "") {
t.Fatal("key prefix must participate in the identity")
}
if a == ArtworkStorageIdentity("https://other.example.com", "assets", "silo/prod") {
t.Fatal("endpoint must participate in the identity")
}
}
func TestReconcileArtworkCacheShouldRun(t *testing.T) {
runner := &fakeReconcileRunner{}
store := &fakeSettingsStore{values: map[string]string{}}
task := NewReconcileArtworkCacheTask(runner, store, nil, "endpoint|bucket|prefix")
// No stored fingerprint: first boot, seeding happens at wiring time; the
// scheduled run must not sweep a catalog it has no baseline for.
if run, err := task.ShouldRun(context.Background()); err != nil || run {
t.Fatalf("ShouldRun with empty fingerprint = %v, %v; want false, nil", run, err)
}
store.values[ArtworkStorageIdentityKey] = "endpoint|bucket|prefix"
if run, err := task.ShouldRun(context.Background()); err != nil || run {
t.Fatalf("ShouldRun with matching fingerprint = %v, %v; want false, nil", run, err)
}
store.values[ArtworkStorageIdentityKey] = "old-endpoint|bucket|prefix"
if run, err := task.ShouldRun(context.Background()); err != nil || !run {
t.Fatalf("ShouldRun with changed fingerprint = %v, %v; want true, nil", run, err)
}
}
func TestReconcileArtworkCacheExecutePersistsFingerprintOnlyOnSuccess(t *testing.T) {
store := &fakeSettingsStore{values: map[string]string{ArtworkStorageIdentityKey: "old"}}
failing := &fakeReconcileRunner{err: errors.New("storage unreachable")}
task := NewReconcileArtworkCacheTask(failing, store, nil, "new")
if err := task.Execute(context.Background(), &fakeProgress{}); err == nil {
t.Fatal("Execute with failing runner returned nil error")
}
if got := store.values[ArtworkStorageIdentityKey]; got != "old" {
t.Fatalf("fingerprint after failed run = %q, want unchanged %q", got, "old")
}
ok := &fakeReconcileRunner{stats: metadata.ArtworkReconcileStats{Mode: "verify", Verified: 3, Requeued: 2, Cleared: 1}}
task = NewReconcileArtworkCacheTask(ok, store, nil, "new")
progress := &fakeProgress{}
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute = %v, want nil", err)
}
if got := store.values[ArtworkStorageIdentityKey]; got != "new" {
t.Fatalf("fingerprint after successful run = %q, want %q", got, "new")
}
if progress.resultData == nil {
t.Fatal("Execute did not record result data")
}
}
func TestReconcileArtworkCacheExecuteDoesNotCertifyOnSweepErrors(t *testing.T) {
// Rows skipped on storage errors were never verified, so the sweep did
// not fully cover the catalog: the fingerprint must stay stale so the
// next startup retries.
store := &fakeSettingsStore{values: map[string]string{ArtworkStorageIdentityKey: "old"}}
runner := &fakeReconcileRunner{stats: metadata.ArtworkReconcileStats{
Mode: "verify", Verified: 10, Errors: 3, SweepErrors: 3,
}}
branding := &fakeBrandingReconciler{checked: 4}
task := NewReconcileArtworkCacheTask(runner, store, branding, "new")
if err := task.Execute(context.Background(), &fakeProgress{}); err == nil {
t.Fatal("Execute with sweep errors returned nil error")
}
if got := store.values[ArtworkStorageIdentityKey]; got != "old" {
t.Fatalf("fingerprint after sweep errors = %q, want unchanged %q", got, "old")
}
}
func TestReconcileArtworkCacheExecuteIncludesBranding(t *testing.T) {
store := &fakeSettingsStore{values: map[string]string{}}
runner := &fakeReconcileRunner{stats: metadata.ArtworkReconcileStats{Mode: "verify", Cleared: 1}}
task := NewReconcileArtworkCacheTask(runner, store, &fakeBrandingReconciler{checked: 4, cleared: 2}, "id")
progress := &fakeProgress{}
if err := task.Execute(context.Background(), progress); err != nil {
t.Fatalf("Execute = %v, want nil", err)
}
var stats metadata.ArtworkReconcileStats
if err := json.Unmarshal(progress.resultData, &stats); err != nil {
t.Fatalf("decode result data: %v", err)
}
if stats.Cleared != 3 {
t.Fatalf("Cleared = %d, want 3 (1 artwork + 2 branding)", stats.Cleared)
}
if stats.Checked != 4 {
t.Fatalf("Checked = %d, want 4 (all probed branding assets, not just cleared ones)", stats.Checked)
}
// A branding failure must NOT discard the completed sweep: the
// fingerprint is certified first and the failure is reported in the
// message instead, so the full catalog sweep never repeats over a
// transient error on a 4-object branding check.
fpStore := &fakeSettingsStore{values: map[string]string{}}
failing := NewReconcileArtworkCacheTask(runner, fpStore,
&fakeBrandingReconciler{err: errors.New("storage unreachable")}, "id")
failingProgress := &fakeProgress{}
if err := failing.Execute(context.Background(), failingProgress); err != nil {
t.Fatalf("Execute with failing branding reconcile = %v, want nil (non-fatal)", err)
}
if got := fpStore.values[ArtworkStorageIdentityKey]; got != "id" {
t.Fatalf("fingerprint after branding failure = %q, want certified %q", got, "id")
}
if !strings.Contains(failingProgress.lastMessage, "branding asset check failed") {
t.Fatalf("completion message %q does not surface the branding failure", failingProgress.lastMessage)
}
}