Fix episode position logging/resume: NaN savePos, clobber-on-boot, unvalidated history writes

- app.js: pause/pagehide listeners passed the Event into savePos(floor),
  making pos NaN -> serialized as null -> every pause/unload clobbered the
  saved position. Wrap in arrows; pagehide now posts keepalive so the final
  save survives page unload
- app.js: play() re-fetches history on a miss (boot fetch swallows errors)
  and only bumps an existing row instead of posting pos:0 at start-of-play
- app.py: POST /api/history requires slug_id (was stored as "None"),
  validates/clamps pos; upsert keeps the old payload when the incoming
  pos is null so stale clients can't corrupt a row
- tests: +3 API tests (slug required, pos clamping, null-pos keeps saved),
  +1 end-to-end browser test (resume ~400s with a failed boot fetch);
  lockout test now uses a throwaway username so it can't lock out users
  the browser tests log in with; hardened the flaky autoplay test
This commit is contained in:
jozamudi committed 2026-09-14 00:54:31 +00:00
1 parent 7444e29592
commit d90b1af8d6
4 files changed
+156 -23

No files matched your search

+42 -3
View File
@@ -104,6 +104,45 @@ def test_history_requires_ep_id(base=BASE):
assert req("POST", "/api/history", {"slug_id": "x"}, jar=jar, base=base) == 422
def test_history_requires_slug(base=BASE):
jar = login("admin", "adminpw12345", base)
# a missing slug_id used to be stored as a "None" ghost row
assert req("POST", "/api/history", {"ep_id": 991}, jar=jar, base=base) == 422
def test_history_clamps_pos(base=BASE):
jar = login("admin", "adminpw12345", base)
e = {"ep_id": 992, "slug_id": "junk-a", "title": "Junk", "season_id": 1,
"ep": "1", "lang": "eng", "pos": -50, "dur": 100}
assert req("POST", "/api/history", e, jar=jar, base=base) == 200
got = [x for x in _json("GET", "/api/history?all=1", jar=jar, base=base)
if x["slug_id"] == "junk-a"]
assert got and got[0]["pos"] == 0.0, got
e["pos"] = 999999
assert req("POST", "/api/history", e, jar=jar, base=base) == 200
got = [x for x in _json("GET", "/api/history?all=1", jar=jar, base=base)
if x["slug_id"] == "junk-a"]
assert got and got[0]["pos"] == 100.0, got
# non-numeric pos is rejected, not stored as junk
e["pos"] = "somenonsense"
assert req("POST", "/api/history", e, jar=jar, base=base) == 422
def test_history_null_pos_keeps_saved(base=BASE):
jar = login("dave", "davepw12345", base)
good = {"ep_id": 993, "slug_id": "keep-a", "title": "Keep", "season_id": 1,
"ep": "1", "lang": "eng", "pos": 400, "dur": 1500}
assert req("POST", "/api/history", good, jar=jar, base=base) == 200
# a stale client (old NaN-pos bug) re-POSTs pos null: the upsert must
# keep the row's saved position instead of clobbering it to 0
bad = dict(good)
bad["pos"] = None
assert req("POST", "/api/history", bad, jar=jar, base=base) == 200
got = [x for x in _json("GET", "/api/history?all=1", jar=jar, base=base)
if x["slug_id"] == "keep-a"]
assert got and got[0]["pos"] == 400, got
def test_history_per_user(base=BASE):
a = login("admin", "adminpw12345", base)
b = login("alice", "alicepw12345", base)
@@ -258,9 +297,9 @@ def test_stream_token_host_allowlist(base=BASE):
def test_lockout_per_user(base=BASE):
# must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob,
# admin unaffected
codes = [req("POST", "/login", {"username": "bob", "password": "bad"}, base=base)
# a throwaway name: the lockout is per-username and in-memory, and bob is
# also used by the browser tests (a locked-out bob breaks their logins)
codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base)
for _ in range(6)]
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200