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.
259 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|