soulsync/tests/playback/test_play_log.py
BoulderBadgeDad bf2a2ca928 Player: log SoulSync web-player plays (recently-played + smart-radio recency)
listening_history was populated ONLY from the media server; the web player
recorded nothing. Now a play heard ~10s logs to listening_history AND bumps
tracks.play_count/last_played — so the existing 'recently played' query reflects
actual SoulSync listening, and the Phase-2 smart-radio recency signal gets real
data.

- core/playback/play_log.build_play_event(): pure, DB-agnostic normalizer from
  player payload -> listening_history event shape. Caller supplies the
  timestamp (stays pure). Composite/streamed ids never become the int
  db_track_id; bool ids rejected; missing title -> skip. 9 unit tests.
- MusicDatabase.record_web_player_play(): inserts the history row + increments
  play_count/last_played for the library track in one call.
- /api/library/log-play: thin endpoint, server-side timestamp, best-effort
  (logging failure never 500s / never affects playback).
- Frontend: npMaybeLogPlay on timeupdate fires once per track at the 10s
  threshold (flag reset in setTrackInfo, set-before-fetch so it can't
  double-fire), fully fire-and-forget.

Pure builder is unit-tested; the DB write can't run in-sandbox (real DB throws)
so it's a thin straightforward insert+update. JS + web_server parse clean.
2026-05-30 15:11:46 -07:00

75 lines
2.3 KiB
Python

"""Tests for core.playback.play_log.build_play_event."""
from __future__ import annotations
from core.playback.play_log import WEB_PLAYER_SOURCE, build_play_event
TS = "2026-05-30T12:00:00Z"
def test_library_track_full_event():
ev = build_play_event(
{"id": 4321, "title": "DtMF", "artist": "Bad Bunny", "album": "DeBÍ"},
TS, duration_ms=237000,
)
assert ev == {
"track_id": "4321",
"title": "DtMF",
"artist": "Bad Bunny",
"album": "DeBÍ",
"played_at": TS,
"duration_ms": 237000,
"server_source": WEB_PLAYER_SOURCE,
"db_track_id": 4321,
}
def test_int_string_id_is_db_track_id():
ev = build_play_event({"id": "99", "title": "X"}, TS)
assert ev["db_track_id"] == 99
assert ev["track_id"] == "99"
def test_composite_id_not_used_as_db_track_id():
# Streamed/search results can carry a composite id like "user||file" —
# must NOT become db_track_id (would corrupt the int FK join).
ev = build_play_event({"id": "peer||song.flac", "title": "Streamed"}, TS)
assert ev["db_track_id"] is None
assert ev["track_id"] == "peer||song.flac"
def test_missing_title_returns_none():
assert build_play_event({"id": 1, "artist": "A"}, TS) is None
assert build_play_event({"title": " "}, TS) is None
def test_non_dict_returns_none():
assert build_play_event(None, TS) is None
assert build_play_event("nope", TS) is None
def test_missing_fields_default_to_empty():
ev = build_play_event({"title": "Solo"}, TS)
assert ev["artist"] == ""
assert ev["album"] == ""
assert ev["duration_ms"] == 0
assert ev["db_track_id"] is None
assert ev["track_id"] is None
def test_bad_duration_is_zero():
ev = build_play_event({"id": 1, "title": "T"}, TS, duration_ms="not-a-number")
assert ev["duration_ms"] == 0
def test_bool_id_not_treated_as_int():
# True is an int in Python — must not slip through as a track id.
ev = build_play_event({"id": True, "title": "T"}, TS)
assert ev["db_track_id"] is None
def test_caller_supplies_timestamp_pure():
# The module never reads the clock — same input → same output.
a = build_play_event({"id": 1, "title": "T"}, TS)
b = build_play_event({"id": 1, "title": "T"}, TS)
assert a == b