package main import ( "context" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "testing" ) func TestClean(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"Testikappale", "Testikappale"}, {" padded ", "padded"}, {"line\nbreak", "line break"}, {"tab\tsep", "tab sep"}, {"bell\x07null\x00", "bellnull"}, {"a b c", "a b c"}, } { if got := clean(tc.in, 100); got != tc.want { t.Errorf("clean(%q) = %q, want %q", tc.in, got, tc.want) } } // Truncation counts runes, not bytes: a 100-ä title is 100 characters, not 50. long := "" for range 150 { long += "ä" } if got := []rune(clean(long, 100)); len(got) != 100 { t.Errorf("truncated to %d runes, want 100", len(got)) } } func makeAudio(t *testing.T, path string) { t.Helper() if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("ffmpeg not on PATH") } cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", "-c:a", "libopus", path) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("ffmpeg: %v\n%s", err, out) } } func (a *app) readySubmission(t *testing.T, userID int64) *submission { t.Helper() var id int64 err := a.pool.QueryRow(context.Background(), ` insert into submissions (user_id, status, title, artist, genre) values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`, userID).Scan(&id) if err != nil { t.Fatal(err) } path := a.tmpPath(id, ".ogg") makeAudio(t, path) if _, err := a.pool.Exec(context.Background(), `update submissions set tmp_path = $2 where id = $1`, id, path); err != nil { t.Fatal(err) } return &submission{ID: id, UserID: userID, Status: "ready", TmpPath: path} } // A song row and its .ogg appear together, or neither does. func TestPublishIsAllOrNothing(t *testing.T) { a := testApp(t) ctx := context.Background() a.cfg.storageDir = t.TempDir() audioDir := filepath.Join(a.cfg.storageDir, "audio") for _, d := range []string{"audio", "tmp"} { if err := os.MkdirAll(filepath.Join(a.cfg.storageDir, d), 0o755); err != nil { t.Fatal(err) } } id := a.seedMember(t, "esa@example.com") sub := a.readySubmission(t, id) // Make the move impossible, the same way a full or read-only disk would. if err := os.Chmod(audioDir, 0o500); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(audioDir, 0o755) }) r := httptest.NewRequest("POST", fmt.Sprintf("/submit/%d/publish", sub.ID), nil) r.SetPathValue("id", fmt.Sprint(sub.ID)) r = r.WithContext(context.WithValue(ctx, memberKey, &member{ID: id})) w := httptest.NewRecorder() a.publish(w, r) if w.Code != http.StatusInternalServerError { t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code) } var songs, submissions int if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil { t.Fatal(err) } if songs != 0 { t.Fatalf("orphan song row: %d rows with no audio file", songs) } if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { t.Fatal(err) } if submissions != 1 { t.Fatalf("submission rows = %d, want 1 — a failed publish must leave it recoverable", submissions) } if _, err := os.Stat(sub.TmpPath); err != nil { t.Fatalf("converted audio was lost: %v", err) } // With the directory writable again, the same submission publishes. os.Chmod(audioDir, 0o755) w = httptest.NewRecorder() a.publish(w, r) if w.Code != http.StatusSeeOther { t.Fatalf("publish: status = %d, want 303", w.Code) } var songID int64 if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil { t.Fatal(err) } if _, err := os.Stat(a.audioPath(songID)); err != nil { t.Fatalf("published song has no audio file: %v", err) } if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { t.Fatal(err) } if submissions != 0 { t.Fatalf("submission survived publish: %d rows", submissions) } } // Five per rolling 24 hours, counting published songs, never counting failures. func TestSubmissionQuota(t *testing.T) { a := testApp(t) ctx := context.Background() id := a.seedMember(t, "esa@example.com") check := func(want bool, why string) { t.Helper() over, err := a.overQuota(ctx, id) if err != nil { t.Fatal(err) } if over != want { t.Fatalf("%s: overQuota = %v, want %v", why, over, want) } } check(false, "no submissions") for range 4 { if _, err := a.pool.Exec(ctx, `insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil { t.Fatal(err) } } check(false, "four in flight") // Failures never count — yt-dlp rot and bad files are not the submitter's fault. for range 10 { if _, err := a.pool.Exec(ctx, `insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil { t.Fatal(err) } } check(false, "failures do not count") // A published song still occupies a slot, even though its submission row is gone. if _, err := a.pool.Exec(ctx, ` insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by) values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil { t.Fatal(err) } check(true, "four in flight plus one published") // Yesterday's submissions are outside the window. if _, err := a.pool.Exec(ctx, `update submissions set created_at = now() - interval '25 hours' where user_id = $1`, id); err != nil { t.Fatal(err) } check(false, "older than 24 hours") } // A submission left mid-conversion by a restart must not say "converting" forever. func TestRestartRecovery(t *testing.T) { a := testApp(t) ctx := context.Background() id := a.seedMember(t, "esa@example.com") for _, status := range []string{"queued", "downloading", "converting"} { if _, err := a.pool.Exec(ctx, `insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil { t.Fatal(err) } } if err := sweep(ctx, a.pool); err != nil { t.Fatal(err) } var stuck int if err := a.pool.QueryRow(ctx, `select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil { t.Fatal(err) } if stuck != 0 { t.Fatalf("%d submissions survived the sweep still in flight", stuck) } var msg string if err := a.pool.QueryRow(ctx, `select status_msg from submissions limit 1`).Scan(&msg); err != nil { t.Fatal(err) } if msg == "" { t.Fatal("swept submission carries no explanation") } }