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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.8 KiB
Go
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())
|
|
}
|
|
}
|