* Rewrite comments in Google developer documentation style Rewrite the comments and docstrings across the backend core modules so they read plainly. The previous prose was accurate but dense and figurative, which made it slow to skim. Applies the Google developer documentation style guide: short sentences, active voice, present tense, American spelling, and no metaphors, idioms, or rhetorical asides. Replaces em-dash chains with separate sentences.
213 lines
7.1 KiB
Python
213 lines
7.1 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
|
|
"""
|
|
import os
|
|
import tempfile
|
|
from datetime import timedelta
|
|
|
|
_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
_tmp.close()
|
|
os.environ["AIDND_DB_PATH"] = _tmp.name
|
|
os.environ.pop("AIDND_DATABASE_URL", None)
|
|
os.environ.pop("DATABASE_URL", None)
|
|
|
|
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
|