package engine import ( "context" "os" "path/filepath" "strings" "sync" "testing" "github.com/cdnutter/playlink/internal/notify" "github.com/cdnutter/playlink/internal/store" ) // events records what the engine would notify about. type events struct { mu sync.Mutex got []notify.Event } func (ev *env) captureEvents() *events { r := &events{} ev.e.SetNotify(func(_ context.Context, e notify.Event) { r.mu.Lock() r.got = append(r.got, e) r.mu.Unlock() }) return r } func (r *events) take() []notify.Event { r.mu.Lock() defer r.mu.Unlock() out := r.got r.got = nil return out } func kinds(es []notify.Event) string { var k []string for _, e := range es { k = append(k, e.Kind) } return strings.Join(k, ",") } func (ev *env) automate(t *testing.T, settle int) { t.Helper() ev.pair.AutoLinkIdentical, ev.pair.AutoCleanupEmpty, ev.pair.SettleMinutes = true, true, settle if err := ev.st.SavePair(ctx, &ev.pair); err != nil { t.Fatal(err) } } func TestAutoCleanupRemovesOnlyEmptyFolders(t *testing.T) { ev := setup(t, false) ev.automate(t, 0) rec := ev.captureEvents() ev.write(ev.master, "A/Same/1 Song.flac", "same") ev.write(ev.slave, "A/Same/1 Song.flac", "same") ev.write(ev.master, "A/Diff/1 Song.flac", "master") ev.write(ev.slave, "A/Diff/1 Song.flac", "slave!") os.MkdirAll(filepath.Join(ev.slave, "B/Empty/Disc 1"), 0o755) ev.write(ev.slave, "C/Art/cover.jpg", "art") ev.write(ev.slave, "D/.DS_Store", "x") // any file at all keeps the folder ev.scan() if exists(filepath.Join(ev.slave, "B")) { t.Fatal("empty folder B should have been removed") } for _, keep := range []string{"C/Art/cover.jpg", "D/.DS_Store", "A/Same/1 Song.flac"} { if !exists(filepath.Join(ev.slave, keep)) { t.Fatalf("%s should be kept", keep) } } es := rec.take() if kinds(es) != "review,linked,cleanup" { t.Fatalf("events = %s", kinds(es)) } if es[0].Message != "1 song needs review." || es[0].PairID != ev.pair.ID { t.Fatalf("review event: %+v", es[0]) } if !strings.Contains(es[1].Message, "1 identical file") { t.Fatalf("linked event: %+v", es[1]) } if !strings.Contains(es[2].Message, "1 empty folder") || !strings.Contains(es[2].Message, "2 folders without music still hold files") { t.Fatalf("cleanup event: %+v", es[2]) } // Removed folders are quarantined and listed in History like manual ones. ops, _, err := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID, Limit: 100}) if err != nil { t.Fatal(err) } var found bool for _, o := range ops { if o.Action == "remove-folder" && o.RelSlave == "B" && strings.HasPrefix(o.Trigger, "auto:") && exists(o.QuarantinePath) { found = true } } if !found { t.Fatalf("no quarantined auto removal of B in %+v", ops) } // Nothing new: a second scan stays quiet. ev.scan() if es := rec.take(); len(es) != 0 { t.Fatalf("second scan notified: %s", kinds(es)) } } func TestAutoCleanupWaitsForSettleAndDryRun(t *testing.T) { ev := setup(t, false) ev.automate(t, 10) rec := ev.captureEvents() os.MkdirAll(filepath.Join(ev.slave, "Fresh"), 0o755) ev.write(ev.slave, "A/1 Song.flac", "x") ev.scan() if !exists(filepath.Join(ev.slave, "Fresh")) { t.Fatal("a folder changed within the settle time must be left alone") } if es := rec.take(); len(es) != 0 { t.Fatalf("events = %s", kinds(es)) } dry := setup(t, true) dry.automate(t, 0) rec = dry.captureEvents() os.MkdirAll(filepath.Join(dry.slave, "Empty"), 0o755) dry.write(dry.slave, "A/1 Song.flac", "x") dry.scan() if !exists(filepath.Join(dry.slave, "Empty")) { t.Fatal("dry run removed a folder") } if es := rec.take(); len(es) != 0 { t.Fatalf("dry run notified: %s", kinds(es)) } }