Files
Levyraati26_go/src/submit_test.go
T
Esa Kataja fd5b4d212c Give a failed submission a code, and put the cause in the log
A download that died on an HTTP 403 told the submitter "HTTP Error 403:
Forbidden" and told the log "error: exit status 1". The tool's stderr went
into status_msg and nowhere else, so the one person who could act on it saw
nothing. Exactly backwards.

Failures now go through a.fail: the submitter gets a sentence and an eight
character code, the log gets that code, the stage, the submission, the
source URL and the stderr tail. Quote the code, grep the log, find the line.

Every record carries file:line now, and LOG_LEVEL sets the threshold —
failures are logged at error, so no level hides them.
2026-09-05 14:56:12 +03:00

259 lines
7.6 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"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.db.QueryRowContext(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.db.ExecContext(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.db.QueryRowContext(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.db.QueryRowContext(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.db.QueryRowContext(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.db.QueryRowContext(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.db.ExecContext(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.db.ExecContext(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.db.ExecContext(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.db.ExecContext(ctx,
`update submissions set created_at = datetime('now', '-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.db.ExecContext(ctx,
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
t.Fatal(err)
}
}
if err := sweep(ctx, a.db); err != nil {
t.Fatal(err)
}
var stuck int
if err := a.db.QueryRowContext(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.db.QueryRowContext(ctx,
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
t.Fatal(err)
}
if msg == "" {
t.Fatal("swept submission carries no explanation")
}
}
// The submitter must get a code they can quote, and must not get yt-dlp's stderr. The code is the
// only thing tying their screenshot to the log line that says what actually broke.
func TestFailGivesTraceableCodeNotToolOutput(t *testing.T) {
a := testApp(t)
ctx := context.Background()
uid := a.seedMember(t, "[email protected]")
var subID int64
if err := a.db.QueryRowContext(ctx,
`insert into submissions (user_id, status) values ($1, 'downloading') returning id`,
uid).Scan(&subID); err != nil {
t.Fatal(err)
}
const secret = "HTTP Error 403: Forbidden"
a.fail(ctx, subID, "download", "Kappaleen lataaminen ei onnistunut.", secret,
fmt.Errorf("exit status 1"), "url", "https://youtu.be/x")
var status, msg string
if err := a.db.QueryRowContext(ctx,
`select status, status_msg from submissions where id = $1`, subID).Scan(&status, &msg); err != nil {
t.Fatal(err)
}
if status != "failed" {
t.Fatalf("status = %q, want failed", status)
}
if strings.Contains(msg, secret) {
t.Fatalf("tool stderr leaked to the submitter: %q", msg)
}
if !regexp.MustCompile(`\(virhekoodi [0-9a-f]{8}\)$`).MatchString(msg) {
t.Fatalf("no traceable code in %q", msg)
}
}