soulsync/tests/test_spotify_token_cache.py
BoulderBadgeDad 603b7a2ab8 Spotify tokens move into the database — daily Docker deauth fixed (wolf39us)
wolf39us: "It keeps unauthenticating... daily" — re-auth fixes it until the
next day. Mechanism: spotipy's token cache was a loose FILE at
config/.spotify_cache. /app/config is a declared VOLUME, but a compose file
that doesn't map it explicitly gets an ANONYMOUS volume — recreated empty on
every container pull. So a nightly Watchtower update kept all his settings
(config lives in the database now) while silently dropping the OAuth tokens.
His redirect-URI change won't help: callback URLs only matter during the
initial handshake, never for refresh.

New DatabaseTokenCache (spotipy CacheHandler) stores the token payload in
the same database-backed config store as every other setting — tokens now
survive exactly as long as the rest of the configuration does. The legacy
file is imported once on upgrade (no forced re-auth) and removed on logout;
a failed cache write logs and never raises (spotipy calls it mid-request).

Tests: roundtrip, JSON-string tolerance, one-time legacy import (store wins
after the file vanishes), garbage file ignored, logout clears both stores,
write failure never raises. 204 spotify tests pass.
2026-06-07 12:01:33 -07:00

85 lines
2.7 KiB
Python

"""Database-backed Spotify token cache (wolf39us daily-deauth).
The token used to live in config/.spotify_cache — gone on every container
recreation unless the user's compose maps /app/config explicitly. The
DatabaseTokenCache stores it in the config store (which demonstrably
survives recreation — the user's settings did while his tokens died), and
imports the legacy file once on upgrade.
"""
from __future__ import annotations
import json
from core.spotify_token_cache import DatabaseTokenCache
class _Cfg:
def __init__(self):
self.store = {}
def get(self, key, default=None):
return self.store.get(key, default)
def set(self, key, value):
self.store[key] = value
TOKEN = {"access_token": "at", "refresh_token": "rt", "expires_at": 123}
def test_save_then_get_roundtrip(tmp_path):
cache = DatabaseTokenCache(_Cfg(), legacy_path=str(tmp_path / "nope"))
assert cache.get_cached_token() is None
cache.save_token_to_cache(TOKEN)
assert cache.get_cached_token() == TOKEN
def test_json_string_value_tolerated(tmp_path):
cfg = _Cfg()
cfg.store["spotify.token_info"] = json.dumps(TOKEN) # stored serialized
cache = DatabaseTokenCache(cfg, legacy_path=str(tmp_path / "nope"))
assert cache.get_cached_token() == TOKEN
def test_legacy_file_imported_once(tmp_path):
legacy = tmp_path / ".spotify_cache"
legacy.write_text(json.dumps(TOKEN))
cfg = _Cfg()
cache = DatabaseTokenCache(cfg, legacy_path=str(legacy))
assert cache.get_cached_token() == TOKEN # imported
assert cfg.store["spotify.token_info"] == TOKEN # persisted to the store
# Subsequent reads come from the store even if the file vanishes.
legacy.unlink()
assert cache.get_cached_token() == TOKEN
def test_garbage_legacy_file_ignored(tmp_path):
legacy = tmp_path / ".spotify_cache"
legacy.write_text("not json{{{")
cache = DatabaseTokenCache(_Cfg(), legacy_path=str(legacy))
assert cache.get_cached_token() is None
def test_clear_drops_store_and_file(tmp_path):
legacy = tmp_path / ".spotify_cache"
legacy.write_text(json.dumps(TOKEN))
cfg = _Cfg()
cache = DatabaseTokenCache(cfg, legacy_path=str(legacy))
cache.save_token_to_cache(TOKEN)
cache.clear()
assert cfg.store["spotify.token_info"] is None
assert not legacy.exists()
assert cache.get_cached_token() is None
def test_write_failure_never_raises(tmp_path):
class _Broken(_Cfg):
def set(self, key, value):
raise RuntimeError("db down")
cache = DatabaseTokenCache(_Broken(), legacy_path=str(tmp_path / "nope"))
cache.save_token_to_cache(TOKEN) # must not raise — spotipy calls this mid-request