feat: phase 2 content model + cache — SQLite schema, markdown, TTL
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.
This commit is contained in:
76
tests/test_cache.py
Normal file
76
tests/test_cache.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user