/
/
/
1"""Tests for cache controller."""
2
3import os
4import time
5from collections.abc import Callable
6from dataclasses import dataclass
7from pathlib import Path
8from typing import Any
9from unittest.mock import AsyncMock, patch
10
11import aiofiles
12import pytest
13
14from music_assistant.constants import DB_TABLE_CACHE, DB_TABLE_SETTINGS, VACUUM_MIN_RECLAIM_RATIO
15from music_assistant.controllers.cache import MAX_CACHE_DB_SIZE_MB, CacheController
16from music_assistant.controllers.cache.constants import DB_SCHEMA_VERSION, SWR_FALLBACK_MAX_AGE
17from music_assistant.helpers.database import DatabaseConnection
18from music_assistant.mass import MusicAssistant
19
20
21@dataclass
22class _FakeModel:
23 """Simple model for testing base_class reconstruction."""
24
25 name: str = ""
26 value: int = 0
27
28 @classmethod
29 def from_dict(cls, data: dict[str, Any]) -> _FakeModel:
30 """Reconstruct from dict."""
31 return cls(name=data.get("name", ""), value=data.get("value", 0))
32
33
34async def _create_db_files(cache_path: str) -> list[str]:
35 """
36 Create small cache.db, cache.db-wal, and cache.db-shm files.
37
38 :param cache_path: Path to the cache directory.
39 """
40 db_path = os.path.join(cache_path, "cache.db")
41 paths = [db_path + suffix for suffix in ("", "-wal", "-shm")]
42 for path in paths:
43 async with aiofiles.open(path, "wb") as f:
44 await f.write(b"\0")
45 return paths
46
47
48# --- Core get/set behavior ---
49
50
51async def test_set_and_get_string(cache_controller: CacheController) -> None:
52 """Test storing and retrieving a string value."""
53 await cache_controller.set("test_key", "hello", provider="test")
54 result = await cache_controller.get("test_key", provider="test")
55 assert result == "hello"
56
57
58async def test_get_expiration(cache_controller: CacheController) -> None:
59 """get_expiration returns the stored expiry epoch, also for expired rows."""
60 before = int(time.time())
61 await cache_controller.set("exp_key", {"a": 1}, provider="test", expiration=500)
62 expires = await cache_controller.get_expiration("exp_key", provider="test")
63 assert expires is not None
64 assert abs(expires - (before + 500)) <= 5
65 # a missing key yields None
66 assert await cache_controller.get_expiration("missing_key", provider="test") is None
67 # an expired-but-present row still reports its (past) expiration
68 await cache_controller.set("expired_key", "x", provider="test", expiration=-100)
69 expired = await cache_controller.get_expiration("expired_key", provider="test")
70 assert expired is not None
71 assert expired < int(time.time())
72
73
74async def test_set_and_get_int(cache_controller: CacheController) -> None:
75 """Test storing and retrieving an integer value."""
76 await cache_controller.set("num", 42, provider="test")
77 result = await cache_controller.get("num", provider="test")
78 assert result == 42
79
80
81async def test_set_and_get_float(cache_controller: CacheController) -> None:
82 """Test storing and retrieving a float value."""
83 await cache_controller.set("pi", 3.14, provider="test")
84 result = await cache_controller.get("pi", provider="test")
85 assert result == pytest.approx(3.14)
86
87
88async def test_set_and_get_bool(cache_controller: CacheController) -> None:
89 """Test storing and retrieving a boolean value."""
90 await cache_controller.set("flag", True, provider="test")
91 result = await cache_controller.get("flag", provider="test")
92 assert result is True
93
94
95async def test_set_and_get_none(cache_controller: CacheController) -> None:
96 """Test storing and retrieving None."""
97 await cache_controller.set("empty", None, provider="test")
98 result = await cache_controller.get("empty", provider="test", default="MISSING")
99 assert result is None
100
101
102async def test_set_and_get_dict(cache_controller: CacheController) -> None:
103 """Test storing and retrieving a dict value."""
104 data = {"name": "test", "count": 5, "nested": {"a": 1}}
105 await cache_controller.set("dict_key", data, provider="test")
106 result = await cache_controller.get("dict_key", provider="test")
107 assert result == data
108
109
110async def test_set_and_get_list(cache_controller: CacheController) -> None:
111 """Test storing and retrieving a list value."""
112 data = [1, "two", 3.0, None, True]
113 await cache_controller.set("list_key", data, provider="test")
114 result = await cache_controller.get("list_key", provider="test")
115 assert result == data
116
117
118async def test_set_and_get_nested_structure(cache_controller: CacheController) -> None:
119 """Test storing and retrieving a deeply nested structure."""
120 data = {"items": [{"id": 1, "tags": ["a", "b"]}, {"id": 2, "tags": []}]}
121 await cache_controller.set("nested", data, provider="test")
122 result = await cache_controller.get("nested", provider="test")
123 assert result == data
124
125
126# --- JSON serialization guarantees ---
127
128
129async def test_data_always_deserialized_from_json(cache_controller: CacheController) -> None:
130 """Test that data is always returned as JSON-deserialized (no Python objects)."""
131 await cache_controller.set("list_data", [1, 2, 3], provider="test")
132 result = await cache_controller.get("list_data", provider="test")
133 assert isinstance(result, list)
134 assert result == [1, 2, 3]
135
136
137async def test_base_class_single_dict(cache_controller: CacheController) -> None:
138 """Test that base_class reconstructs a single dict into a model."""
139 await cache_controller.set("model", {"name": "test", "value": 42}, provider="test")
140 result = await cache_controller.get("model", provider="test", base_class=_FakeModel)
141 assert isinstance(result, _FakeModel)
142 assert result.name == "test"
143 assert result.value == 42
144
145
146async def test_base_class_list_of_dicts(cache_controller: CacheController) -> None:
147 """Test that base_class reconstructs each item in a list of dicts."""
148 await cache_controller.set("models", [{"name": "a"}, {"name": "b"}], provider="test")
149 result = await cache_controller.get("models", provider="test", base_class=_FakeModel)
150 assert isinstance(result, list)
151 assert len(result) == 2
152 assert all(isinstance(item, _FakeModel) for item in result)
153 assert result[0].name == "a"
154 assert result[1].name == "b"
155
156
157async def test_base_class_not_applied_to_none(cache_controller: CacheController) -> None:
158 """Test that base_class is not applied when cache returns default."""
159 result = await cache_controller.get("nonexistent", provider="test", base_class=_FakeModel)
160 assert result is None
161
162
163async def test_non_serializable_raises(cache_controller: CacheController) -> None:
164 """Test that non-serializable data raises on set."""
165 with pytest.raises(TypeError):
166 await cache_controller.set("bad", object(), provider="test") # type: ignore[arg-type]
167
168
169# --- Expiration ---
170
171
172async def test_expired_cache_returns_default(cache_controller: CacheController) -> None:
173 """Test that expired cache entries return the default value."""
174 await cache_controller.set("expiring", "data", provider="test", expiration=-1)
175 result = await cache_controller.get("expiring", provider="test", default="gone")
176 assert result == "gone"
177
178
179# --- Checksum validation ---
180
181
182async def test_checksum_match(cache_controller: CacheController) -> None:
183 """Test that data is returned when checksum matches."""
184 await cache_controller.set("ck", "val", provider="test", checksum="abc")
185 result = await cache_controller.get("ck", provider="test", checksum="abc")
186 assert result == "val"
187
188
189async def test_checksum_mismatch(cache_controller: CacheController) -> None:
190 """Test that default is returned when checksum doesn't match."""
191 await cache_controller.set("ck2", "val", provider="test", checksum="abc")
192 result = await cache_controller.get("ck2", provider="test", checksum="xyz", default="nope")
193 assert result == "nope"
194
195
196async def test_checksum_as_int(cache_controller: CacheController) -> None:
197 """Test that integer checksums are converted to strings."""
198 await cache_controller.set("ck3", "val", provider="test", checksum="123")
199 result = await cache_controller.get("ck3", provider="test", checksum=123)
200 assert result == "val"
201
202
203# --- Category and provider isolation ---
204
205
206async def test_different_providers_isolated(cache_controller: CacheController) -> None:
207 """Test that the same key in different providers returns different data."""
208 await cache_controller.set("key", "from_a", provider="prov_a")
209 await cache_controller.set("key", "from_b", provider="prov_b")
210 assert await cache_controller.get("key", provider="prov_a") == "from_a"
211 assert await cache_controller.get("key", provider="prov_b") == "from_b"
212
213
214async def test_different_categories_isolated(cache_controller: CacheController) -> None:
215 """Test that the same key in different categories returns different data."""
216 await cache_controller.set("key", "cat_1", provider="test", category=1)
217 await cache_controller.set("key", "cat_2", provider="test", category=2)
218 assert await cache_controller.get("key", provider="test", category=1) == "cat_1"
219 assert await cache_controller.get("key", provider="test", category=2) == "cat_2"
220
221
222# --- get_all bulk load ---
223
224
225async def test_get_all_returns_every_key_for_provider_and_category(
226 cache_controller: CacheController,
227) -> None:
228 """get_all returns a key -> data mapping for every entry under a provider/category."""
229 await cache_controller.set("a", "1", provider="prov", category=9)
230 await cache_controller.set("b", "2", provider="prov", category=9)
231 result = await cache_controller.get_all(provider="prov", category=9)
232 assert result == {"a": "1", "b": "2"}
233
234
235async def test_get_all_filters_by_provider(cache_controller: CacheController) -> None:
236 """get_all only returns entries for the requested provider."""
237 await cache_controller.set("shared_key", "from_a", provider="prov_a", category=1)
238 await cache_controller.set("shared_key", "from_b", provider="prov_b", category=1)
239 assert await cache_controller.get_all(provider="prov_a", category=1) == {"shared_key": "from_a"}
240 assert await cache_controller.get_all(provider="prov_b", category=1) == {"shared_key": "from_b"}
241
242
243async def test_get_all_filters_by_category(cache_controller: CacheController) -> None:
244 """get_all only returns entries for the requested category."""
245 await cache_controller.set("shared_key", "cat_1", provider="prov", category=1)
246 await cache_controller.set("shared_key", "cat_2", provider="prov", category=2)
247 assert await cache_controller.get_all(provider="prov", category=1) == {"shared_key": "cat_1"}
248 assert await cache_controller.get_all(provider="prov", category=2) == {"shared_key": "cat_2"}
249
250
251async def test_get_all_excludes_expired_entries(cache_controller: CacheController) -> None:
252 """get_all omits an entry once its expiration has passed, like get() does."""
253 await cache_controller.set("fresh", "keep", provider="prov", category=1, expiration=500)
254 await cache_controller.set("stale", "gone", provider="prov", category=1, expiration=-1)
255 assert await cache_controller.get_all(provider="prov", category=1) == {"fresh": "keep"}
256
257
258async def test_get_all_reconstructs_with_base_class(cache_controller: CacheController) -> None:
259 """get_all reconstructs each entry via base_class.from_dict(), like get() does."""
260 await cache_controller.set("m1", {"name": "a", "value": 1}, provider="prov", category=1)
261 await cache_controller.set("m2", {"name": "b", "value": 2}, provider="prov", category=1)
262 result = await cache_controller.get_all(provider="prov", category=1, base_class=_FakeModel)
263 assert set(result) == {"m1", "m2"}
264 assert all(isinstance(item, _FakeModel) for item in result.values())
265 assert result["m1"].name == "a"
266 assert result["m2"].value == 2
267
268
269async def test_get_all_empty_when_nothing_stored(cache_controller: CacheController) -> None:
270 """get_all returns an empty mapping for a provider/category with no entries."""
271 assert await cache_controller.get_all(provider="unknown", category=1) == {}
272
273
274# --- Delete and clear ---
275
276
277async def test_delete_specific_key(cache_controller: CacheController) -> None:
278 """Test deleting a specific cache entry."""
279 await cache_controller.set("del_me", "data", provider="test")
280 await cache_controller.delete("del_me", provider="test")
281 result = await cache_controller.get("del_me", provider="test", default="gone")
282 assert result == "gone"
283
284
285async def test_clear_removes_entries(cache_controller: CacheController) -> None:
286 """Test that clear removes all non-persistent entries."""
287 await cache_controller.set("a", "1", provider="test")
288 await cache_controller.set("b", "2", provider="test")
289 await cache_controller.clear()
290 assert await cache_controller.get("a", provider="test") is None
291 assert await cache_controller.get("b", provider="test") is None
292
293
294async def test_clear_preserves_persistent(cache_controller: CacheController) -> None:
295 """Test that clear preserves persistent entries."""
296 await cache_controller.set("persist", "keep", provider="test", persistent=True)
297 await cache_controller.set("temp", "drop", provider="test")
298 await cache_controller.clear()
299 assert await cache_controller.get("persist", provider="test") == "keep"
300 assert await cache_controller.get("temp", provider="test") is None
301
302
303async def test_clear_with_provider_filter(cache_controller: CacheController) -> None:
304 """Test that clear with provider filter only removes matching entries."""
305 await cache_controller.set("k", "v1", provider="spotify")
306 await cache_controller.set("k", "v2", provider="tidal")
307 await cache_controller.clear(provider_filter="spotify")
308 assert await cache_controller.get("k", provider="spotify") is None
309 assert await cache_controller.get("k", provider="tidal") == "v2"
310
311
312# --- Bypass ---
313
314
315async def test_bypass_cache(cache_controller: CacheController) -> None:
316 """Test that the bypass context manager skips cache reads."""
317 await cache_controller.set("bypass_key", "data", provider="test")
318 async with cache_controller.handle_refresh(bypass=True):
319 result = await cache_controller.get("bypass_key", provider="test", default="bypassed")
320 assert result == "bypassed"
321 # outside bypass, cache should still work
322 result = await cache_controller.get("bypass_key", provider="test")
323 assert result == "data"
324
325
326# --- Overwrite ---
327
328
329async def test_overwrite_existing_key(cache_controller: CacheController) -> None:
330 """Test that setting the same key overwrites the previous value."""
331 await cache_controller.set("ow", "old", provider="test")
332 await cache_controller.set("ow", "new", provider="test")
333 assert await cache_controller.get("ow", provider="test") == "new"
334
335
336# --- Empty key handling ---
337
338
339async def test_get_with_empty_key_raises(cache_controller: CacheController) -> None:
340 """Test that getting with empty key raises."""
341 with pytest.raises(AssertionError):
342 await cache_controller.get("", provider="test")
343
344
345async def test_set_with_empty_key_is_noop(cache_controller: CacheController) -> None:
346 """Test that setting with empty key is silently ignored."""
347 await cache_controller.set("", "data", provider="test")
348 # should not raise, just be a no-op
349
350
351# --- Oversized cache detection ---
352
353
354async def test_cache_warns_when_exceeding_limit(
355 mass_minimal: MusicAssistant,
356 caplog: pytest.LogCaptureFixture,
357) -> None:
358 """Test that a warning is logged (and files kept) when the db exceeds the limit."""
359 cache = mass_minimal.cache
360 db_files = await _create_db_files(mass_minimal.cache_path)
361
362 with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread:
363
364 async def _side_effect(func: Callable[..., Any], *args: Any) -> Any:
365 if getattr(func, "__name__", "") == "_get_db_size":
366 return float(MAX_CACHE_DB_SIZE_MB + 100)
367 return func(*args)
368
369 mock_to_thread.side_effect = _side_effect
370 await cache._check_oversized_cache()
371
372 assert "exceeds recommended maximum" in caplog.text
373 for path in db_files:
374 assert Path(path).exists()
375
376
377async def test_cache_does_not_warn_when_under_limit(
378 mass_minimal: MusicAssistant,
379 caplog: pytest.LogCaptureFixture,
380) -> None:
381 """Test that no warning is logged when the db is under the limit."""
382 cache = mass_minimal.cache
383 db_files = await _create_db_files(mass_minimal.cache_path)
384
385 with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_to_thread:
386
387 async def _side_effect(func: Callable[..., Any], *args: Any) -> Any:
388 if getattr(func, "__name__", "") == "_get_db_size":
389 return 1.0
390 return func(*args)
391
392 mock_to_thread.side_effect = _side_effect
393 await cache._check_oversized_cache()
394
395 assert "exceeds recommended maximum" not in caplog.text
396 for path in db_files:
397 assert Path(path).exists()
398
399
400async def test_all_three_db_files_included_in_size(
401 mass_minimal: MusicAssistant,
402 caplog: pytest.LogCaptureFixture,
403) -> None:
404 """Test that cache.db, cache.db-wal, and cache.db-shm are all summed for size check."""
405 cache = mass_minimal.cache
406 db_path = os.path.join(mass_minimal.cache_path, "cache.db")
407
408 for suffix in ("", "-wal", "-shm"):
409 async with aiofiles.open(db_path + suffix, "wb") as f:
410 await f.write(b"\0" * 100)
411
412 size_threshold_mb = 0.0002
413 with patch(
414 "music_assistant.controllers.cache.controller.MAX_CACHE_DB_SIZE_MB", size_threshold_mb
415 ):
416 await cache._check_oversized_cache()
417
418 assert "exceeds recommended maximum" in caplog.text
419 for suffix in ("", "-wal", "-shm"):
420 assert Path(db_path + suffix).exists()
421
422
423# --- allow_expired_cache flag ---
424
425
426async def test_get_with_allow_expired_cache_returns_expired_data(
427 cache_controller: CacheController,
428) -> None:
429 """Test that get(allow_expired_cache=True) returns data past its expiration."""
430 await cache_controller.set("stale", "old_data", provider="test", expiration=-1)
431 assert await cache_controller.get("stale", provider="test", default="gone") == "gone"
432 assert (
433 await cache_controller.get(
434 "stale", provider="test", default="gone", allow_expired_cache=True
435 )
436 == "old_data"
437 )
438
439
440async def test_get_with_allow_expired_cache_still_returns_default_when_missing(
441 cache_controller: CacheController,
442) -> None:
443 """Test that allow_expired_cache=True still returns default when nothing is cached."""
444 result = await cache_controller.get(
445 "nonexistent", provider="test", default="gone", allow_expired_cache=True
446 )
447 assert result == "gone"
448
449
450async def test_auto_cleanup_removes_expired_entries(cache_controller: CacheController) -> None:
451 """Test that auto_cleanup removes expired entries by default."""
452 await cache_controller.set("evict", "data", provider="test", expiration=-1)
453 await cache_controller.auto_cleanup()
454 result = await cache_controller.get(
455 "evict", provider="test", default="gone", allow_expired_cache=True
456 )
457 assert result == "gone"
458
459
460async def test_auto_cleanup_keeps_allow_expired_cache_entries(
461 cache_controller: CacheController,
462) -> None:
463 """Test that auto_cleanup keeps expired entries with allow_expired_cache=True."""
464 await cache_controller.set(
465 "keep", "data", provider="test", expiration=-1, allow_expired_cache=True
466 )
467 await cache_controller.auto_cleanup()
468 result = await cache_controller.get("keep", provider="test", allow_expired_cache=True)
469 assert result == "data"
470
471
472async def test_auto_cleanup_keeps_fresh_entries(cache_controller: CacheController) -> None:
473 """Test that auto_cleanup keeps fresh entries regardless of the flag."""
474 await cache_controller.set("alive", "data", provider="test", expiration=3600)
475 await cache_controller.auto_cleanup()
476 assert await cache_controller.get("alive", provider="test") == "data"
477
478
479async def test_auto_cleanup_scans_all_records(cache_controller: CacheController) -> None:
480 """
481 Test that auto_cleanup removes expired entries beyond the row-fetch page size.
482
483 Regression test: cleanup previously fetched rows via the default 500-row limit,
484 so large caches kept most of their expired entries forever.
485 """
486 expired_count = 1200
487 for i in range(expired_count):
488 await cache_controller.set(f"expired_{i}", "data", provider="test", expiration=-1)
489 await cache_controller.set("fresh", "data", provider="test", expiration=3600)
490
491 await cache_controller.auto_cleanup()
492
493 assert cache_controller.database is not None
494 assert await cache_controller.database.get_count(DB_TABLE_CACHE) == 1
495 assert await cache_controller.get("fresh", provider="test") == "data"
496
497
498# --- Startup vacuum ---
499
500
501async def test_setup_skips_vacuum_when_little_reclaimable(
502 mass_minimal: MusicAssistant,
503) -> None:
504 """Test that the startup vacuum is skipped when little space can be reclaimed."""
505 cache = mass_minimal.cache
506 with (
507 patch.object(
508 DatabaseConnection,
509 "get_reclaimable_ratio",
510 AsyncMock(return_value=VACUUM_MIN_RECLAIM_RATIO / 2),
511 ),
512 patch.object(DatabaseConnection, "vacuum", AsyncMock()) as mock_vacuum,
513 ):
514 await cache._setup_database()
515 mock_vacuum.assert_not_called()
516
517
518async def test_setup_runs_vacuum_when_reclaimable(
519 mass_minimal: MusicAssistant,
520) -> None:
521 """Test that the startup vacuum runs when enough space can be reclaimed."""
522 cache = mass_minimal.cache
523 with (
524 patch.object(
525 DatabaseConnection,
526 "get_reclaimable_ratio",
527 AsyncMock(return_value=VACUUM_MIN_RECLAIM_RATIO + 0.1),
528 ),
529 patch.object(DatabaseConnection, "vacuum", AsyncMock()) as mock_vacuum,
530 ):
531 await cache._setup_database()
532 mock_vacuum.assert_awaited_once_with()
533
534
535# --- upsert (in-place write) ---
536
537
538async def test_set_upserts_in_place(cache_controller: CacheController) -> None:
539 """Test that overwriting a key updates the row in place instead of replacing it."""
540 assert cache_controller.database is not None
541 await cache_controller.set("k", "v1", provider="test")
542 row = await cache_controller.database.get_row(
543 DB_TABLE_CACHE, {"category": 0, "provider": "test", "key": "k"}
544 )
545 assert row is not None
546 row_id = row["id"]
547
548 await cache_controller.set("k", "v2", provider="test")
549 assert await cache_controller.get("k", provider="test") == "v2"
550 # a single row that kept its id â an INSERT OR REPLACE would delete it and assign a new id
551 assert await cache_controller.database.get_count(DB_TABLE_CACHE) == 1
552 row = await cache_controller.database.get_row(
553 DB_TABLE_CACHE, {"category": 0, "provider": "test", "key": "k"}
554 )
555 assert row is not None
556 assert row["id"] == row_id
557
558
559# --- secondary indexes ---
560
561
562async def _index_names(cache_controller: CacheController) -> set[str]:
563 """Return the names of all indexes on the cache table."""
564 assert cache_controller.database is not None
565 rows = await cache_controller.database.get_rows_from_query(
566 "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = :table",
567 {"table": DB_TABLE_CACHE},
568 )
569 return {str(row["name"]) for row in rows}
570
571
572async def test_only_key_provider_index_is_created(cache_controller: CacheController) -> None:
573 """Test that only the (key, provider) secondary index is created besides the autoindex."""
574 names = await _index_names(cache_controller)
575 assert f"{DB_TABLE_CACHE}_key_provider_idx" in names
576 # the UNIQUE(category, key, provider) constraint provides an autoindex
577 assert any(name.startswith("sqlite_autoindex") for name in names)
578 # the redundant indexes are not (re)created
579 for removed in (
580 "category_idx",
581 "key_idx",
582 "provider_idx",
583 "category_key_idx",
584 "category_provider_idx",
585 "category_key_provider_idx",
586 ):
587 assert f"{DB_TABLE_CACHE}_{removed}" not in names
588
589
590async def test_migration_drops_redundant_indexes(mass_minimal: MusicAssistant) -> None:
591 """Test that opening a pre-v9 database migrates cleanly and drops the redundant indexes."""
592 db_path = os.path.join(mass_minimal.cache_path, "cache.db")
593 redundant = (
594 ("category_idx", "category"),
595 ("key_idx", "key"),
596 ("provider_idx", "provider"),
597 ("category_key_idx", "category,key"),
598 ("category_provider_idx", "category,provider"),
599 ("category_key_provider_idx", "category,key,provider"),
600 ("key_provider_idx", "key,provider"),
601 )
602 # build a v8 database with the old (full) index set and one row
603 old_db = DatabaseConnection(db_path)
604 await old_db.setup()
605 await old_db.execute(
606 f"CREATE TABLE {DB_TABLE_SETTINGS}(key TEXT PRIMARY KEY, value TEXT, type TEXT)"
607 )
608 await old_db.execute(
609 f"""CREATE TABLE {DB_TABLE_CACHE}(
610 [id] INTEGER PRIMARY KEY AUTOINCREMENT,
611 [category] INTEGER NOT NULL DEFAULT 0,
612 [key] TEXT NOT NULL,
613 [provider] TEXT NOT NULL,
614 [expires] INTEGER NOT NULL,
615 [data] TEXT NULL,
616 [checksum] TEXT NULL,
617 [persistent] INTEGER NOT NULL DEFAULT 0,
618 [allow_expired_cache] INTEGER NOT NULL DEFAULT 0,
619 UNIQUE(category, key, provider)
620 )"""
621 )
622 for suffix, columns in redundant:
623 await old_db.execute(
624 f"CREATE INDEX {DB_TABLE_CACHE}_{suffix} ON {DB_TABLE_CACHE}({columns})"
625 )
626 await old_db.execute(
627 f"INSERT INTO {DB_TABLE_SETTINGS}(key, value, type) VALUES ('version', '8', 'str')"
628 )
629 await old_db.execute(
630 f"INSERT INTO {DB_TABLE_CACHE}(category, key, provider, expires, data) "
631 "VALUES (0, 'kept', 'test', 9999999999, '\"payload\"')"
632 )
633 await old_db.commit()
634 await old_db.close()
635
636 # opening the controller runs the migration
637 await mass_minimal.cache._setup_database()
638 cache = mass_minimal.cache
639 assert cache.database is not None
640
641 version_row = await cache.database.get_row(DB_TABLE_SETTINGS, {"key": "version"})
642 assert version_row is not None
643 assert version_row["value"] == str(DB_SCHEMA_VERSION)
644
645 names = await _index_names(cache)
646 assert f"{DB_TABLE_CACHE}_key_provider_idx" in names
647 for suffix, _ in redundant[:-1]: # every index except key_provider is dropped
648 assert f"{DB_TABLE_CACHE}_{suffix}" not in names
649
650 # existing data survived the migration
651 assert await cache.get("kept", provider="test") == "payload"
652
653
654# --- stale-while-revalidate cleanup ---
655
656
657async def test_auto_cleanup_removes_stale_swr_rows(cache_controller: CacheController) -> None:
658 """Test that auto_cleanup removes SWR fallback rows expired beyond the grace window."""
659 # expired but within the grace window -> kept as fallback
660 await cache_controller.set(
661 "recent", "data", provider="test", expiration=-1, allow_expired_cache=True
662 )
663 # expired well beyond the grace window -> removed
664 await cache_controller.set(
665 "ancient",
666 "data",
667 provider="test",
668 expiration=-(SWR_FALLBACK_MAX_AGE + 86400),
669 allow_expired_cache=True,
670 )
671 await cache_controller.auto_cleanup()
672
673 assert await cache_controller.get("recent", provider="test", allow_expired_cache=True) == "data"
674 assert (
675 await cache_controller.get(
676 "ancient", provider="test", default="gone", allow_expired_cache=True
677 )
678 == "gone"
679 )
680