release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// fakeReconciler records what reconcileMissing decided to do, so the guards can
|
|
// be tested without a database. The guards are the whole point of this pass —
|
|
// they are what stands between an unmounted volume and the library disappearing
|
|
// from every mix — so they get tested directly rather than via integration.
|
|
type fakeReconciler struct {
|
|
rows []dbq.ListTrackPathsForReconcileRow
|
|
marked []pgtype.UUID
|
|
cleared []pgtype.UUID
|
|
listErr error
|
|
markErr error
|
|
clearErr error
|
|
// Change rows the reconcile pass asked the delta sync to emit.
|
|
loggedChanges []dbq.InsertLibraryChangesParams
|
|
}
|
|
|
|
func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) {
|
|
return f.rows, f.listErr
|
|
}
|
|
|
|
func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
|
|
if f.markErr != nil {
|
|
return 0, f.markErr
|
|
}
|
|
f.marked = append(f.marked, ids...)
|
|
return int64(len(ids)), nil
|
|
}
|
|
|
|
func (f *fakeReconciler) InsertLibraryChanges(
|
|
_ context.Context, arg dbq.InsertLibraryChangesParams,
|
|
) error {
|
|
f.loggedChanges = append(f.loggedChanges, arg)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) {
|
|
if f.clearErr != nil {
|
|
return 0, f.clearErr
|
|
}
|
|
f.cleared = append(f.cleared, ids...)
|
|
return int64(len(ids)), nil
|
|
}
|
|
|
|
// Compile-time proof the real queries still satisfy what reconcile needs — the
|
|
// interface exists to narrow dbq.Queries, not to diverge from it.
|
|
var _ trackReconciler = (*dbq.Queries)(nil)
|
|
|
|
func testUUID(n byte) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
u.Bytes[15] = n
|
|
u.Valid = true
|
|
return u
|
|
}
|
|
|
|
func markedAt() pgtype.Timestamptz {
|
|
return pgtype.Timestamptz{Valid: true}
|
|
}
|
|
|
|
func row(n byte, path string, missing bool) dbq.ListTrackPathsForReconcileRow {
|
|
r := dbq.ListTrackPathsForReconcileRow{ID: testUUID(n), FilePath: path}
|
|
if missing {
|
|
r.MissingSince = markedAt()
|
|
}
|
|
return r
|
|
}
|
|
|
|
// populatedRoot returns a directory containing one file, so verifyRootsPresent
|
|
// treats it as a real, mounted library root.
|
|
func populatedRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "a.mp3"), []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func testScanner(t *testing.T, roots ...string) *Scanner {
|
|
t.Helper()
|
|
return &Scanner{
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
paths: roots,
|
|
}
|
|
}
|
|
|
|
func TestReconcileMissing_MarksRowsTheWalkDidNotSee(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
|
|
// 10 rows with 2 absent — 20%, deliberately under missingMarkMaxFraction so
|
|
// this exercises marking rather than the cap. (An earlier version of this
|
|
// test used 2-of-4 and was really testing the guard by accident.)
|
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
|
|
seen := map[string]struct{}{}
|
|
for i := 0; i < 10; i++ {
|
|
p := fmt.Sprintf("/music/track-%02d.mp3", i)
|
|
rows = append(rows, row(byte(i), p, false))
|
|
if i >= 2 {
|
|
seen[p] = struct{}{}
|
|
}
|
|
}
|
|
q := &fakeReconciler{rows: rows}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if len(q.marked) != 2 {
|
|
t.Fatalf("marked %d rows, want 2", len(q.marked))
|
|
}
|
|
if q.marked[0] != testUUID(0) || q.marked[1] != testUUID(1) {
|
|
t.Errorf("marked the wrong rows: %v", q.marked)
|
|
}
|
|
if stats.Missing != 2 {
|
|
t.Errorf("stats.Missing = %d, want 2", stats.Missing)
|
|
}
|
|
if len(q.cleared) != 0 {
|
|
t.Errorf("cleared %d rows, want 0", len(q.cleared))
|
|
}
|
|
}
|
|
|
|
func TestReconcileMissing_ClearsRowsWhoseFileReturned(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/back.mp3", true),
|
|
row(2, "/music/still-here.mp3", false),
|
|
}}
|
|
seen := map[string]struct{}{
|
|
"/music/back.mp3": {},
|
|
"/music/still-here.mp3": {},
|
|
}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if len(q.cleared) != 1 || q.cleared[0] != testUUID(1) {
|
|
t.Fatalf("cleared = %v, want just row 1", q.cleared)
|
|
}
|
|
if stats.Restored != 1 {
|
|
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
|
|
}
|
|
if len(q.marked) != 0 {
|
|
t.Errorf("marked %d rows, want 0", len(q.marked))
|
|
}
|
|
}
|
|
|
|
// An already-marked row must not be re-marked: the timestamp is the "how long
|
|
// has this been gone" clock that any future cleanup policy depends on.
|
|
func TestReconcileMissing_DoesNotRemarkAlreadyMissingRows(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/long-gone.mp3", true),
|
|
row(2, "/music/present.mp3", false),
|
|
}}
|
|
seen := map[string]struct{}{"/music/present.mp3": {}}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if len(q.marked) != 0 {
|
|
t.Errorf("re-marked an already-missing row: %v", q.marked)
|
|
}
|
|
if len(q.cleared) != 0 {
|
|
t.Errorf("cleared = %v, want none", q.cleared)
|
|
}
|
|
}
|
|
|
|
// The guard that matters most. A half-attached mount makes the walk succeed
|
|
// while seeing only part of the library — evidence indistinguishable from a
|
|
// mass deletion, so reconcile must refuse rather than guess.
|
|
func TestReconcileMissing_RefusesWhenTooMuchWouldBeMarked(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 100)
|
|
seen := map[string]struct{}{}
|
|
for i := 0; i < 100; i++ {
|
|
p := fmt.Sprintf("/music/track-%03d.mp3", i)
|
|
rows = append(rows, row(byte(i), p, false))
|
|
// Only 60 of 100 present -> 40% would be marked, over the 25% cap.
|
|
if i < 60 {
|
|
seen[p] = struct{}{}
|
|
}
|
|
}
|
|
q := &fakeReconciler{rows: rows}
|
|
|
|
var stats Stats
|
|
err := s.reconcileMissing(context.Background(), q, seen, &stats)
|
|
if err == nil {
|
|
t.Fatal("expected reconcile to refuse, got nil error")
|
|
}
|
|
if len(q.marked) != 0 {
|
|
t.Errorf("marked %d rows despite refusing", len(q.marked))
|
|
}
|
|
if stats.Missing != 0 {
|
|
t.Errorf("stats.Missing = %d, want 0", stats.Missing)
|
|
}
|
|
}
|
|
|
|
// Restoring is never the dangerous direction, so it must survive the cap —
|
|
// otherwise a library that tripped the cap once could never clear its marks
|
|
// even after the volume came back.
|
|
func TestReconcileMissing_ClearsEvenWhenMarkCapTrips(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
rows := []dbq.ListTrackPathsForReconcileRow{row(1, "/music/back.mp3", true)}
|
|
seen := map[string]struct{}{"/music/back.mp3": {}}
|
|
// Add enough absent rows to blow the cap.
|
|
for i := 2; i < 10; i++ {
|
|
rows = append(rows, row(byte(i), fmt.Sprintf("/music/absent-%02d.mp3", i), false))
|
|
}
|
|
q := &fakeReconciler{rows: rows}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err == nil {
|
|
t.Fatal("expected the mark cap to trip")
|
|
}
|
|
if len(q.cleared) != 1 {
|
|
t.Errorf("cleared %d rows, want 1 — restores must not be blocked by the cap", len(q.cleared))
|
|
}
|
|
if stats.Restored != 1 {
|
|
t.Errorf("stats.Restored = %d, want 1", stats.Restored)
|
|
}
|
|
}
|
|
|
|
func TestReconcileMissing_RefusesOnEmptyWalk(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/a.mp3", false),
|
|
}}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{}, &stats); err == nil {
|
|
t.Fatal("expected refusal when the walk saw no files")
|
|
}
|
|
if len(q.marked) != 0 {
|
|
t.Errorf("marked rows on an empty walk: %v", q.marked)
|
|
}
|
|
}
|
|
|
|
// The unmounted-volume case: the configured root doesn't exist at all.
|
|
func TestReconcileMissing_RefusesWhenRootMissing(t *testing.T) {
|
|
s := testScanner(t, filepath.Join(t.TempDir(), "not-mounted"))
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/a.mp3", false),
|
|
}}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
|
t.Fatal("expected refusal when a scan root is absent")
|
|
}
|
|
if len(q.marked) != 0 {
|
|
t.Errorf("marked rows with an absent root: %v", q.marked)
|
|
}
|
|
}
|
|
|
|
// A mount point that exists but has nothing mounted on it: os.Stat succeeds on
|
|
// the bare directory, which is why emptiness is checked separately.
|
|
func TestReconcileMissing_RefusesWhenRootEmpty(t *testing.T) {
|
|
s := testScanner(t, t.TempDir())
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/a.mp3", false),
|
|
}}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
|
t.Fatal("expected refusal when a scan root is empty")
|
|
}
|
|
}
|
|
|
|
// Several roots, one detached. Marking must not proceed on partial evidence
|
|
// just because the other roots looked fine.
|
|
func TestReconcileMissing_RefusesWhenAnyRootMissing(t *testing.T) {
|
|
good := populatedRoot(t)
|
|
s := testScanner(t, good, filepath.Join(t.TempDir(), "detached"))
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/a.mp3", false),
|
|
}}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err == nil {
|
|
t.Fatal("expected refusal when one of several roots is absent")
|
|
}
|
|
}
|
|
|
|
func TestReconcileMissing_NoRowsIsNotAnError(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
q := &fakeReconciler{}
|
|
|
|
var stats Stats
|
|
if err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats); err != nil {
|
|
t.Fatalf("empty library should reconcile cleanly, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReconcileMissing_PropagatesListError(t *testing.T) {
|
|
root := populatedRoot(t)
|
|
s := testScanner(t, root)
|
|
sentinel := errors.New("boom")
|
|
q := &fakeReconciler{listErr: sentinel}
|
|
|
|
var stats Stats
|
|
err := s.reconcileMissing(context.Background(), q, map[string]struct{}{"/x": {}}, &stats)
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("err = %v, want it to wrap %v", err, sentinel)
|
|
}
|
|
}
|
|
|
|
func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) {
|
|
s := testScanner(t)
|
|
if err := s.verifyRootsPresent(); err == nil {
|
|
t.Fatal("expected an error with no scan roots configured")
|
|
}
|
|
}
|
|
|
|
// Without a change row the mark is invisible to every client:
|
|
// /api/library/sync is a change-log feed, so a plain UPDATE never reaches
|
|
// anyone, and the wire field would exist with nothing ever telling a client to
|
|
// re-read the track.
|
|
func TestReconcileMissing_MarkingEmitsSyncChanges(t *testing.T) {
|
|
s := testScanner(t, populatedRoot(t))
|
|
|
|
// 10 rows, 2 absent — under the cap, so this exercises marking.
|
|
rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10)
|
|
seen := map[string]struct{}{}
|
|
for i := 0; i < 10; i++ {
|
|
path := fmt.Sprintf("/music/track-%02d.mp3", i)
|
|
rows = append(rows, row(byte(i), path, false))
|
|
if i >= 2 {
|
|
seen[path] = struct{}{}
|
|
}
|
|
}
|
|
q := &fakeReconciler{rows: rows}
|
|
var stats Stats
|
|
|
|
if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
if len(q.loggedChanges) != 1 {
|
|
t.Fatalf("want one batch of change rows, got %d", len(q.loggedChanges))
|
|
}
|
|
got := q.loggedChanges[0]
|
|
if got.EntityType != "track" {
|
|
t.Errorf("entity_type = %q, want track", got.EntityType)
|
|
}
|
|
// Upsert, not delete: the track still exists and keeps its history. A
|
|
// delete would tell clients to drop the row, and a missing file is
|
|
// expected to come back.
|
|
if got.Op != "upsert" {
|
|
t.Errorf("op = %q, want upsert — the row survives, only its state changed", got.Op)
|
|
}
|
|
if len(got.EntityIds) != 2 {
|
|
t.Errorf("want both missing tracks logged, got %d ids", len(got.EntityIds))
|
|
}
|
|
}
|
|
|
|
// A file coming back must reach clients too, or a restored track stays
|
|
// greyed out on every device until something unrelated touches it.
|
|
func TestReconcileMissing_RestoringEmitsSyncChanges(t *testing.T) {
|
|
s := testScanner(t, populatedRoot(t))
|
|
q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{
|
|
row(1, "/music/back.mp3", true),
|
|
}}
|
|
var stats Stats
|
|
|
|
if err := s.reconcileMissing(
|
|
context.Background(), q, map[string]struct{}{"/music/back.mp3": {}}, &stats,
|
|
); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
|
|
if len(q.loggedChanges) != 1 {
|
|
t.Fatalf("a restored file must produce a change row, got %d batches", len(q.loggedChanges))
|
|
}
|
|
if q.loggedChanges[0].Op != "upsert" {
|
|
t.Errorf("op = %q, want upsert", q.loggedChanges[0].Op)
|
|
}
|
|
}
|