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:
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
err := a.pool.QueryRow(context.Background(), `
|
||||
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
||||
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
|
||||
title, submitter).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
err := a.pool.QueryRow(context.Background(), `
|
||||
insert into reviews (song_id, reviewer_id, score, text)
|
||||
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A member who hasn't reviewed a song must not receive other reviews *in the result set*, and must
|
||||
// not receive the average either — not merely fail to render them.
|
||||
func TestRevealRuleWithholdsReviewsAndAverage(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
cecilia := a.seedMember(t, "[email protected]")
|
||||
|
||||
songID := a.seedSong(t, aino, "Testikappale")
|
||||
a.seedReview(t, songID, bertta, 88)
|
||||
|
||||
// Cecilia has not reviewed it.
|
||||
d, err := a.song(ctx, cecilia, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Revealed() {
|
||||
t.Fatal("song is revealed to a member who has not reviewed it")
|
||||
}
|
||||
if d.Reviews != nil {
|
||||
t.Fatalf("withheld reviews were still fetched: %d of them", len(d.Reviews))
|
||||
}
|
||||
if d.Average != nil {
|
||||
t.Fatalf("withheld average was still sent: %v", *d.Average)
|
||||
}
|
||||
if d.ReviewCount != 1 {
|
||||
t.Fatalf("review count = %d, want 1 — the count is not secret", d.ReviewCount)
|
||||
}
|
||||
|
||||
// Writing her own review unlocks both.
|
||||
a.seedReview(t, songID, cecilia, 60)
|
||||
d, err = a.song(ctx, cecilia, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.Revealed() || len(d.Reviews) != 2 {
|
||||
t.Fatalf("after reviewing: revealed = %v, reviews = %d, want true and 2",
|
||||
d.Revealed(), len(d.Reviews))
|
||||
}
|
||||
if d.Average == nil || *d.Average != 74 {
|
||||
t.Fatalf("average = %v, want 74", d.Average)
|
||||
}
|
||||
|
||||
// The submitter sees everything without reviewing — they cannot review their own song.
|
||||
d, err = a.song(ctx, aino, songID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.Revealed() || len(d.Reviews) != 2 || d.Average == nil {
|
||||
t.Fatal("the submitter cannot see the reviews of their own song")
|
||||
}
|
||||
if d.CanReview {
|
||||
t.Fatal("the submitter is offered a review form for their own song")
|
||||
}
|
||||
|
||||
// And the same rule holds in the list query, which is a different SQL path.
|
||||
list, err := a.browse(ctx, cecilia, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list.Items) != 1 || list.Items[0].Average == nil {
|
||||
t.Fatal("browse withheld the average from someone who has reviewed the song")
|
||||
}
|
||||
list, err = a.browse(ctx, a.seedMember(t, "[email protected]"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list.Items[0].Average != nil {
|
||||
t.Fatal("browse leaked the average to someone who has not reviewed the song")
|
||||
}
|
||||
}
|
||||
|
||||
// The queue excludes your own songs and anything you have already reviewed, oldest first.
|
||||
func TestQueueContents(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
|
||||
own := a.seedSong(t, aino, "Oma kappale")
|
||||
reviewed := a.seedSong(t, bertta, "Jo arvosteltu")
|
||||
fresh := a.seedSong(t, bertta, "Arvostelematon")
|
||||
a.seedReview(t, reviewed, aino, 50)
|
||||
|
||||
list, err := a.queue(ctx, aino, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list.Items) != 1 {
|
||||
var titles []string
|
||||
for _, s := range list.Items {
|
||||
titles = append(titles, s.Title)
|
||||
}
|
||||
t.Fatalf("queue = %v, want just the unreviewed song", titles)
|
||||
}
|
||||
if list.Items[0].ID != fresh {
|
||||
t.Fatalf("queue holds song %d, want %d", list.Items[0].ID, fresh)
|
||||
}
|
||||
_ = own
|
||||
|
||||
// Oldest first: a second unreviewed song comes after the first.
|
||||
older := a.seedSong(t, bertta, "Vanhempi")
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err = a.queue(ctx, aino, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list.Items[0].ID != older {
|
||||
t.Fatal("queue is not oldest first")
|
||||
}
|
||||
}
|
||||
|
||||
// Locked is a live state: deleting the only review makes the song editable again.
|
||||
func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
songID := a.seedSong(t, aino, "Testikappale")
|
||||
|
||||
d, _ := a.song(ctx, aino, songID)
|
||||
if !d.CanEdit {
|
||||
t.Fatal("a song with no reviews is not editable by its submitter")
|
||||
}
|
||||
|
||||
reviewID := a.seedReview(t, songID, bertta, 88)
|
||||
d, _ = a.song(ctx, aino, songID)
|
||||
if d.CanEdit {
|
||||
t.Fatal("a reviewed song is still editable")
|
||||
}
|
||||
|
||||
if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _ = a.song(ctx, aino, songID)
|
||||
if !d.CanEdit {
|
||||
t.Fatal("song did not unlock after its only review was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// The window is measured from updated_at, so an edit extends it — and it gates delete too.
|
||||
func TestEditWindow(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
songID := a.seedSong(t, aino, "Testikappale")
|
||||
reviewID := a.seedReview(t, songID, bertta, 88)
|
||||
|
||||
v, err := a.viewerReview(ctx, songID, bertta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !v.CanEdit() {
|
||||
t.Fatal("a fresh review is not editable")
|
||||
}
|
||||
|
||||
// Just inside the window.
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
|
||||
reviewID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ = a.viewerReview(ctx, songID, bertta)
|
||||
if !v.CanEdit() {
|
||||
t.Fatal("a 29-minute-old review is not editable")
|
||||
}
|
||||
|
||||
// Past it.
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
|
||||
reviewID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ = a.viewerReview(ctx, songID, bertta)
|
||||
if v.CanEdit() {
|
||||
t.Fatal("a 31-minute-old review is still editable")
|
||||
}
|
||||
|
||||
// The database is the authority, not the Go clock: the update and the delete both refuse.
|
||||
var n int64
|
||||
err = a.pool.QueryRow(ctx, `
|
||||
update reviews set score = 1, updated_at = now()
|
||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
||||
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
||||
if err == nil {
|
||||
t.Fatal("an expired review was edited")
|
||||
}
|
||||
err = a.pool.QueryRow(ctx, `
|
||||
delete from reviews
|
||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
||||
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
||||
if err == nil {
|
||||
t.Fatal("an expired review was deleted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user