Add stats, profiles, avatars and palaute
Step 6. The surfaces around the review loop. - Nine leaderboards, ordered and limited in SQL, each with a deterministic tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews to qualify, for reviewer boards too - Profiles show counts and history-wide averages and the member's songs, never a list of their reviews — per-song opinion stays gated - Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation. No upload still means initials, and avatars are public - Changing your own password requires the current one and drops your other sessions - Palaute: free text plus the page you were on, carried in a footer link, and the user agent from the header. Reporters see their own; the admin resolves them with a timestamp rather than a status enum - Admin gained the song list with delete, the reports page, and an open-report count on the dashboard Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter because display:grid suppresses list markers, and count-based boards were printing 3.0 where they mean 3.
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
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, "stddev_pop(r.score)", "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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user