"""Unit tests for app.cli. Sub-command parsing only — the DB-touching paths (`grant_credit`, `revoke_credit`, `show_status`) are exercised manually inside the dev container. The parser-level tests are enough to catch the common shapes: bad args, missing args, unknown sub-command.""" from __future__ import annotations import asyncio import pytest from app.cli import build_parser def test_grant_credit_parses(): args = build_parser().parse_args(["grant-credit", "user@example.com", "3"]) assert args.cmd == "grant-credit" assert args.email == "user@example.com" assert args.months == 3.0 def test_grant_credit_accepts_fractional_months(): args = build_parser().parse_args(["grant-credit", "user@x.com", "0.5"]) assert args.months == 0.5 def test_revoke_credit_parses(): args = build_parser().parse_args(["revoke-credit", "user@example.com"]) assert args.cmd == "revoke-credit" assert args.email == "user@example.com" def test_show_status_parses(): args = build_parser().parse_args(["show-status", "user@example.com"]) assert args.cmd == "show-status" def test_grant_credit_requires_months(): with pytest.raises(SystemExit): build_parser().parse_args(["grant-credit", "user@example.com"]) def test_unknown_command_rejected(): with pytest.raises(SystemExit): build_parser().parse_args(["bogus-cmd"]) def test_no_command_rejected(): with pytest.raises(SystemExit): build_parser().parse_args([]) # --- purge-test-users ------------------------------------------------------ def test_purge_parses_dry_run_default(): args = build_parser().parse_args(["purge-test-users"]) assert args.cmd == "purge-test-users" assert args.commit is False assert args.keep is None def test_purge_parses_keep_and_commit(): args = build_parser().parse_args( ["purge-test-users", "--keep", "a@x", "--keep", "b@x", "--commit"]) assert args.keep == ["a@x", "b@x"] assert args.commit is True def _seed_users(tmp_path): """Two keepers + one throwaway with child rows across every table.""" from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as db_mod from app.db import Base, utcnow from app.models import ( EmailOTP, EmailSend, PortfolioSync, Referral, StrategicLogFeedback, User, UserAcknowledgement, ) engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/cli.db") factory = async_sessionmaker(engine, expire_on_commit=False) db_mod._engine = engine db_mod._session_factory = factory now = utcnow() async def _go(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with factory() as s: s.add(User(id=1, email="keep@real.com", tier="paid", created_at=now)) s.add(User(id=2, email="also@real.com", tier="free", created_at=now, referred_by_user_id=3)) # referred by a victim s.add(User(id=3, email="junk-123@gilest.ro", tier="free", created_at=now)) await s.flush() s.add(EmailSend(user_id=3, kind="daily", sent_at=now, status="sent")) s.add(PortfolioSync(user_id=3, outer_ciphertext=b"x", outer_nonce=b"y", version=1, created_at=now, updated_at=now)) s.add(UserAcknowledgement(user_id=3, version=1, lang="en", accepted_at=now)) s.add(StrategicLogFeedback(log_id=1, user_id=3, vote="up", created_at=now, updated_at=now)) s.add(Referral(referrer_user_id=1, referred_user_id=3, created_at=now)) s.add(EmailOTP(email="junk-123@gilest.ro", code_hash="h", created_at=now, expires_at=now)) await s.commit() asyncio.run(_go()) return factory def _count(factory, model): from sqlalchemy import func, select async def _go(): async with factory() as s: return (await s.execute(select(func.count()).select_from(model))).scalar() return asyncio.run(_go()) def test_purge_dry_run_deletes_nothing(tmp_path): from app.cli import purge_test_users from app.models import User factory = _seed_users(tmp_path) rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], commit=False)) assert rc == 0 assert _count(factory, User) == 3 # untouched def test_purge_commit_removes_victim_and_children(tmp_path): from sqlalchemy import select from app.cli import purge_test_users from app.models import ( EmailOTP, EmailSend, PortfolioSync, Referral, StrategicLogFeedback, User, UserAcknowledgement, ) factory = _seed_users(tmp_path) rc = asyncio.run(purge_test_users(["keep@real.com", "also@real.com"], commit=True)) assert rc == 0 # Only the two keepers remain. async def _remaining(): async with factory() as s: emails = (await s.execute(select(User.email).order_by(User.id))).scalars().all() survivor = (await s.execute( select(User.referred_by_user_id).where(User.id == 2))).scalar() return emails, survivor emails, survivor = asyncio.run(_remaining()) assert emails == ["keep@real.com", "also@real.com"] # Self-ref FK to the deleted user was nulled, not left dangling. assert survivor is None # Every child table row for the victim is gone. for model in (EmailSend, PortfolioSync, UserAcknowledgement, StrategicLogFeedback, Referral, EmailOTP): assert _count(factory, model) == 0, model.__name__