Files
Levyraati26_go/songs_test.go
T
Esa Kataja 1fe5211ae6 Replace Postgres with SQLite
Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
2026-08-02 20:47:41 +03:00

233 lines
6.8 KiB
Go

package main
import (
"context"
"testing"
)
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
t.Helper()
var id int64
err := a.db.QueryRowContext(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.db.QueryRowContext(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.db.ExecContext(ctx,
`update songs set created_at = datetime('now', '-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.db.ExecContext(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.db.ExecContext(ctx,
`update reviews set updated_at = datetime('now', '-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.db.ExecContext(ctx,
`update reviews set updated_at = datetime('now', '-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.db.QueryRowContext(ctx, `
update reviews set score = 1, updated_at = datetime('now')
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
if err == nil {
t.Fatal("an expired review was edited")
}
err = a.db.QueryRowContext(ctx, `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
if err == nil {
t.Fatal("an expired review was deleted")
}
}