Stand up the full SQLite content layer: all 7 tables from the authoritative schema with WAL + foreign-keys enforced per-connection, entity dataclasses plus row mappers, hand-rolled versioned migrations tracked in schema_migrations, and an idempotent Python seed (system user + welcome post + About page). Add a Markdown->HTML service using markdown-it-py with a strict bleach allowlist (tables intentionally omitted on both sides). Add a typed in-process TTLCache[K,V] and wire it into real DB-backed PostService and PageService, both exposing invalidate_all() for Phase 4 admin writes. Rewire / and /about to read from the DB; homepage renders the seeded welcome post, About renders page.title + sanitized body_html_cached. Update the Phase 1 route tests accordingly. Mark Phase 2 complete in docs/ROADMAP.md.
77 lines
2.3 KiB
Python
77 lines
2.3 KiB
Python
"""Tests for the in-process TTL cache.
|
|
|
|
Covers:
|
|
|
|
- stored values round-trip via ``get`` before TTL expiry
|
|
- entries expire after TTL elapses
|
|
- ``invalidate_all`` drops every entry
|
|
- construction rejects a non-positive TTL
|
|
- the cache is typed-generic (spot check at runtime that multiple
|
|
concrete types work — the real type safety comes from static
|
|
checking, which isn't part of the runtime suite)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from app.services.cache import TTLCache
|
|
|
|
|
|
def test_set_then_get_returns_stored_value() -> None:
|
|
"""A value stored via ``set`` is visible to ``get`` until expiry."""
|
|
cache: TTLCache[str, int] = TTLCache(ttl_seconds=5.0)
|
|
cache.set("answer", 42)
|
|
assert cache.get("answer") == 42
|
|
|
|
|
|
def test_get_returns_none_for_missing_key() -> None:
|
|
"""Absent keys return ``None`` cleanly (no KeyError)."""
|
|
cache: TTLCache[str, str] = TTLCache(ttl_seconds=5.0)
|
|
assert cache.get("nope") is None
|
|
|
|
|
|
def test_entries_expire_after_ttl() -> None:
|
|
"""An entry past its TTL is treated as absent.
|
|
|
|
Uses a tiny TTL + ``time.sleep`` rather than mocking
|
|
``time.monotonic`` so the test exercises the real code path.
|
|
"""
|
|
cache: TTLCache[str, str] = TTLCache(ttl_seconds=0.05)
|
|
cache.set("k", "v")
|
|
time.sleep(0.1)
|
|
assert cache.get("k") is None
|
|
|
|
|
|
def test_invalidate_all_clears_everything() -> None:
|
|
"""``invalidate_all`` drops every entry regardless of TTL."""
|
|
cache: TTLCache[str, int] = TTLCache(ttl_seconds=60.0)
|
|
cache.set("a", 1)
|
|
cache.set("b", 2)
|
|
cache.invalidate_all()
|
|
assert cache.get("a") is None
|
|
assert cache.get("b") is None
|
|
|
|
|
|
def test_non_positive_ttl_is_rejected() -> None:
|
|
"""Zero/negative TTL raises at construction time.
|
|
|
|
A zero TTL would make every write immediately expire, which is
|
|
almost certainly a bug; the defensive check turns it into a loud
|
|
failure.
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
TTLCache(ttl_seconds=0.0)
|
|
with pytest.raises(ValueError):
|
|
TTLCache(ttl_seconds=-1.0)
|
|
|
|
|
|
def test_cache_works_with_int_keys_and_list_values() -> None:
|
|
"""Runtime smoke: generic over both ``K`` and ``V``."""
|
|
cache: TTLCache[int, list[str]] = TTLCache(ttl_seconds=5.0)
|
|
cache.set(10, ["a", "b"])
|
|
stored = cache.get(10)
|
|
assert stored == ["a", "b"]
|