Files
toni-sync/internal/syncer/syncer_test.go
T
arnefandCopilot 4e1a89cb05 Skip empty tonie folders instead of pruning all chapters
Previously, an accidentally empty tonie folder combined with pruning
(the default) would delete every existing chapter on the tonie. Now:

- syncer.BuildPlan detects an empty local folder and returns a
  Plan{Skipped: true, SkipReason: ...} instead of computing a diff
- syncer.ApplyPlan is a no-op for a skipped plan (defense in depth)
- Plan.NeedsChanges() reports false for skipped plans
- cmd/toni-sync sync prints "Skipped: <reason>" for such folders and
  moves on, regardless of --no-prune
- Added a test covering the skip behavior end-to-end (BuildPlan +
  ApplyPlan no-op)
- README documents the safety behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-14 19:27:58 +02:00

203 lines
6.2 KiB
Go

package syncer
import (
"os"
"path/filepath"
"testing"
"github.com/arnef/toni-sync/internal/tonieapi"
)
// fakeClient is a minimal in-memory stand-in for tonieapi.Client used in tests.
type fakeClient struct {
tonieSequence []tonieapi.CreativeTonie // consumed in order by GetCreativeTonie calls
callIndex int
uploadedTitles []string
sortedTitles []string
}
func (f *fakeClient) GetCreativeTonie(tonieID string) (*tonieapi.CreativeTonie, error) {
t := f.tonieSequence[f.callIndex]
if f.callIndex < len(f.tonieSequence)-1 {
f.callIndex++
}
return &t, nil
}
func (f *fakeClient) UploadFileToTonie(tonie tonieapi.CreativeTonie, filePath, title string) error {
f.uploadedTitles = append(f.uploadedTitles, title)
return nil
}
func (f *fakeClient) SortChaptersOfTonie(tonie tonieapi.CreativeTonie, chapters []tonieapi.Chapter) error {
for _, c := range chapters {
f.sortedTitles = append(f.sortedTitles, c.Title)
}
return nil
}
func makeTonie(chapters []tonieapi.Chapter) tonieapi.CreativeTonie {
return tonieapi.CreativeTonie{ID: "t1", HouseholdID: "h1", Name: "Test Tonie", Chapters: chapters}
}
func setupFolder(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "01 - First.mp3"), []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "02 - Second.mp3"), []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func TestListLocalTracksFiltersByExtension(t *testing.T) {
dir := setupFolder(t)
tracks, err := ListLocalTracks(dir)
if err != nil {
t.Fatal(err)
}
if len(tracks) != 2 || tracks[0].Title != "01 - First" || tracks[1].Title != "02 - Second" {
t.Fatalf("unexpected tracks: %+v", tracks)
}
}
func TestListLocalTracksMissingFolder(t *testing.T) {
if _, err := ListLocalTracks(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected error for missing folder")
}
}
func TestBuildPlanDetectsUploadNoRemoval(t *testing.T) {
dir := setupFolder(t)
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if len(plan.ToUpload) != 1 || plan.ToUpload[0].Title != "02 - Second" {
t.Fatalf("expected upload of '02 - Second', got %+v", plan.ToUpload)
}
if len(plan.ToRemove) != 0 {
t.Fatalf("expected no removal, got %+v", plan.ToRemove)
}
if !plan.NeedsChanges() {
t.Fatal("expected NeedsChanges to be true")
}
}
func TestBuildPlanPrunesRemovedChapters(t *testing.T) {
dir := setupFolder(t)
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if len(plan.ToRemove) != 1 || plan.ToRemove[0].Title != "stale" {
t.Fatalf("expected 'stale' to be removed, got %+v", plan.ToRemove)
}
}
func TestBuildPlanNoPruneKeepsStaleChapters(t *testing.T) {
dir := setupFolder(t)
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "stale"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
m := Target{TonieID: "t1", Folder: dir, Prune: false}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if len(plan.ToRemove) != 0 {
t.Fatalf("expected no removal, got %+v", plan.ToRemove)
}
want := []string{"01 - First", "02 - Second", "stale"}
if !equalStrings(plan.FinalOrderTitles, want) {
t.Fatalf("expected order %v, got %v", want, plan.FinalOrderTitles)
}
}
func TestBuildPlanSkipsEmptyFolder(t *testing.T) {
dir := t.TempDir() // no audio files
tonie := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "existing chapter"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{tonie}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if !plan.Skipped {
t.Fatal("expected plan to be skipped for empty folder")
}
if plan.NeedsChanges() {
t.Fatal("expected NeedsChanges to be false for a skipped plan")
}
if err := ApplyPlan(client, m, plan); err != nil {
t.Fatal(err)
}
if len(client.sortedTitles) != 0 || len(client.uploadedTitles) != 0 {
t.Fatalf("expected ApplyPlan to be a no-op for a skipped plan, got uploads=%v sorted=%v", client.uploadedTitles, client.sortedTitles)
}
}
func TestApplyPlanUploadsAndSkipsReorderWhenAlreadyMatching(t *testing.T) {
dir := setupFolder(t)
before := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}})
after := makeTonie([]tonieapi.Chapter{{ID: "c1", Title: "01 - First"}, {ID: "c2", Title: "02 - Second"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{before, after}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if err := ApplyPlan(client, m, plan); err != nil {
t.Fatal(err)
}
if len(client.uploadedTitles) != 1 || client.uploadedTitles[0] != "02 - Second" {
t.Fatalf("expected upload of '02 - Second', got %v", client.uploadedTitles)
}
if len(client.sortedTitles) != 0 {
t.Fatalf("expected no sort call since order already matches, got %v", client.sortedTitles)
}
}
func TestApplyPlanReordersWhenOrderDiffers(t *testing.T) {
dir := setupFolder(t)
reversed := makeTonie([]tonieapi.Chapter{{ID: "c2", Title: "02 - Second"}, {ID: "c1", Title: "01 - First"}})
client := &fakeClient{tonieSequence: []tonieapi.CreativeTonie{reversed, reversed}}
m := Target{TonieID: "t1", Folder: dir, Prune: true}
plan, err := BuildPlan(client, m)
if err != nil {
t.Fatal(err)
}
if err := ApplyPlan(client, m, plan); err != nil {
t.Fatal(err)
}
if len(client.uploadedTitles) != 0 {
t.Fatalf("expected no uploads, got %v", client.uploadedTitles)
}
want := []string{"01 - First", "02 - Second"}
if !equalStrings(client.sortedTitles, want) {
t.Fatalf("expected sorted order %v, got %v", want, client.sortedTitles)
}
}