Networking: the superadmin console now mirrors `app` instead of a loopback-only host port. Base compose drops the host port; the dev override binds 127.0.0.1:8091; the prod overlay joins the `intranet` network and listens on :80 with --proxy-headers so NPM can proxy it. CLI: add `purge-test-users` (dry-run by default, --commit to delete, --keep allow-list defaulting to the real accounts). Deletes child rows explicitly (DB-agnostic) plus email-keyed OTPs, so smoke-test signups that were pointed at prod can be cleaned repeatably instead of via ad-hoc SQL. Covered by 6 new tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
5.7 KiB
Python
164 lines
5.7 KiB
Python
"""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__
|