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()) } }