package backup_test import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/backup" ) // library writes music files under a new library folder and adds a track for // each. It returns the folder. func library(t *testing.T, pool *pgxpool.Pool, files map[string]string) string { t.Helper() root := t.TempDir() artist := mustID(t, pool, `INSERT INTO artists (name, sort_name) VALUES ('Artist', 'artist') RETURNING id::text`) album := mustID(t, pool, `INSERT INTO albums (title, sort_title, artist_id) VALUES ('Album', 'album', $1) RETURNING id::text`, artist) for rel, body := range files { path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } mustExec(t, pool, `INSERT INTO tracks (title, album_id, artist_id, duration_ms, file_path, file_size, file_format) VALUES ($1, $2, $3, 1000, $4, $5, 'flac')`, rel, album, artist, path, len(body)) } return root } func runOnce(t *testing.T, svc *backup.Service) backup.Run { t.Helper() if err := svc.Start(context.Background(), backup.TriggerManual); err != nil { t.Fatal(err) } waitIdle(t, svc) st := status(t, svc) if len(st.Runs) == 0 || st.Runs[0].Status != "succeeded" { t.Fatalf("the run didn't succeed: %+v", st.Runs) } return st.Runs[0] } func TestTheMusicGoesToTheFolderOnceAndAgainOnlyWhenItChanges_Integration(t *testing.T) { svc, pool := newService(t) ctx := context.Background() lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"}) svc.Roots = func() []string { return []string{lib} } dest := t.TempDir() if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: dest, RunHour: 3, IncludeMusic: true}); err != nil { t.Fatal(err) } first := runOnce(t, svc) if first.MusicFiles != 2 || first.MusicBytes != int64(len("one")+len("second")) || first.MusicNote != "" { t.Errorf("the first run sent %d files, %d bytes, note %q", first.MusicFiles, first.MusicBytes, first.MusicNote) } got, err := os.ReadFile(filepath.Join(dest, "music", "B", "two.flac")) if err != nil || string(got) != "second" { t.Errorf("music/B/two.flac = %q, %v", got, err) } src, _ := os.Stat(filepath.Join(lib, "A", "one.flac")) dst, err := os.Stat(filepath.Join(dest, "music", "A", "one.flac")) // The database keeps microseconds, so the copy's time is the file's to the µs. if err != nil || !dst.ModTime().Equal(src.ModTime().Truncate(time.Microsecond)) { t.Errorf("the copy's time = %v, want the file's %v (%v)", dst, src.ModTime(), err) } if p := status(t, svc).Music; p == nil || p.TotalFiles != 2 || p.SentFiles != 2 || p.RefusedFiles != 0 { t.Errorf("progress = %+v, want 2 of 2", p) } if again := runOnce(t, svc); again.MusicFiles != 0 { t.Errorf("a second run sent %d files, want none", again.MusicFiles) } // A scan that touched the track but found the same file sends nothing. mustExec(t, pool, `UPDATE tracks SET updated_at = now()`) if touched := runOnce(t, svc); touched.MusicFiles != 0 { t.Errorf("after a scan touched the tracks the run sent %d files, want none", touched.MusicFiles) } if p := status(t, svc).Music; p == nil || p.SentFiles != 2 { t.Errorf("progress after the scan = %+v, want 2 sent", p) } // A changed file goes again, to the same place. path := filepath.Join(lib, "A", "one.flac") if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil { t.Fatal(err) } later := time.Now().Add(time.Minute) if err := os.Chtimes(path, later, later); err != nil { t.Fatal(err) } mustExec(t, pool, `UPDATE tracks SET file_size = $1 WHERE file_path = $2`, len("one, retagged"), path) if changed := runOnce(t, svc); changed.MusicFiles != 1 { t.Errorf("after one file changed the run sent %d files, want 1", changed.MusicFiles) } if got, _ := os.ReadFile(filepath.Join(dest, "music", "A", "one.flac")); string(got) != "one, retagged" { t.Errorf("music/A/one.flac = %q, want the changed file", got) } if entries, _ := os.ReadDir(filepath.Join(dest, "music", "A")); len(entries) != 1 { t.Errorf("music/A holds %d entries, want just the file", len(entries)) } } // lockedBucket is a fakeBucket that refuses to overwrite an object, like a // bucket with object lock. With all set it refuses every music object, like // a key that may write only under database/. type lockedBucket struct { *fakeBucket all *atomic.Bool } func (b lockedBucket) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/minstrel/music/") { b.mu.Lock() _, held := b.puts[r.URL.Path] b.mu.Unlock() if held || (b.all != nil && b.all.Load()) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`AccessDeniedobject is locked`)) return } } b.fakeBucket.ServeHTTP(w, r) } func TestTheMusicGoesToTheBucketAndARefusedOverwriteIsKept_Integration(t *testing.T) { svc, pool := newService(t) ctx := context.Background() fake := &fakeBucket{puts: map[string][]byte{}} srv := httptest.NewServer(lockedBucket{fakeBucket: fake}) defer srv.Close() lib := library(t, pool, map[string]string{"A/one.flac": "one", "B/two.flac": "second"}) svc.Roots = func() []string { return []string{lib} } u := bucketUpdate(srv.URL) u.IncludeMusic = true if err := svc.Save(ctx, u); err != nil { t.Fatal(err) } if first := runOnce(t, svc); first.MusicFiles != 2 { t.Fatalf("the first run sent %d files, want 2", first.MusicFiles) } fake.mu.Lock() body := string(fake.puts["/minstrel/music/A/one.flac"]) fake.mu.Unlock() if body != "one" { t.Errorf("music/A/one.flac in the bucket = %q", body) } path := filepath.Join(lib, "A", "one.flac") if err := os.WriteFile(path, []byte("one, retagged"), 0o644); err != nil { t.Fatal(err) } mustExec(t, pool, `UPDATE tracks SET file_size = $1, updated_at = now() WHERE file_path = $2`, len("one, retagged"), path) run := runOnce(t, svc) if run.MusicFiles != 0 || run.MusicNote != "" { t.Errorf("a refused overwrite: sent %d, note %q; want nothing sent and no note", run.MusicFiles, run.MusicNote) } p := status(t, svc).Music if p == nil || p.SentFiles != 1 || p.RefusedFiles != 1 || len(p.Refused) != 1 || p.Refused[0].Path != path || !strings.Contains(p.Refused[0].Error, "AccessDenied") { t.Errorf("progress = %+v, want one sent and the overwrite refused", p) } // Refused is settled until the file changes again. if again := runOnce(t, svc); again.MusicFiles != 0 { t.Errorf("the run after a refusal sent %d files, want none", again.MusicFiles) } if p := status(t, svc).Music; p == nil || p.RefusedFiles != 1 { t.Errorf("progress after the next run = %+v, want the refusal kept", p) } } func TestAKeyThatMayNotWriteTheMusicStopsThePassAndASaveTriesAgain_Integration(t *testing.T) { svc, pool := newService(t) ctx := context.Background() fake := &fakeBucket{puts: map[string][]byte{}} all := &atomic.Bool{} all.Store(true) srv := httptest.NewServer(lockedBucket{fakeBucket: fake, all: all}) defer srv.Close() lib := library(t, pool, map[string]string{"a.flac": "a", "b.flac": "b", "c.flac": "c", "d.flac": "d"}) svc.Roots = func() []string { return []string{lib} } u := bucketUpdate(srv.URL) u.IncludeMusic = true if err := svc.Save(ctx, u); err != nil { t.Fatal(err) } run := runOnce(t, svc) if run.MusicFiles != 0 || !strings.Contains(run.MusicNote, "refused 3 files in a row") { t.Errorf("sent %d, note %q; want the pass stopped after three refusals", run.MusicFiles, run.MusicNote) } if p := status(t, svc).Music; p == nil || p.RefusedFiles != 3 { t.Errorf("progress = %+v, want three refused and the fourth not tried", p) } // The key is fixed and the settings saved: the refused files go again. all.Store(false) if err := svc.Save(ctx, u); err != nil { t.Fatal(err) } if again := runOnce(t, svc); again.MusicFiles != 4 || again.MusicNote != "" { t.Errorf("after the save: sent %d, note %q; want all four", again.MusicFiles, again.MusicNote) } }