"""Tests for user accounts, authentication, impersonation, and watch list."""
import pytest
from web.app import app, _rate_buckets
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _use_duckdb(tmp_path, monkeypatch):
"""Force DuckDB backend with temp database for isolation."""
db_path = str(tmp_path / "test_auth.duckdb")
monkeypatch.setenv("SF_PERMITS_DB", db_path)
monkeypatch.delenv("DATABASE_URL", raising=False)
# Reset cached backend detection in db module
import src.db as db_mod
monkeypatch.setattr(db_mod, "BACKEND", "duckdb")
monkeypatch.setattr(db_mod, "_DUCKDB_PATH", db_path)
# Reset schema init flag in auth module
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "_schema_initialized", False)
# Init schema
db_mod.init_user_schema()
@pytest.fixture
def client():
app.config["TESTING"] = True
_rate_buckets.clear()
with app.test_client() as client:
yield client
_rate_buckets.clear()
def _login_user(client, email="test@example.com"):
"""Helper: create user and magic-link session."""
from web.auth import get_or_create_user, create_magic_token
user = get_or_create_user(email)
token = create_magic_token(user["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
return user
def _make_admin(email="admin@example.com", monkeypatch=None):
"""Helper: create an admin user."""
import web.auth as auth_mod
if monkeypatch:
monkeypatch.setattr(auth_mod, "ADMIN_EMAIL", email)
else:
auth_mod.ADMIN_EMAIL = email
return auth_mod.get_or_create_user(email)
# ---------------------------------------------------------------------------
# Auth: login page
# ---------------------------------------------------------------------------
def test_login_page_loads(client):
rv = client.get("/auth/login")
assert rv.status_code == 200
html = rv.data.decode()
assert "magic link" in html.lower()
assert 'name="email"' in html
# ---------------------------------------------------------------------------
# Auth: send magic link
# ---------------------------------------------------------------------------
def test_send_link_new_user(client):
rv = client.post("/auth/send-link", data={"email": "new@example.com"})
assert rv.status_code == 200
html = rv.data.decode()
assert "/auth/verify/" in html # dev mode shows link
from web.auth import get_user_by_email
user = get_user_by_email("new@example.com")
assert user is not None
assert user["email"] == "new@example.com"
def test_send_link_existing_user(client):
from web.auth import create_user
create_user("existing@example.com")
rv = client.post("/auth/send-link", data={"email": "existing@example.com"})
assert rv.status_code == 200
html = rv.data.decode()
assert "/auth/verify/" in html
def test_send_link_invalid_email(client):
rv = client.post("/auth/send-link", data={"email": "notanemail"})
assert rv.status_code == 400
# ---------------------------------------------------------------------------
# Auth: verify token
# ---------------------------------------------------------------------------
def test_verify_valid_token(client):
from web.auth import create_user, create_magic_token
user = create_user("verify@example.com")
token = create_magic_token(user["user_id"])
rv = client.get(f"/auth/verify/{token}", follow_redirects=False)
assert rv.status_code == 302 # redirect to /
# Session should be set
with client.session_transaction() as sess:
assert sess["user_id"] == user["user_id"]
assert sess["email"] == "verify@example.com"
def test_verify_expired_token(client):
from web.auth import create_user, create_magic_token
from src.db import get_connection
user = create_user("expired@example.com")
token = create_magic_token(user["user_id"])
# Manually expire the token
conn = get_connection()
try:
conn.execute(
"UPDATE auth_tokens SET expires_at = '2020-01-01' WHERE token = ?",
(token,),
)
finally:
conn.close()
rv = client.get(f"/auth/verify/{token}")
assert rv.status_code == 400
html = rv.data.decode()
assert "expired" in html.lower() or "invalid" in html.lower()
def test_verify_used_token(client):
from web.auth import create_user, create_magic_token
user = create_user("used@example.com")
token = create_magic_token(user["user_id"])
# Use it once
client.get(f"/auth/verify/{token}", follow_redirects=True)
# Try again — should fail
rv = client.get(f"/auth/verify/{token}")
assert rv.status_code == 400
def test_verify_nonexistent_token(client):
rv = client.get("/auth/verify/bogus-token-12345")
assert rv.status_code == 400
# ---------------------------------------------------------------------------
# Auth: logout
# ---------------------------------------------------------------------------
def test_logout(client):
_login_user(client)
with client.session_transaction() as sess:
assert "user_id" in sess
rv = client.post("/auth/logout", follow_redirects=False)
assert rv.status_code == 302
with client.session_transaction() as sess:
assert "user_id" not in sess
# ---------------------------------------------------------------------------
# Auth: session persistence
# ---------------------------------------------------------------------------
def test_session_persists_across_requests(client):
_login_user(client, "persist@example.com")
rv = client.get("/")
html = rv.data.decode()
assert "persist@example.com" in html
assert "Sign in" not in html
def test_anonymous_sees_sign_in(client):
rv = client.get("/")
html = rv.data.decode()
assert "Sign in" in html
# ---------------------------------------------------------------------------
# Admin impersonation
# ---------------------------------------------------------------------------
def test_impersonate_as_admin(client, monkeypatch):
admin = _make_admin("admin@test.com", monkeypatch)
# Login as admin
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
# Create target user
from web.auth import create_user
target = create_user("target@test.com")
# Impersonate
rv = client.post("/auth/impersonate", data={"target_email": "target@test.com"},
follow_redirects=False)
assert rv.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == target["user_id"]
assert sess["impersonating"] == "target@test.com"
assert sess["admin_user_id"] == admin["user_id"]
def test_impersonate_as_non_admin(client):
_login_user(client, "regular@test.com")
rv = client.post("/auth/impersonate", data={"target_email": "someone@test.com"})
assert rv.status_code == 403
def test_stop_impersonate(client, monkeypatch):
admin = _make_admin("admin2@test.com", monkeypatch)
from web.auth import create_magic_token, create_user
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
create_user("target2@test.com")
client.post("/auth/impersonate", data={"target_email": "target2@test.com"})
# Stop
rv = client.post("/auth/stop-impersonate", follow_redirects=False)
assert rv.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == admin["user_id"]
assert "impersonating" not in sess
def test_admin_email_retroactive(client, monkeypatch):
"""User created before ADMIN_EMAIL is set still gets admin when env var matches."""
import web.auth as auth_mod
# Create user with no ADMIN_EMAIL set — should NOT be admin
monkeypatch.setattr(auth_mod, "ADMIN_EMAIL", None)
user = auth_mod.create_user("retroactive-admin@test.com")
assert not user["is_admin"]
# Now set ADMIN_EMAIL to match — user should dynamically become admin
monkeypatch.setattr(auth_mod, "ADMIN_EMAIL", "retroactive-admin@test.com")
user2 = auth_mod.get_user_by_email("retroactive-admin@test.com")
assert user2["is_admin"]
# ---------------------------------------------------------------------------
# Watch list: add/remove
# ---------------------------------------------------------------------------
def test_watch_add_logged_in(client):
_login_user(client)
rv = client.post("/watch/add", data={
"watch_type": "permit",
"permit_number": "202401019876",
"label": "Test permit",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "Watching" in html
def test_watch_add_not_logged_in(client):
rv = client.post("/watch/add", data={
"watch_type": "permit",
"permit_number": "202401019876",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "Sign in" in html
def test_watch_remove(client):
user = _login_user(client)
from web.auth import add_watch
watch = add_watch(user["user_id"], "permit", permit_number="123456789")
rv = client.post("/watch/remove", data={"watch_id": str(watch["watch_id"])})
assert rv.status_code == 200
from web.auth import get_watches
watches = get_watches(user["user_id"])
assert len(watches) == 0
def test_watch_duplicate_idempotent(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
w1 = add_watch(user["user_id"], "permit", permit_number="999999999")
w2 = add_watch(user["user_id"], "permit", permit_number="999999999")
assert w1["watch_id"] == w2["watch_id"]
watches = get_watches(user["user_id"])
assert len(watches) == 1
# ---------------------------------------------------------------------------
# Watch list: all 5 types
# ---------------------------------------------------------------------------
def test_watch_permit(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
add_watch(user["user_id"], "permit", permit_number="202401010001")
watches = get_watches(user["user_id"])
assert len(watches) == 1
assert watches[0]["watch_type"] == "permit"
def test_watch_address(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
add_watch(user["user_id"], "address", street_number="123", street_name="Main")
watches = get_watches(user["user_id"])
assert len(watches) == 1
assert watches[0]["watch_type"] == "address"
def test_watch_parcel(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
add_watch(user["user_id"], "parcel", block="3512", lot="001")
watches = get_watches(user["user_id"])
assert len(watches) == 1
assert watches[0]["watch_type"] == "parcel"
def test_watch_entity(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
add_watch(user["user_id"], "entity", entity_id=12345)
watches = get_watches(user["user_id"])
assert len(watches) == 1
assert watches[0]["watch_type"] == "entity"
def test_watch_neighborhood(client):
user = _login_user(client)
from web.auth import add_watch, get_watches
add_watch(user["user_id"], "neighborhood", neighborhood="Mission")
watches = get_watches(user["user_id"])
assert len(watches) == 1
assert watches[0]["watch_type"] == "neighborhood"
# ---------------------------------------------------------------------------
# Watch edit: label update
# ---------------------------------------------------------------------------
def test_watch_edit_label(client):
"""Edit a watch item's label via POST /watch/edit."""
user = _login_user(client)
from web.auth import add_watch, get_watches
watch = add_watch(user["user_id"], "address", street_number="100", street_name="Main St")
rv = client.post("/watch/edit", data={"watch_id": str(watch["watch_id"]), "label": "My Office"})
assert rv.status_code == 200
assert b"My Office" in rv.data
watches = get_watches(user["user_id"])
assert watches[0]["label"] == "My Office"
def test_watch_edit_not_logged_in(client):
"""Edit without login returns 403."""
rv = client.post("/watch/edit", data={"watch_id": "1", "label": "Nope"})
assert rv.status_code == 403
def test_watch_edit_empty_label(client):
"""Empty label is a no-op (returns empty)."""
user = _login_user(client)
from web.auth import add_watch
watch = add_watch(user["user_id"], "permit", permit_number="999")
rv = client.post("/watch/edit", data={"watch_id": str(watch["watch_id"]), "label": ""})
assert rv.status_code == 200
# ---------------------------------------------------------------------------
# Watch context: regression test for _watch_context unpacking bug
# ---------------------------------------------------------------------------
def test_watch_context_no_double_watch_type(client):
"""_watch_context must not pass watch_type twice to check_watch (regression)."""
user = _login_user(client)
# The /ask endpoint triggers _watch_context internally.
# A direct test: import and call it to verify no TypeError.
from web.app import _watch_context
from flask import g as flask_g
with client.application.test_request_context():
flask_g.user = user
# This would raise TypeError: check_watch() got multiple values
# for argument 'watch_type' before the fix.
ctx = _watch_context({
"watch_type": "address",
"street_number": "723",
"street_name": "16th",
"label": "723 16th",
})
assert "watch_data" in ctx
assert ctx["watch_data"]["watch_type"] == "address"
# ---------------------------------------------------------------------------
# Account page
# ---------------------------------------------------------------------------
def test_account_page_logged_in(client):
user = _login_user(client, "acct@example.com")
from web.auth import add_watch
add_watch(user["user_id"], "permit", permit_number="111111111", label="My project")
rv = client.get("/account")
assert rv.status_code == 200
html = rv.data.decode()
assert "acct@example.com" in html
assert "My project" in html
assert "Watch List" in html
def test_account_page_not_logged_in(client):
rv = client.get("/account", follow_redirects=False)
assert rv.status_code == 302
assert "/auth/login" in rv.headers["Location"]
# ---------------------------------------------------------------------------
# Header: auth state rendering
# ---------------------------------------------------------------------------
def test_header_shows_email_when_logged_in(client):
_login_user(client, "header@example.com")
rv = client.get("/")
html = rv.data.decode()
assert "header@example.com" in html
assert "Logout" in html
def test_header_shows_sign_in_when_anonymous(client):
rv = client.get("/")
html = rv.data.decode()
assert "Sign in" in html
assert "Logout" not in html
# ---------------------------------------------------------------------------
# Invite codes
# ---------------------------------------------------------------------------
def test_invite_code_not_required_by_default(client):
"""When INVITE_CODES is empty, signup is open."""
import web.auth as auth_mod
assert not auth_mod.invite_required()
rv = client.post("/auth/send-link", data={"email": "open@example.com"})
assert rv.status_code == 200
html = rv.data.decode()
assert "/auth/verify/" in html
def test_invite_code_required_blocks_new_user(client, monkeypatch):
"""When invite codes are set, new users without code or shared_link are redirected
to the beta request form (Sprint 56D three-tier signup)."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"disco-penguin-7f3a", "turbo-walrus-a1b2"})
rv = client.post("/auth/send-link", data={"email": "blocked@example.com"})
# Sprint 56D: redirect to beta-request form instead of 403
assert rv.status_code == 302
location = rv.headers.get("Location", "")
assert "beta-request" in location or "beta_request" in location
def test_invite_code_required_bad_code(client, monkeypatch):
"""Wrong invite code redirects to beta request form (Sprint 56D three-tier signup)."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"disco-penguin-7f3a"})
rv = client.post("/auth/send-link", data={
"email": "wrong@example.com",
"invite_code": "bad-code-0000",
})
# Sprint 56D: redirect to beta-request instead of 403
assert rv.status_code == 302
location = rv.headers.get("Location", "")
assert "beta-request" in location or "beta_request" in location
def test_invite_code_required_valid_code(client, monkeypatch):
"""Valid invite code allows new user creation."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"disco-penguin-7f3a"})
rv = client.post("/auth/send-link", data={
"email": "invited@example.com",
"invite_code": "disco-penguin-7f3a",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "/auth/verify/" in html
# Check user was created with the invite code stored
user = auth_mod.get_user_by_email("invited@example.com")
assert user is not None
assert user["invite_code"] == "disco-penguin-7f3a"
def test_existing_user_skips_invite_code(client, monkeypatch):
"""Existing users can log in without an invite code even when required."""
import web.auth as auth_mod
# Create user first (before invite codes are enabled)
auth_mod.create_user("existing@example.com")
# Now enable invite codes
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"disco-penguin-7f3a"})
rv = client.post("/auth/send-link", data={"email": "existing@example.com"})
assert rv.status_code == 200
html = rv.data.decode()
assert "/auth/verify/" in html
def test_login_page_shows_invite_field_when_required(client, monkeypatch):
"""Login page shows invite code field when codes are configured."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"some-code-1234"})
rv = client.get("/auth/login")
assert rv.status_code == 200
html = rv.data.decode()
assert 'name="invite_code"' in html
assert "invite code" in html.lower()
def test_login_page_hides_invite_field_when_open(client):
"""Login page hides invite code field when signup is open."""
import web.auth as auth_mod
assert not auth_mod.invite_required()
rv = client.get("/auth/login")
assert rv.status_code == 200
html = rv.data.decode()
assert 'name="invite_code"' not in html
def test_validate_invite_code_function(monkeypatch):
"""Direct test of validate_invite_code helper."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"turbo-walrus-a1b2", "mega-sloth-c3d4"})
assert auth_mod.validate_invite_code("turbo-walrus-a1b2") is True
assert auth_mod.validate_invite_code("mega-sloth-c3d4") is True
assert auth_mod.validate_invite_code("wrong-code-0000") is False
assert auth_mod.validate_invite_code("") is False
assert auth_mod.validate_invite_code(" turbo-walrus-a1b2 ") is True # strips whitespace
def test_validate_invite_code_open_signup(monkeypatch):
"""When no codes configured, any code (or empty) is accepted."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", set())
assert auth_mod.validate_invite_code("anything") is True
assert auth_mod.validate_invite_code("") is True
# ---------------------------------------------------------------------------
# Admin: send invite
# ---------------------------------------------------------------------------
def test_send_invite_as_admin(client, monkeypatch):
"""Admin can send an invite email (dev mode logs it)."""
import web.auth as auth_mod
admin = _make_admin("admin-invite@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"team-test-code-1234"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "friend@example.com",
"invite_code": "team-test-code-1234",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "friend@example.com" in html
assert "team-test-code-1234" in html
def test_send_invite_non_admin_forbidden(client, monkeypatch):
"""Non-admin users cannot send invites."""
import web.auth as auth_mod
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"some-code-5678"})
_login_user(client, "regular@test.com")
rv = client.post("/admin/send-invite", data={
"to_email": "someone@example.com",
"invite_code": "some-code-5678",
})
assert rv.status_code == 403
def test_send_invite_bad_code_rejected(client, monkeypatch):
"""Admin cannot send an invalid invite code."""
import web.auth as auth_mod
admin = _make_admin("admin-bad@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"real-code-abcd"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "friend@example.com",
"invite_code": "fake-code-0000",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "invalid" in html.lower() or "error" in html.lower()
def test_send_invite_bad_email_rejected(client, monkeypatch):
"""Admin cannot send to an invalid email."""
import web.auth as auth_mod
admin = _make_admin("admin-email@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"real-code-efgh"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "notanemail",
"invite_code": "real-code-efgh",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "invalid" in html.lower() or "error" in html.lower()
def test_account_shows_invite_codes_for_admin(client, monkeypatch):
"""Admin account page shows invite code dropdown."""
import web.auth as auth_mod
admin = _make_admin("admin-codes@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"team-abc-1234", "friends-xyz-5678"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/account")
html = rv.data.decode()
assert "Send Invite" in html
assert "team-abc-1234" in html
assert "friends-xyz-5678" in html
# ---------------------------------------------------------------------------
# Primary address
# ---------------------------------------------------------------------------
def test_set_primary_address(client):
"""User can set a primary address."""
user = _login_user(client)
from web.auth import set_primary_address, get_primary_address
set_primary_address(user["user_id"], "75", "Robin Hood Dr")
addr = get_primary_address(user["user_id"])
assert addr is not None
assert addr["street_number"] == "75"
assert addr["street_name"] == "Robin Hood Dr"
def test_clear_primary_address(client):
"""User can clear their primary address."""
user = _login_user(client)
from web.auth import set_primary_address, clear_primary_address, get_primary_address
set_primary_address(user["user_id"], "614", "6th Ave")
clear_primary_address(user["user_id"])
addr = get_primary_address(user["user_id"])
assert addr is None
def test_primary_address_no_address_returns_none(client):
"""get_primary_address returns None when no address is set."""
user = _login_user(client)
from web.auth import get_primary_address
addr = get_primary_address(user["user_id"])
assert addr is None
def test_set_primary_address_via_route(client):
"""POST /account/primary-address sets the address (HTMX)."""
_login_user(client)
rv = client.post("/account/primary-address", data={
"street_number": "75",
"street_name": "Robin Hood Dr",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "75 Robin Hood Dr" in html
assert "Saved" in html
def test_clear_primary_address_via_route(client):
"""POST /account/primary-address/clear clears the address."""
user = _login_user(client)
from web.auth import set_primary_address
set_primary_address(user["user_id"], "614", "6th Ave")
rv = client.post("/account/primary-address/clear")
assert rv.status_code == 200
html = rv.data.decode()
assert "Not set" in html
def test_set_primary_address_requires_login(client):
"""POST /account/primary-address redirects when not logged in."""
rv = client.post("/account/primary-address",
data={"street_number": "75", "street_name": "Robin Hood Dr"},
follow_redirects=False)
assert rv.status_code == 302
assert "/auth/login" in rv.headers["Location"]
def test_account_page_shows_primary_address(client):
"""Account page displays the primary address in the profile card."""
user = _login_user(client, "primary-show@test.com")
from web.auth import set_primary_address
set_primary_address(user["user_id"], "75", "Robin Hood Dr")
rv = client.get("/account")
assert rv.status_code == 200
html = rv.data.decode()
assert "75 Robin Hood Dr" in html
assert "Primary Address" in html
def test_account_page_shows_no_primary_address(client):
"""Account page shows 'Not set' when no primary address."""
_login_user(client, "no-primary@test.com")
rv = client.get("/account")
assert rv.status_code == 200
html = rv.data.decode()
assert "Not set" in html
def test_index_shows_quick_action_with_primary_address(client):
"""Index page shows quick-action button when primary address is set."""
user = _login_user(client, "quick-action@test.com")
from web.auth import set_primary_address
set_primary_address(user["user_id"], "614", "6th Ave")
rv = client.get("/")
assert rv.status_code == 200
html = rv.data.decode()
assert "614 6th Ave" in html
assert "Check" in html
def test_index_no_quick_action_without_primary_address(client):
"""Index page does NOT show address-specific quick-action when no primary."""
_login_user(client, "no-quick@test.com")
rv = client.get("/")
assert rv.status_code == 200
html = rv.data.decode()
assert "Robin Hood" not in html
assert "6th Ave" not in html
def test_user_dict_includes_primary_address(client):
"""User dict from get_user_by_id includes primary address fields."""
user = _login_user(client, "dict-test@test.com")
from web.auth import set_primary_address, get_user_by_id
set_primary_address(user["user_id"], "75", "Robin Hood Dr")
refreshed = get_user_by_id(user["user_id"])
assert refreshed["primary_street_number"] == "75"
assert refreshed["primary_street_name"] == "Robin Hood Dr"
# ---------------------------------------------------------------------------
# Admin: Quick Search
# ---------------------------------------------------------------------------
def test_admin_account_shows_quick_search(client, monkeypatch):
"""Admin account page includes the Quick Search card."""
admin = _make_admin("admin-search@test.com", monkeypatch)
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/account")
html = rv.data.decode()
assert "Quick Search" in html
assert 'name="q"' in html
def test_non_admin_no_quick_search(client):
"""Non-admin users should not see the Quick Search card."""
_login_user(client, "regular@test.com")
rv = client.get("/account")
html = rv.data.decode()
assert "Quick Search" not in html
# ---------------------------------------------------------------------------
# Invite email: cohort templates + message
# ---------------------------------------------------------------------------
def test_send_invite_with_cohort(client, monkeypatch):
"""Admin can send invite with cohort template selection."""
import web.auth as auth_mod
admin = _make_admin("admin-cohort@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"team-test-code-1234"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "expeditor@example.com",
"invite_code": "team-test-code-1234",
"cohort": "consultants",
"message": "Welcome to the professional network!",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "expeditor@example.com" in html
def test_send_invite_with_personal_message(client, monkeypatch):
"""Admin invite includes optional personal message."""
import web.auth as auth_mod
admin = _make_admin("admin-msg@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"friends-code-abcd"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "buddy@example.com",
"invite_code": "friends-code-abcd",
"cohort": "friends",
"message": "Hey, check this out!",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "buddy@example.com" in html
def test_send_invite_default_cohort(client, monkeypatch):
"""Invite defaults to 'friends' cohort when not specified."""
import web.auth as auth_mod
admin = _make_admin("admin-default@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"default-code-1234"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.post("/admin/send-invite", data={
"to_email": "default@example.com",
"invite_code": "default-code-1234",
})
assert rv.status_code == 200
html = rv.data.decode()
assert "default@example.com" in html
def test_account_shows_cohort_selector(client, monkeypatch):
"""Admin account page shows cohort template selector."""
import web.auth as auth_mod
admin = _make_admin("admin-cohort-ui@test.com", monkeypatch)
monkeypatch.setattr(auth_mod, "INVITE_CODES", {"test-code-1234"})
from web.auth import create_magic_token
token = create_magic_token(admin["user_id"])
client.get(f"/auth/verify/{token}", follow_redirects=True)
rv = client.get("/account")
html = rv.data.decode()
assert "invite-cohort" in html
assert "Friends (casual)" in html
assert "Beta Testers" in html
assert "Land Use Consultants (professional)" in html
assert "invite-message" in html