Files
bvandeusenandClaude Opus 5.5 2365514e5b
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m22s
release / Verify release artifacts (tag releases only) (push) Skipped
test: TestPlanRoots looks for the empty-list refusal as the message words it (#6129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:40:54 -04:00

149 lines
4.8 KiB
Go

package library
import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func mkdir(t *testing.T, parts ...string) string {
t.Helper()
p := filepath.Join(parts...)
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
return p
}
func TestPlanRoots(t *testing.T) {
base := t.TempDir()
music := mkdir(t, base, "music")
data := mkdir(t, base, "data", "music")
more := mkdir(t, base, "more")
file := filepath.Join(base, "a.mp3")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
current := []string{music, more}
roots, moves, err := planRoots(current, []RootChange{{Path: data + "/", Was: music}, {Path: more, Was: more}})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(roots, []string{data, more}) {
t.Errorf("roots %v", roots)
}
if len(moves) != 1 || moves[0] != (RootChange{Path: data, Was: music}) {
t.Errorf("moves %v; want only %s → %s", moves, music, data)
}
for name, tc := range map[string]struct {
changes []RootChange
mentions string
}{
"none": {nil, "at least one"},
"relative": {[]RootChange{{Path: "music"}}, "full path"},
"root": {[]RootChange{{Path: "/"}}, "whole filesystem"},
"absent": {[]RootChange{{Path: filepath.Join(base, "nope")}}, "does not exist"},
"a file": {[]RootChange{{Path: file}}, "not a folder"},
"twice": {[]RootChange{{Path: music}, {Path: music}}, "twice"},
"nested": {[]RootChange{{Path: base}, {Path: music}}, "overlap"},
"unknown was": {[]RootChange{{Path: data, Was: "/elsewhere"}}, "not one of"},
"split a move": {[]RootChange{{Path: data, Was: music}, {Path: more, Was: music}}, "one place"},
} {
_, _, err := planRoots(current, tc.changes)
var re *RootsError
if !errors.As(err, &re) || !strings.Contains(err.Error(), tc.mentions) {
t.Errorf("%s: err %v; want a RootsError mentioning %q", name, err, tc.mentions)
}
}
}
// clearSavedRoots puts the folders back to env afterwards. It also finishes any
// scan an earlier test left running: dbtest.ResetDB keeps scan_runs, and a
// save refuses while a scan is in flight.
func clearSavedRoots(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`UPDATE scan_runs SET finished_at = now() WHERE finished_at IS NULL`); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `UPDATE library_settings SET scan_paths = NULL`)
})
}
func TestSetRoots_TracksMoveWithTheirFolder_Integration(t *testing.T) {
pool := newPool(t)
clearSavedRoots(t, pool)
ctx := context.Background()
base := t.TempDir()
old := filepath.Join(base, "music") // gone: the mount moved
moved := mkdir(t, base, "data", "music", "Artist")
if err := os.WriteFile(filepath.Join(moved, "a.mp3"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
newRoot := filepath.Dir(moved)
track, _, _ := seedTrack(t, pool, filepath.Join(old, "Artist", "a.mp3"))
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
res, err := s.SetRoots(ctx, []RootChange{{Path: newRoot, Was: old}})
if err != nil {
t.Fatal(err)
}
if res.Tracks != 1 {
t.Errorf("moved %d tracks; want 1", res.Tracks)
}
got, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(newRoot, "Artist", "a.mp3"))
if err != nil {
t.Fatalf("the track is not at its new path: %v", err)
}
if got.ID != track.ID {
t.Error("the move made a new track instead of keeping the old one")
}
if !slices.Equal(s.Roots(), []string{newRoot}) {
t.Errorf("roots %v", s.Roots())
}
select {
case <-s.RootsChanged():
default:
t.Error("the watcher was not told the folders changed")
}
roots, saved, err := LoadRoots(ctx, pool, []string{old})
if err != nil || !saved || !slices.Equal(roots, []string{newRoot}) {
t.Errorf("after a restart: %v saved=%v err=%v; want the saved folder over env", roots, saved, err)
}
}
func TestSetRoots_RefusesAMoveToAFolderWithoutTheFiles_Integration(t *testing.T) {
pool := newPool(t)
clearSavedRoots(t, pool)
ctx := context.Background()
base := t.TempDir()
old := filepath.Join(base, "music")
wrong := mkdir(t, base, "elsewhere")
seedTrack(t, pool, filepath.Join(old, "a.mp3"))
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
var re *RootsError
if _, err := s.SetRoots(ctx, []RootChange{{Path: wrong, Was: old}}); !errors.As(err, &re) {
t.Fatalf("err %v; want a RootsError", err)
}
if _, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(old, "a.mp3")); err != nil {
t.Errorf("a refused move rewrote the track: %v", err)
}
if !slices.Equal(s.Roots(), []string{old}) {
t.Errorf("a refused move changed the folders to %v", s.Roots())
}
}