Every test module carried the same eight-line prologue redirecting the database to a temp file. Only the first one to be imported ever took effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine` from it once, so by the time the second module ran the engine already existed. The other 34 copies created a temp file that nothing opened and nothing deleted, and leaked one per module per run. `conftest.py` now does it once, which is early enough because pytest imports conftest before any test module. It also deletes the file when the run ends. The tests still share one database, exactly as they already did: each `client` fixture calls `create_all` on setup and `drop_all` on teardown, so no test sees another test's rows. `tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a copy, and the copies had drifted into four feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported that. The shared one is the superset. The two `FakeProvider` copies were the same class with a fixed reply, so they use it too. `test_chat.py` keeps its own, which implements `chat` rather than `generate` and records what it was constructed with. An autouse fixture resets the fake's class state between tests, so a stale reply list can no longer reach the next test. 435 lines out of the suite. 549 tests pass. Verified live by sabotage: breaking the shared fake fails 13 tests across four modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""Guest retention policy: app/cleanup.py.
|
|
|
|
These tests cover the two things that matter. Idle guests and their whole
|
|
data graph must actually be deleted, and nothing else must ever be
|
|
deleted.
|
|
|
|
python -m pytest tests/test_guest_cleanup.py -v
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app import cleanup, models
|
|
from app.database import Base
|
|
from app.migrations import bootstrap
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
engine = create_engine(f"sqlite:///{tmp_path/'t.db'}", connect_args={"check_same_thread": False})
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _fk(dbapi_connection, _record):
|
|
# The whole policy relies on ON DELETE CASCADE. SQLite ignores every
|
|
# one of them unless this is set, the same as database.py does.
|
|
cur = dbapi_connection.cursor()
|
|
cur.execute("PRAGMA foreign_keys=ON")
|
|
cur.close()
|
|
|
|
bootstrap(engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
|
yield session
|
|
session.close()
|
|
|
|
|
|
NOW = models.utcnow().replace(tzinfo=None)
|
|
|
|
|
|
def make_user(db, *, days_idle=None, days_old=0, guest=True, email=None):
|
|
"""A user last seen `days_idle` ago (None = never seen, only created)."""
|
|
user = models.User(
|
|
is_guest=guest,
|
|
email=email,
|
|
password_hash=None if email is None else "x",
|
|
created_at=NOW - timedelta(days=days_old),
|
|
last_seen_at=None if days_idle is None else NOW - timedelta(days=days_idle),
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
return user
|
|
|
|
|
|
def sweep(db):
|
|
return cleanup.delete_stale_guests(db, now=NOW)
|
|
|
|
|
|
def alive(db, user_id):
|
|
# A count, not db.get: the sweep deletes with synchronize_session=False, so
|
|
# the session's identity map still holds the object and db.get would answer
|
|
# from memory without ever asking the database.
|
|
return db.query(models.User).filter(models.User.id == user_id).count() == 1
|
|
|
|
|
|
# ---------- what goes ----------
|
|
|
|
def test_deletes_guest_idle_past_the_window(db):
|
|
user = make_user(db, days_idle=6)
|
|
assert sweep(db) == 1
|
|
assert not alive(db, user.id)
|
|
|
|
|
|
def test_keeps_guest_inside_the_window(db):
|
|
user = make_user(db, days_idle=4)
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_boundary_is_not_yet_stale(db):
|
|
# Exactly 5 days survives. The comparison is strict.
|
|
user = make_user(db, days_idle=cleanup.RETENTION_DAYS)
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_never_seen_guest_falls_back_to_created_at(db):
|
|
"""last_seen_at is NULL until a guest's second request (auth._touch runs
|
|
hourly), so a coalesce-less query would delete brand-new visitors."""
|
|
fresh = make_user(db, days_idle=None, days_old=0)
|
|
stale = make_user(db, days_idle=None, days_old=9)
|
|
assert sweep(db) == 1
|
|
assert alive(db, fresh.id)
|
|
assert not alive(db, stale.id)
|
|
|
|
|
|
def test_recent_visit_beats_an_old_created_at(db):
|
|
# A long-standing guest who came back yesterday stays.
|
|
user = make_user(db, days_idle=1, days_old=90)
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
# ---------- what must never go ----------
|
|
|
|
def test_spares_registered_users(db):
|
|
"""Registering upgrades the guest row in place. An idle account here is
|
|
a real user with real data, which is what signing up is meant to
|
|
protect."""
|
|
user = make_user(db, days_idle=400, guest=False, email="a@b.com")
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_spares_the_local_mode_user(db):
|
|
# email NULL but is_guest False: local mode's implicit owner of everything.
|
|
user = make_user(db, days_idle=400, guest=False)
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_spares_a_guest_flagged_row_that_has_an_email(db):
|
|
# This row should not exist, but both clauses are checked so it cannot be collected.
|
|
user = make_user(db, days_idle=400, guest=True, email="odd@b.com")
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_leaves_seeded_public_scenarios_alone(db):
|
|
"""Seeded demo content has user_id NULL, so it is outside the filter."""
|
|
seeded = models.Scenario(user_id=None, is_public=True, title="Demo")
|
|
db.add(seeded)
|
|
make_user(db, days_idle=30)
|
|
db.commit()
|
|
assert sweep(db) == 1
|
|
assert db.query(models.Scenario).filter(models.Scenario.id == seeded.id).count() == 1
|
|
|
|
|
|
def test_disabled_when_retention_is_zero(db, monkeypatch):
|
|
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
|
|
user = make_user(db, days_idle=999)
|
|
assert sweep(db) == 0
|
|
assert alive(db, user.id)
|
|
|
|
|
|
def test_enabled_requires_multi_user(monkeypatch):
|
|
from app import auth
|
|
monkeypatch.setattr(auth, "MULTI_USER", False)
|
|
assert cleanup.enabled() is False
|
|
monkeypatch.setattr(auth, "MULTI_USER", True)
|
|
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 5)
|
|
assert cleanup.enabled() is True
|
|
monkeypatch.setattr(cleanup, "RETENTION_DAYS", 0)
|
|
assert cleanup.enabled() is False
|
|
|
|
|
|
# ---------- the cascade ----------
|
|
|
|
def test_deletes_the_whole_data_graph(db):
|
|
"""One DELETE must remove the adventure, its actions and memories, the
|
|
story cards, and the settings row. Nothing is loaded into Python, so if
|
|
the FK cascade does not reach a table, its rows are silently orphaned,
|
|
or the statement fails, instead of being removed."""
|
|
user = make_user(db, days_idle=30)
|
|
scenario = models.Scenario(user_id=user.id, title="S")
|
|
db.add(scenario)
|
|
db.commit()
|
|
adventure = models.Adventure(user_id=user.id, scenario_id=scenario.id, title="A")
|
|
db.add(adventure)
|
|
db.commit()
|
|
db.add_all([
|
|
models.Action(adventure_id=adventure.id, index=0, type="ai", text="t"),
|
|
models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0),
|
|
models.StoryCard(adventure_id=adventure.id, name="c"),
|
|
models.Settings(user_id=user.id),
|
|
])
|
|
db.commit()
|
|
|
|
assert sweep(db) == 1
|
|
|
|
for model in (models.Scenario, models.Adventure, models.Action,
|
|
models.Memory, models.StoryCard, models.Settings):
|
|
assert db.query(model).count() == 0, f"{model.__name__} rows survived"
|
|
|
|
|
|
def test_one_users_cleanup_does_not_touch_another(db):
|
|
keeper = make_user(db, days_idle=1)
|
|
keep_adv = models.Adventure(user_id=keeper.id, title="mine")
|
|
goner = make_user(db, days_idle=30)
|
|
db.add_all([keep_adv, models.Adventure(user_id=goner.id, title="theirs")])
|
|
db.commit()
|
|
|
|
assert sweep(db) == 1
|
|
remaining = db.query(models.Adventure).all()
|
|
assert [a.title for a in remaining] == ["mine"]
|
|
|
|
|
|
def test_sweep_swallows_errors(monkeypatch):
|
|
"""A broken cleanup must not take the app down (same rule as seeding)."""
|
|
monkeypatch.setattr(cleanup.auth, "MULTI_USER", True)
|
|
monkeypatch.setattr(cleanup, "delete_stale_guests",
|
|
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
assert cleanup.sweep() == 0
|