package store import ( "context" "fmt" "os" "path/filepath" "sync" "testing" "coda/internal/models" ) func openTestDB(t *testing.T) *Store { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") st, err := New(dbPath) if err != nil { t.Fatalf("New(%q): %v", dbPath, err) } t.Cleanup(func() { st.Close() }) return st } func TestNewIdempotent(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") st1, err := New(dbPath) if err != nil { t.Fatalf("first New: %v", err) } st1.Close() st2, err := New(dbPath) if err != nil { t.Fatalf("second New: %v", err) } st2.Close() } func TestNewRelativePath(t *testing.T) { t.Chdir(t.TempDir()) st, err := New(filepath.Join("nested", "test.db")) if err != nil { t.Fatalf("New relative path: %v", err) } st.Close() if _, err := os.Stat(filepath.Join("nested", "test.db")); err != nil { t.Fatalf("stat relative db: %v", err) } } func TestJobCreateAndGet(t *testing.T) { st := openTestDB(t) ctx := context.Background() job := &models.Job{ Artist: "Test Artist", Album: "Test Album", Path: "/music/Test Artist/Test Album", Status: models.StatusReceived, Source: models.SourceManual, } if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } if job.ID == "" { t.Fatal("expected ID to be set by Create") } if job.CreatedAt.IsZero() { t.Fatal("expected CreatedAt to be set by Create") } got, err := st.Jobs.Get(ctx, job.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.Artist != job.Artist { t.Errorf("Artist = %q, want %q", got.Artist, job.Artist) } if got.Status != models.StatusReceived { t.Errorf("Status = %q, want %q", got.Status, models.StatusReceived) } } func TestJobGetNotFound(t *testing.T) { st := openTestDB(t) ctx := context.Background() _, err := st.Jobs.Get(ctx, "nonexistent") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func TestJobUpdateStatus(t *testing.T) { st := openTestDB(t) ctx := context.Background() job := &models.Job{Artist: "A", Album: "B", Path: "/p", Status: models.StatusReceived, Source: models.SourceManual} if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } if err := st.Jobs.UpdateStatus(ctx, job.ID, models.StatusProcessing); err != nil { t.Fatalf("UpdateStatus: %v", err) } got, _ := st.Jobs.Get(ctx, job.ID) if got.Status != models.StatusProcessing { t.Errorf("Status = %q, want %q", got.Status, models.StatusProcessing) } } func TestJobUpdateStatusNotFound(t *testing.T) { st := openTestDB(t) ctx := context.Background() err := st.Jobs.UpdateStatus(ctx, "nonexistent", models.StatusProcessing) if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func TestJobUpdateProgress(t *testing.T) { st := openTestDB(t) ctx := context.Background() job := &models.Job{Artist: "A", Album: "B", Path: "/p", Status: models.StatusProcessing, Source: models.SourceManual} if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } if err := st.Jobs.UpdateProgress(ctx, job.ID, 0, 5); err != nil { t.Fatalf("UpdateProgress (set total): %v", err) } got, _ := st.Jobs.Get(ctx, job.ID) if got.FilesTotal != 5 { t.Errorf("FilesTotal = %d, want 5", got.FilesTotal) } if got.FilesProcessed != 0 { t.Errorf("FilesProcessed = %d, want 0", got.FilesProcessed) } if err := st.Jobs.UpdateProgress(ctx, job.ID, 3, 5); err != nil { t.Fatalf("UpdateProgress (mid): %v", err) } got, _ = st.Jobs.Get(ctx, job.ID) if got.FilesProcessed != 3 { t.Errorf("FilesProcessed = %d, want 3", got.FilesProcessed) } if got.FilesTotal != 5 { t.Errorf("FilesTotal = %d, want 5", got.FilesTotal) } } func TestJobUpdateProgressNotFound(t *testing.T) { st := openTestDB(t) ctx := context.Background() err := st.Jobs.UpdateProgress(ctx, "nonexistent", 1, 5) if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func TestJobFinish(t *testing.T) { st := openTestDB(t) ctx := context.Background() job := &models.Job{Artist: "A", Album: "B", Path: "/p", Status: models.StatusProcessing, Source: models.SourceManual} if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } err := st.Jobs.Finish(ctx, job.ID, models.StatusDone, 5000, "") if err != nil { t.Fatalf("Finish: %v", err) } got, _ := st.Jobs.Get(ctx, job.ID) if got.Status != models.StatusDone { t.Errorf("Status = %q, want %q", got.Status, models.StatusDone) } if got.DurationMs != 5000 { t.Errorf("DurationMs = %d, want 5000", got.DurationMs) } if got.FinishedAt == nil { t.Fatal("expected FinishedAt to be set") } } func TestJobFinishError(t *testing.T) { st := openTestDB(t) ctx := context.Background() job := &models.Job{Artist: "A", Album: "B", Path: "/p", Status: models.StatusProcessing, Source: models.SourceManual} if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } err := st.Jobs.Finish(ctx, job.ID, models.StatusError, 0, "something went wrong") if err != nil { t.Fatalf("Finish: %v", err) } got, _ := st.Jobs.Get(ctx, job.ID) if got.Status != models.StatusError { t.Errorf("Status = %q, want %q", got.Status, models.StatusError) } if got.ErrorMsg != "something went wrong" { t.Errorf("ErrorMsg = %q, want %q", got.ErrorMsg, "something went wrong") } } func TestJobListAndCount(t *testing.T) { st := openTestDB(t) ctx := context.Background() for _, s := range []models.JobStatus{models.StatusReceived, models.StatusDone, models.StatusError} { job := &models.Job{ Artist: "ListTest", Album: "Album", Path: "/p", Status: s, Source: models.SourceManual, } if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } } jobs, err := st.Jobs.List(ctx, ListJobsParams{Limit: 10}) if err != nil { t.Fatalf("List: %v", err) } if len(jobs) < 3 { t.Errorf("List len = %d, want >= 3", len(jobs)) } total, err := st.Jobs.Count(ctx, nil) if err != nil { t.Fatalf("Count(all): %v", err) } if total < 3 { t.Errorf("Count(all) = %d, want >= 3", total) } doneCount, err := st.Jobs.Count(ctx, &[]models.JobStatus{models.StatusDone}[0]) if err != nil { t.Fatalf("Count(done): %v", err) } if doneCount != 1 { t.Errorf("Count(done) = %d, want 1", doneCount) } } func TestJobListPagination(t *testing.T) { st := openTestDB(t) ctx := context.Background() for i := 0; i < 5; i++ { job := &models.Job{Artist: "Page", Album: "Test", Path: "/p", Status: models.StatusReceived, Source: models.SourceManual} if err := st.Jobs.Create(ctx, job); err != nil { t.Fatalf("Create: %v", err) } } jobs, err := st.Jobs.List(ctx, ListJobsParams{Limit: 2, Offset: 0}) if err != nil { t.Fatalf("List page 1: %v", err) } if len(jobs) != 2 { t.Errorf("List page 1 len = %d, want 2", len(jobs)) } jobs2, err := st.Jobs.List(ctx, ListJobsParams{Limit: 2, Offset: 2}) if err != nil { t.Fatalf("List page 2: %v", err) } if len(jobs2) < 2 { t.Errorf("List page 2 len = %d, want >= 2", len(jobs2)) } } func TestLogInsertAndList(t *testing.T) { st := openTestDB(t) ctx := context.Background() entry := &models.LogEntry{ JobID: "job-123", Level: "INFO", Message: "test message", } if err := st.Logs.Insert(ctx, entry); err != nil { t.Fatalf("Insert: %v", err) } if entry.ID == 0 { t.Fatal("expected ID to be set by Insert") } logs, err := st.Logs.List(ctx, ListLogsParams{Limit: 10}) if err != nil { t.Fatalf("List: %v", err) } if len(logs) < 1 { t.Fatal("expected at least 1 log entry") } if logs[0].JobID != "job-123" { t.Errorf("JobID = %q, want %q", logs[0].JobID, "job-123") } } func TestLogListFilters(t *testing.T) { st := openTestDB(t) ctx := context.Background() for _, level := range []string{"INFO", "WARN", "ERROR"} { entry := &models.LogEntry{JobID: "filter-test", Level: level, Message: "msg"} if err := st.Logs.Insert(ctx, entry); err != nil { t.Fatalf("Insert: %v", err) } } warnLogs, err := st.Logs.List(ctx, ListLogsParams{Level: "WARN", Limit: 10}) if err != nil { t.Fatalf("List WARN: %v", err) } if len(warnLogs) != 1 || warnLogs[0].Level != "WARN" { t.Errorf("expected 1 WARN log, got %d", len(warnLogs)) } byJob, err := st.Logs.List(ctx, ListLogsParams{JobID: "filter-test", Limit: 10}) if err != nil { t.Fatalf("List by job: %v", err) } if len(byJob) != 3 { t.Errorf("expected 3 logs for filter-test, got %d", len(byJob)) } } func TestLogCount(t *testing.T) { st := openTestDB(t) ctx := context.Background() for i := 0; i < 3; i++ { entry := &models.LogEntry{Level: "INFO", Message: "msg"} if err := st.Logs.Insert(ctx, entry); err != nil { t.Fatalf("Insert: %v", err) } } count, err := st.Logs.Count(ctx, ListLogsParams{}) if err != nil { t.Fatalf("Count: %v", err) } if count < 3 { t.Errorf("Count = %d, want >= 3", count) } infoCount, err := st.Logs.Count(ctx, ListLogsParams{Level: "INFO"}) if err != nil { t.Fatalf("Count INFO: %v", err) } if infoCount < 3 { t.Errorf("Count INFO = %d, want >= 3", infoCount) } } func TestConcurrentWritesDoNotReturnBusy(t *testing.T) { st := openTestDB(t) ctx := context.Background() var wg sync.WaitGroup errs := make(chan error, 90) for i := 0; i < 30; i++ { wg.Add(3) go func(i int) { defer wg.Done() job := &models.Job{ Artist: fmt.Sprintf("Artist %d", i), Album: fmt.Sprintf("Album %d", i), Path: fmt.Sprintf("/music/%d", i), Status: models.StatusReceived, Source: models.SourceManual, } if err := st.Jobs.Create(ctx, job); err != nil { errs <- err return } if err := st.Jobs.UpdateStatus(ctx, job.ID, models.StatusProcessing); err != nil { errs <- err return } if err := st.Jobs.Finish(ctx, job.ID, models.StatusDone, int64(i), ""); err != nil { errs <- err } }(i) go func(i int) { defer wg.Done() errs <- st.Logs.Insert(ctx, &models.LogEntry{ JobID: fmt.Sprintf("job-%d", i), Level: "INFO", Message: fmt.Sprintf("message %d", i), }) }(i) go func(i int) { defer wg.Done() errs <- st.Config.Set(ctx, fmt.Sprintf("key-%d", i), fmt.Sprintf("value-%d", i)) }(i) } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent write: %v", err) } } } func TestConfigSetGetGetAllDelete(t *testing.T) { st := openTestDB(t) ctx := context.Background() err := st.Config.Set(ctx, "mykey", "myvalue") if err != nil { t.Fatalf("Set: %v", err) } val, found, err := st.Config.Get(ctx, "mykey") if err != nil { t.Fatalf("Get: %v", err) } if !found { t.Fatal("expected key to be found") } if val != "myvalue" { t.Errorf("value = %q, want %q", val, "myvalue") } _, found, err = st.Config.Get(ctx, "nonexistent") if err != nil { t.Fatalf("Get nonexistent: %v", err) } if found { t.Fatal("expected key not found") } all, err := st.Config.GetAll(ctx) if err != nil { t.Fatalf("GetAll: %v", err) } if len(all) < 1 { t.Error("expected at least 1 config key") } err = st.Config.Delete(ctx, "mykey") if err != nil { t.Fatalf("Delete: %v", err) } _, found, err = st.Config.Get(ctx, "mykey") if err != nil { t.Fatalf("Get after delete: %v", err) } if found { t.Fatal("expected key to be deleted") } } func TestConfigUpsert(t *testing.T) { st := openTestDB(t) ctx := context.Background() st.Config.Set(ctx, "dupe", "first") st.Config.Set(ctx, "dupe", "second") val, found, _ := st.Config.Get(ctx, "dupe") if !found || val != "second" { t.Errorf("value = %q, want %q", val, "second") } all, _ := st.Config.GetAll(ctx) count := 0 for k := range all { if k == "dupe" { count++ } } if count != 1 { t.Errorf("expected 1 entry for dupe, got %d", count) } } func TestConfigDeleteNotFound(t *testing.T) { st := openTestDB(t) ctx := context.Background() err := st.Config.Delete(ctx, "nonexistent") if err != ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } func init() { if os.Getenv("CI") != "" { // TODO: CI-specific setup } }