SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
121 lines
3.9 KiB
Go
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")
|
|
}
|
|
}
|