Files
Levyraati26_go/stats_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

121 lines
3.9 KiB
Go

package main
import (
"context"
"testing"
)
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
// same ten come back in the same order every time.
func TestLeaderboardThresholdAndOrder(t *testing.T) {
a := testApp(t)
ctx := context.Background()
submitter := a.seedMember(t, "[email protected]")
var reviewers []int64
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
reviewers = append(reviewers, a.seedMember(t, e))
}
loved := a.seedSong(t, submitter, "Rakastettu")
hated := a.seedSong(t, submitter, "Vihattu")
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
for _, r := range reviewers {
a.seedReview(t, loved, r, 90)
a.seedReview(t, hated, r, 20)
}
// One short of the threshold.
a.seedReview(t, ignored, reviewers[0], 100)
a.seedReview(t, ignored, reviewers[1], 100)
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
if err != nil {
t.Fatal(err)
}
if len(top) != 2 {
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
}
if top[0].ID != loved || top[1].ID != hated {
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
}
if top[0].Value != 90 || top[0].ReviewCount != 3 {
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
}
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
if bottom[0].ID != hated {
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
}
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
unified, err := a.songLeaderboard(ctx, stddevPop, "asc")
if err != nil {
t.Fatal(err)
}
if unified[0].Value != 0 {
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
}
// Ties are the common case in a ten-person club: the same query must return the same order.
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
for i := range first {
if first[i].ID != second[i].ID {
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
}
}
}
// Profiles are counts and history-wide averages. They never carry per-song opinions.
func TestProfileStats(t *testing.T) {
a := testApp(t)
ctx := context.Background()
aino := a.seedMember(t, "[email protected]")
bertta := a.seedMember(t, "[email protected]")
song := a.seedSong(t, aino, "Testikappale")
a.seedReview(t, song, bertta, 80)
other := a.seedSong(t, bertta, "Berttan kappale")
a.seedReview(t, other, aino, 40)
p, err := a.profile(ctx, bertta, aino)
if err != nil {
t.Fatal(err)
}
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
}
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
}
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
}
// Viewing someone else's profile does not expose their email.
if p.Email != "" {
t.Fatalf("another member's email leaked: %q", p.Email)
}
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
// its average here.
if len(p.Songs) != 1 {
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
}
if p.Songs[0].Average == nil {
t.Fatal("a reviewer cannot see the average of a song they reviewed")
}
// A third member who has reviewed nothing must not learn it from the profile page.
cecilia := a.seedMember(t, "[email protected]")
p, err = a.profile(ctx, cecilia, aino)
if err != nil {
t.Fatal(err)
}
if p.Songs[0].Average != nil {
t.Fatal("profile leaked a song average to someone who has not reviewed it")
}
}