- Replace the Python implementation with a Go module compiling to a single static binary (no Python/pip runtime dependency) - internal/tonieapi: minimal, dependency-light HTTP client for the TonieCloud REST API (login, households, creative tonies, file upload via presigned S3 request, chapter add/sort/clear) - internal/config: YAML-based mapping of local playlist folders to Kreativ-Tonies, credentials never stored - internal/syncer: diff/apply logic (upload new tracks, prune removed chapters, reorder to match local file order), built against a TonieClient interface for testability - cmd/toni-sync: Cobra CLI with `tonies list`, `config add/list/remove`, `sync [NAME|--all] [--dry-run]` - Unit tests for config persistence and syncer plan/apply logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
package syncer
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/arnef/toni-sync/internal/config"
|
|
"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 := config.Mapping{Name: "m", HouseholdID: "h1", 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 := config.Mapping{Name: "m", HouseholdID: "h1", 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 := config.Mapping{Name: "m", HouseholdID: "h1", 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 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 := config.Mapping{Name: "m", HouseholdID: "h1", 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 := config.Mapping{Name: "m", HouseholdID: "h1", 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)
|
|
}
|
|
}
|