Add the submission pipeline and the review loop
Steps 3 and 4 of the build order. A member can now upload a song, watch it convert, publish it, and review what everyone else has published. Pipeline: - ffprobe reads tags synchronously at submit so prefill never races typing; ffmpeg converts to Opus in the background, two at a time - ffmpeg succeeding is the validation — no container sniffing - publish moves the file inside the transaction, so a song row and its .ogg appear together or neither does - five submissions per rolling 24h, failures excluded Reviews and the reveal rule: - the queue is unreviewed songs only, oldest first, never your own - other people's reviews and the average are withheld in the query, not the template — a hidden average is never sent - 30 minutes to edit or delete your own review, enforced in the WHERE clause - deleting the last review unlocks the song for its submitter again The waiting page has one button: the metadata form autosaves after a pause in typing, and Julkaise submits it and publishes in the same request, so nothing is lost without JS. Genres store an English code and render a Finnish label.
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
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, "[email protected]")
|
||||
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, "[email protected]")
|
||||
|
||||
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, "[email protected]")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user