Files
chicken_babies_site/tests/test_cache.py
Phillip Tarrant 0306f71763 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.
2026-04-21 15:40:35 -05:00

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"]