293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""
|
|
tests/test_admin_phase2.py
|
|
Phase 2 admin portal tests: system users, plans, tenants,
|
|
billing, settings override, audit log, analytics.
|
|
Uses the testing app with SQLite in-memory DB.
|
|
"""
|
|
|
|
import pytest
|
|
from app.extensions import db as _db, bcrypt as _bcrypt
|
|
from app.models.platform import SystemUser, Plan, Tenant, TenantBillingHistory, TenantSettingOverride, AuditLog
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _login_admin(client, email="admin@test.com", password="AdminPass1A"):
|
|
return client.post("/login", data={"email": email, "password": password},
|
|
follow_redirects=True)
|
|
|
|
|
|
def _create_admin(app, email="admin@test.com", password="AdminPass1A"):
|
|
with app.app_context():
|
|
if SystemUser.query.filter_by(email=email).first():
|
|
return
|
|
u = SystemUser(
|
|
email=email,
|
|
password_hash=_bcrypt.generate_password_hash(password).decode("utf-8"),
|
|
name="Test Admin", role="superadmin", is_active=True,
|
|
)
|
|
_db.session.add(u)
|
|
_db.session.commit()
|
|
|
|
|
|
def _create_tenant(app, slug="test-salon"):
|
|
with app.app_context():
|
|
plan = Plan.query.first()
|
|
if Tenant.query.filter_by(slug=slug).first():
|
|
return
|
|
t = Tenant(slug=slug, name="Test Salon", owner_email="owner@test.com",
|
|
plan_id=plan.id, status="trial")
|
|
_db.session.add(t)
|
|
_db.session.commit()
|
|
return t.id
|
|
|
|
|
|
# ── System Users ──────────────────────────────────────────────────────────────
|
|
|
|
class TestSystemUsers:
|
|
def test_index_requires_login(self, admin_client):
|
|
resp = admin_client.get("/system-users/", follow_redirects=False)
|
|
assert resp.status_code in (302, 401)
|
|
|
|
def test_index_accessible_to_admin(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/system-users/")
|
|
assert resp.status_code == 200
|
|
|
|
def test_create_system_user(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post("/system-users/new", data={
|
|
"email": "newadmin@test.com", "name": "New Admin",
|
|
"role": "superadmin", "password": "NewAdmin1A",
|
|
"confirm_password": "NewAdmin1A",
|
|
}, follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
assert SystemUser.query.filter_by(email="newadmin@test.com").first()
|
|
|
|
def test_create_user_weak_password_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post("/system-users/new", data={
|
|
"email": "weak@test.com", "name": "Weak", "role": "superadmin",
|
|
"password": "weak", "confirm_password": "weak",
|
|
}, follow_redirects=True)
|
|
assert b"password" in resp.data.lower()
|
|
with admin_app.app_context():
|
|
assert SystemUser.query.filter_by(email="weak@test.com").first() is None
|
|
|
|
def test_create_user_duplicate_email_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post("/system-users/new", data={
|
|
"email": "admin@test.com", "name": "Dupe", "role": "superadmin",
|
|
"password": "AdminPass1A", "confirm_password": "AdminPass1A",
|
|
}, follow_redirects=True)
|
|
assert b"already exists" in resp.data
|
|
|
|
|
|
# ── Plans ─────────────────────────────────────────────────────────────────────
|
|
|
|
class TestPlans:
|
|
def test_plans_index(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/plans/")
|
|
assert resp.status_code == 200
|
|
assert b"Starter" in resp.data # seeded in conftest
|
|
|
|
def test_create_plan(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post("/plans/new", data={
|
|
"name": "Enterprise", "price_monthly": "199.00",
|
|
"max_staff": "", "max_locations": "",
|
|
"feature_pos": "1", "feature_marketing": "1",
|
|
"is_active": "1",
|
|
}, follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
assert Plan.query.filter_by(name="Enterprise").first()
|
|
|
|
def test_plan_missing_name_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post("/plans/new", data={
|
|
"name": "", "price_monthly": "99",
|
|
}, follow_redirects=True)
|
|
assert b"required" in resp.data.lower()
|
|
|
|
|
|
# ── Tenants ───────────────────────────────────────────────────────────────────
|
|
|
|
class TestTenants:
|
|
def test_tenants_index(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/tenants/")
|
|
assert resp.status_code == 200
|
|
|
|
def test_create_tenant(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
with admin_app.app_context():
|
|
plan = Plan.query.first()
|
|
resp = admin_client.post("/tenants/new", data={
|
|
"slug": "brand-new-salon", "name": "Brand New Salon",
|
|
"owner_email": "owner@brandnew.com", "plan_id": str(plan.id),
|
|
"trial_days": "14",
|
|
"owner_password": "OwnerPass1A", "confirm_password": "OwnerPass1A",
|
|
}, follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
assert Tenant.query.filter_by(slug="brand-new-salon").first()
|
|
|
|
def test_duplicate_slug_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_create_tenant(admin_app, slug="dup-slug")
|
|
_login_admin(admin_client)
|
|
with admin_app.app_context():
|
|
plan = Plan.query.first()
|
|
resp = admin_client.post("/tenants/new", data={
|
|
"slug": "dup-slug", "name": "Dup", "owner_email": "dup@test.com",
|
|
"plan_id": str(plan.id), "owner_password": "OwnerPass1A",
|
|
"confirm_password": "OwnerPass1A",
|
|
}, follow_redirects=True)
|
|
assert b"already taken" in resp.data
|
|
|
|
def test_invalid_slug_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
with admin_app.app_context():
|
|
plan = Plan.query.first()
|
|
resp = admin_client.post("/tenants/new", data={
|
|
"slug": "INVALID SLUG!", "name": "Bad", "owner_email": "bad@test.com",
|
|
"plan_id": str(plan.id), "owner_password": "OwnerPass1A",
|
|
"confirm_password": "OwnerPass1A",
|
|
}, follow_redirects=True)
|
|
assert b"slug" in resp.data.lower()
|
|
|
|
def test_tenant_detail_accessible(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
tid = _create_tenant(admin_app, slug="detail-salon")
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get(f"/tenants/{tid}")
|
|
assert resp.status_code == 200
|
|
|
|
def test_set_status_suspended(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
tid = _create_tenant(admin_app, slug="suspend-salon")
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post(f"/tenants/{tid}/set-status",
|
|
data={"status": "suspended"}, follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
t = Tenant.query.get(tid)
|
|
assert t.status == "suspended"
|
|
|
|
|
|
# ── Settings Override ─────────────────────────────────────────────────────────
|
|
|
|
class TestSettingsOverride:
|
|
def test_set_override(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
tid = _create_tenant(admin_app, slug="override-salon")
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post(
|
|
f"/settings-override/tenant/{tid}/set",
|
|
data={"setting_key": "feature_marketing", "setting_value": "true", "note": "Test override"},
|
|
follow_redirects=True,
|
|
)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
ov = TenantSettingOverride.query.filter_by(
|
|
tenant_id=tid, setting_key="feature_marketing"
|
|
).filter(TenantSettingOverride.lifted_at.is_(None)).first()
|
|
assert ov is not None
|
|
assert ov.setting_value == "true"
|
|
|
|
def test_lift_override(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
tid = _create_tenant(admin_app, slug="lift-salon")
|
|
_login_admin(admin_client)
|
|
# Set first
|
|
admin_client.post(
|
|
f"/settings-override/tenant/{tid}/set",
|
|
data={"setting_key": "feature_inventory", "setting_value": "false"},
|
|
follow_redirects=True,
|
|
)
|
|
with admin_app.app_context():
|
|
ov = TenantSettingOverride.query.filter_by(
|
|
tenant_id=tid, setting_key="feature_inventory"
|
|
).first()
|
|
ov_id = ov.id
|
|
# Lift
|
|
resp = admin_client.post(f"/settings-override/{ov_id}/lift", follow_redirects=True)
|
|
assert resp.status_code == 200
|
|
with admin_app.app_context():
|
|
ov = TenantSettingOverride.query.get(ov_id)
|
|
assert ov.lifted_at is not None
|
|
|
|
def test_invalid_setting_key_rejected(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
tid = _create_tenant(admin_app, slug="badkey-salon")
|
|
_login_admin(admin_client)
|
|
resp = admin_client.post(
|
|
f"/settings-override/tenant/{tid}/set",
|
|
data={"setting_key": "bad key!", "setting_value": "true"},
|
|
follow_redirects=True,
|
|
)
|
|
assert b"Invalid setting key" in resp.data
|
|
|
|
|
|
# ── Audit Log ─────────────────────────────────────────────────────────────────
|
|
|
|
class TestAuditLog:
|
|
def test_audit_log_accessible(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/audit-log/")
|
|
assert resp.status_code == 200
|
|
|
|
def test_actions_are_logged(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
# Create a plan — should produce an audit log entry
|
|
admin_client.post("/plans/new", data={
|
|
"name": "AuditTestPlan", "price_monthly": "49.00",
|
|
"is_active": "1",
|
|
}, follow_redirects=True)
|
|
with admin_app.app_context():
|
|
entry = AuditLog.query.filter(
|
|
AuditLog.action == "plan.create"
|
|
).first()
|
|
assert entry is not None
|
|
|
|
def test_audit_log_csv_export(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/audit-log/export.csv")
|
|
assert resp.status_code == 200
|
|
assert resp.content_type == "text/csv; charset=utf-8"
|
|
|
|
|
|
# ── Analytics ─────────────────────────────────────────────────────────────────
|
|
|
|
class TestAnalytics:
|
|
def test_analytics_dashboard_loads(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/analytics/")
|
|
assert resp.status_code == 200
|
|
assert b"Platform Analytics" in resp.data
|
|
|
|
def test_analytics_shows_tenant_counts(self, admin_app, admin_client):
|
|
_create_admin(admin_app)
|
|
_create_tenant(admin_app, slug="analytics-salon")
|
|
_login_admin(admin_client)
|
|
resp = admin_client.get("/analytics/")
|
|
assert resp.status_code == 200
|
|
assert b"Total Tenants" in resp.data
|