feat(playevents): persist session_vector_at_play on every record path

RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation.
This commit is contained in:
2026-04-27 11:19:16 -04:00
parent 627b7d1be5
commit d6acb3b63c
2 changed files with 123 additions and 0 deletions
+83
View File
@@ -2,6 +2,7 @@ package playevents
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
@@ -234,3 +235,85 @@ func TestRecordSyntheticCompletedPlay_WritesBothRows(t *testing.T) {
t.Errorf("closed play_events count = %d, want 1", count)
}
}
func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) {
f := newFixture(t, 200_000)
now := time.Now().UTC()
res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
if err != nil {
t.Fatalf("RecordPlayStarted: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.SessionVectorAtPlay == nil {
t.Fatalf("session_vector_at_play is NULL")
}
var vec map[string]any
if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if seed, _ := vec["seed"].(bool); !seed {
t.Errorf("seed = %v, want true (first play in session)", vec["seed"])
}
if artists, _ := vec["artists"].([]any); len(artists) != 0 {
t.Errorf("artists not empty: %v", artists)
}
}
func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) {
f := newFixture(t, 200_000)
// Seed 4 prior plays with the same track (reusing the fixture's single
// track is fine — vector dedup means we'll see 1 artist regardless).
t0 := time.Now().UTC().Add(-1 * time.Hour)
for i := 0; i < 4; i++ {
at := t0.Add(time.Duration(i) * time.Minute)
_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
}
// 5th play — should see vector with 4 prior tracks.
at := t0.Add(10 * time.Minute)
res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
if err != nil {
t.Fatalf("RecordPlayStarted: %v", err)
}
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if got.SessionVectorAtPlay == nil {
t.Fatal("session_vector_at_play is NULL")
}
var vec map[string]any
_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
if seed, _ := vec["seed"].(bool); seed {
t.Errorf("seed = true after 4 prior tracks, want false")
}
recentIDs, _ := vec["recent_track_ids"].([]any)
if len(recentIDs) != 4 {
t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs))
}
}
func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) {
f := newFixture(t, 200_000)
// Play 1: at t=0.
t0 := time.Now().UTC().Add(-2 * time.Hour)
_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0)
// Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout).
t1 := t0.Add(31 * time.Minute)
res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
if err != nil {
t.Fatalf("RecordPlayStarted: %v", err)
}
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if got.SessionVectorAtPlay == nil {
t.Fatal("vector NULL")
}
var vec map[string]any
_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
if seed, _ := vec["seed"].(bool); !seed {
t.Errorf("seed = false in fresh session, want true")
}
recentIDs, _ := vec["recent_track_ids"].([]any)
if len(recentIDs) != 0 {
t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs))
}
}