package orphan import ( "context" "errors" "sort" "sync" "testing" "time" "yovision/sense/internal/store" ) type fakeLease struct { owner, token string expires time.Time } type fakeRepository struct { mu sync.Mutex leases map[string]fakeLease ownership []store.MediaPathOwnership scans map[string]store.OrphanScan deleted map[string]map[string]bool } func newFakeRepository() *fakeRepository { return &fakeRepository{ leases: make(map[string]fakeLease), scans: make(map[string]store.OrphanScan), deleted: make(map[string]map[string]bool), } } func (f *fakeRepository) AcquireOperationalLease( _ context.Context, name, owner, token string, now time.Time, duration time.Duration, ) (bool, error) { f.mu.Lock() defer f.mu.Unlock() current, exists := f.leases[name] if exists && current.expires.After(now) && (current.owner != owner || current.token != token) { return false, nil } f.leases[name] = fakeLease{owner: owner, token: token, expires: now.Add(duration)} return true, nil } func (f *fakeRepository) ReleaseOperationalLease( _ context.Context, name, owner, token string, now time.Time, ) error { f.mu.Lock() defer f.mu.Unlock() current := f.leases[name] if current.owner == owner && current.token == token { current.expires = now f.leases[name] = current } return nil } func (f *fakeRepository) ListMediaPathOwnership(context.Context) ([]store.MediaPathOwnership, error) { f.mu.Lock() defer f.mu.Unlock() return append([]store.MediaPathOwnership(nil), f.ownership...), nil } func (f *fakeRepository) SaveOrphanScan( _ context.Context, scan store.OrphanScan, owner, token string, ) error { f.mu.Lock() defer f.mu.Unlock() lease := f.leases[store.OperationalLeaseOrphanScan] if lease.owner != owner || lease.token != token || !lease.expires.After(scan.CompletedAt) { return store.ErrOperationalLeaseLost } f.scans[scan.ID] = cloneScan(scan) lease.expires = scan.CompletedAt f.leases[store.OperationalLeaseOrphanScan] = lease return nil } func (f *fakeRepository) GetOrphanScan(_ context.Context, id string) (store.OrphanScan, error) { f.mu.Lock() defer f.mu.Unlock() scan, exists := f.scans[id] if !exists { return store.OrphanScan{}, store.ErrOrphanScanNotFound } result := cloneScan(scan) for index := range result.Findings { result.Findings[index].Deleted = f.deleted[id][result.Findings[index].PathName] } return result, nil } func (f *fakeRepository) RecordOrphanCleanup( _ context.Context, scanID, pathName, _, status, _ string, _ time.Time, ) error { f.mu.Lock() defer f.mu.Unlock() if f.deleted[scanID] == nil { f.deleted[scanID] = make(map[string]bool) } if status == "deleted" { f.deleted[scanID][pathName] = true } return nil } func cloneScan(value store.OrphanScan) store.OrphanScan { value.Findings = append([]store.OrphanFinding(nil), value.Findings...) return value } type fakeRuntime struct { paths map[string]bool fail map[string]bool deleted []string } func (f *fakeRuntime) ListPathNames(context.Context) ([]string, error) { values := make([]string, 0, len(f.paths)) for path, present := range f.paths { if present { values = append(values, path) } } sort.Strings(values) return values, nil } func (f *fakeRuntime) DeletePath(_ context.Context, path string) error { if f.fail[path] { return errors.New("redacted media failure") } delete(f.paths, path) f.deleted = append(f.deleted, path) return nil } func TestSafetyGateUsesExactTenPercentBoundary(t *testing.T) { t.Parallel() if allowed, reason := safetyGate(1, 10); !allowed || reason != "allowed" { t.Fatalf("exact 10%% boundary was rejected: %v %s", allowed, reason) } if allowed, reason := safetyGate(1, 9); allowed || reason != "ratio_exceeded" { t.Fatalf("more than 10%% was accepted: %v %s", allowed, reason) } if allowed, reason := safetyGate(129, 2000); allowed || reason != "scope_exceeded" { t.Fatalf("129-item scope was accepted: %v %s", allowed, reason) } } func TestReportClassifiesOwnershipAndApplyNeverDeletesUnowned(t *testing.T) { repository := newFakeRepository() repository.ownership = []store.MediaPathOwnership{ {PathName: "owned-stale", DeviceID: "old-device"}, {PathName: "owned-current", DeviceID: "live-device", CurrentClaim: true}, } runtime := &fakeRuntime{paths: map[string]bool{ "owned-stale": true, "owned-current": true, "unowned-1": true, "unowned-2": true, "unowned-3": true, "unowned-4": true, "unowned-5": true, "unowned-6": true, "unowned-7": true, "unowned-8": true, }, fail: make(map[string]bool)} now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) manager := New(repository, runtime, "ins_test", nil) manager.now = func() time.Time { return now } scan, err := manager.Report(context.Background()) if err != nil { t.Fatal(err) } if scan.ObservedCount != 10 || scan.OwnedStaleCount != 1 || scan.UnownedCount != 8 || !scan.SafetyAllowed { t.Fatalf("unexpected report: %+v", scan) } result, err := manager.Apply( context.Background(), scan.ID, "operator-1", Confirmation(scan.ID), ) if err != nil { t.Fatal(err) } if result.Deleted != 1 || len(runtime.deleted) != 1 || runtime.deleted[0] != "owned-stale" { t.Fatalf("cleanup escaped owned stale set: result=%+v deleted=%v", result, runtime.deleted) } for _, path := range []string{"owned-current", "unowned-1", "unowned-8"} { if !runtime.paths[path] { t.Fatalf("cleanup deleted protected path %q", path) } } } func TestApplyBlocksExpiredOrOversizedSnapshotBeforeDelete(t *testing.T) { repository := newFakeRepository() repository.ownership = []store.MediaPathOwnership{{PathName: "stale", DeviceID: "old"}} runtime := &fakeRuntime{paths: map[string]bool{"stale": true}, fail: make(map[string]bool)} now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) manager := New(repository, runtime, "ins_test", nil) manager.now = func() time.Time { return now } scan, err := manager.Report(context.Background()) if err != nil { t.Fatal(err) } if scan.SafetyAllowed || scan.SafetyReason != "ratio_exceeded" { t.Fatalf("single-path 100%% cleanup was not blocked: %+v", scan) } if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSafetyBlocked) { t.Fatalf("expected ratio gate, got %v", err) } if len(runtime.deleted) != 0 { t.Fatal("ratio-blocked cleanup mutated MediaMTX") } for index := 0; index < 9; index++ { runtime.paths[string(rune('a'+index))] = true } scan, err = manager.Report(context.Background()) if err != nil || !scan.SafetyAllowed { t.Fatalf("expected a fresh 1/10 executable report: %+v %v", scan, err) } now = now.Add(reportTTL) if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSnapshotExpired) { t.Fatalf("expected expired snapshot, got %v", err) } if len(runtime.deleted) != 0 { t.Fatal("expired cleanup mutated MediaMTX") } } func TestApplyIsRetryableAndCannotExpandBeyondSnapshot(t *testing.T) { repository := newFakeRepository() repository.ownership = []store.MediaPathOwnership{ {PathName: "stale-a", DeviceID: "old-a"}, {PathName: "stale-b", DeviceID: "old-b"}, } runtime := &fakeRuntime{paths: make(map[string]bool), fail: map[string]bool{"stale-b": true}} runtime.paths["stale-a"], runtime.paths["stale-b"] = true, true for index := 0; index < 18; index++ { runtime.paths[string(rune(0x100+index))] = true } now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) manager := New(repository, runtime, "ins_test", nil) manager.now = func() time.Time { return now } scan, err := manager.Report(context.Background()) if err != nil || !scan.SafetyAllowed { t.Fatalf("expected 2/20 report: %+v %v", scan, err) } repository.ownership = append(repository.ownership, store.MediaPathOwnership{PathName: "new-stale", DeviceID: "new-old"}) runtime.paths["new-stale"] = true result, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)) if err == nil || result.Deleted != 1 || result.Failed != 1 { t.Fatalf("expected one partial failure: %+v %v", result, err) } if !runtime.paths["new-stale"] { t.Fatal("cleanup expanded beyond the approved snapshot") } runtime.fail["stale-b"] = false result, err = manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)) if err != nil || result.Deleted != 1 || result.Failed != 0 { t.Fatalf("failed item was not retryable: %+v %v", result, err) } if !runtime.paths["new-stale"] { t.Fatal("retry expanded beyond the approved snapshot") } }