Files
agentbox-test/tests/test_ui.py
T
agentbox 11d111fe83
CI / test-and-package (push) Successful in 1m3s
CI / build-android (push) Successful in 1m29s
test: 42 web UI tests covering all pages and settings
tests/test_ui.py — 7 test classes, 42 tests:

TestLoginUI (4): form fields, empty submit, wrong password, success redirect
TestRegisterUI (6): join/create options, admin on create, not admin on join,
  duplicate name, missing selection, password mismatch
TestDashboardUI (6): correct columns, own response buttons, other-user badges,
  date nav, changed-mind badge, history mode
TestAccountSettingsUI (7): section visible, password form, wrong current,
  success, mismatch, too short, delete requires password
TestHouseholdSettingsUI (8): hidden from non-admin, admin sees all sections,
  generate/disable/enable public link
TestPublicPageUI (5): household name, no response buttons, date nav,
  invalid token 404, disabled token 404
TestLanguageUI (6): default en, fr switch, de switch, session persist,
  public page lang, footer links
2026-08-01 12:54:30 +00:00

425 lines
19 KiB
Python

"""Web UI tests — page structure, forms, visibility, i18n."""
import pytest
from models import (
generate_public_token,
get_household_by_name,
get_user_by_username,
set_public_enabled,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Login page
# ═══════════════════════════════════════════════════════════════════════════════
class TestLoginUI:
def test_form_has_fields(self, client):
resp = client.get("/login")
assert resp.status_code == 200
html = resp.data.decode()
assert 'name="username"' in html
assert 'name="password"' in html
assert 'type="submit"' in html
def test_error_on_empty_submit(self, client):
resp = client.post("/login", data={}, follow_redirects=True)
assert resp.status_code == 200
assert b"required" in resp.data.lower()
def test_error_on_wrong_password(self, client):
client.post("/register", data={
"username": "ui_user", "password": "correct",
"confirm": "correct",
"household_action": "create",
"new_household_name": "UI House",
})
client.get("/logout")
resp = client.post("/login", data={
"username": "ui_user", "password": "wrong",
}, follow_redirects=True)
assert b"Invalid username or password" in resp.data
def test_successful_login_redirects_to_dashboard(self, client):
client.post("/register", data={
"username": "ui_user2", "password": "secret",
"confirm": "secret",
"household_action": "create",
"new_household_name": "UI House 2",
})
client.get("/logout")
resp = client.post("/login", data={
"username": "ui_user2", "password": "secret",
}, follow_redirects=True)
assert resp.status_code == 200
assert b"Today" in resp.data or b"Meal" in resp.data
# ═══════════════════════════════════════════════════════════════════════════════
# Register page
# ═══════════════════════════════════════════════════════════════════════════════
class TestRegisterUI:
def test_form_shows_join_create_options(self, client):
resp = client.get("/register")
html = resp.data.decode()
assert 'household_action' in html
assert 'new_household_name' in html
assert 'household_id' in html
def test_create_household_makes_admin(self, client):
client.post("/register", data={
"username": "creator_ui", "password": "pass1234",
"confirm": "pass1234",
"household_action": "create",
"new_household_name": "Creator House",
}, follow_redirects=True)
user = get_user_by_username("creator_ui")
assert user is not None
assert user["is_admin"] == 1
def test_join_household_is_not_admin(self, client):
# First user creates household
client.post("/register", data={
"username": "founder", "password": "founder1",
"confirm": "founder1",
"household_action": "create",
"new_household_name": "Shared Home",
})
client.get("/logout")
# Second user joins
hh = get_household_by_name("Shared Home")
client.post("/register", data={
"username": "member", "password": "member1",
"confirm": "member1",
"household_action": "join",
"household_id": str(hh["id"]),
}, follow_redirects=True)
member = get_user_by_username("member")
assert member["is_admin"] == 0
def test_duplicate_household_name_error(self, client):
client.post("/register", data={
"username": "u1", "password": "pass1234",
"confirm": "pass1234",
"household_action": "create",
"new_household_name": "DupHouse",
})
client.get("/logout")
resp = client.post("/register", data={
"username": "u2", "password": "pass5678",
"confirm": "pass5678",
"household_action": "create",
"new_household_name": "DupHouse",
}, follow_redirects=True)
assert b"already exists" in resp.data
def test_missing_household_selection_error(self, client):
resp = client.post("/register", data={
"username": "u3", "password": "pass1234",
"confirm": "pass1234",
"household_action": "join",
"household_id": "",
}, follow_redirects=True)
assert b"select a household" in resp.data.lower()
def test_password_mismatch_error(self, client):
resp = client.post("/register", data={
"username": "u4", "password": "abc12345",
"confirm": "abc12346",
"household_action": "create",
"new_household_name": "Mismatch",
}, follow_redirects=True)
assert b"do not match" in resp.data.lower()
# ═══════════════════════════════════════════════════════════════════════════════
# Dashboard page
# ═══════════════════════════════════════════════════════════════════════════════
class TestDashboardUI:
def test_table_has_correct_columns(self, auth_client):
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert "User" in html or "Utilisateur" in html or "Benutzer" in html
assert "Lunch" in html or "Déjeuner" in html or "Mittagessen" in html
assert "Dinner" in html or "Dîner" in html or "Abendessen" in html
def test_own_user_has_response_buttons(self, auth_client):
"""Bob (logged in) should see clickable Home/Out buttons for himself."""
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert 'name="status"' in html
assert 'Home' in html or 'Présent' in html or 'Zuhause' in html
def test_other_users_show_as_badges(self, auth_client):
"""Alice's status should be a static badge, not a form button."""
# Bob answers something first to populate
auth_client.post("/respond", data={"meal_type": "lunch", "status": "yes"})
resp = auth_client.get("/dashboard")
html = resp.data.decode()
# Alice's row should have a badge (read-only), Bob's has buttons
assert 'badge' in html
def test_date_nav_links_present(self, auth_client):
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert "Prev" in html or "Préc" in html or "Zurück" in html
assert "Next" in html or "Suiv" in html or "Weiter" in html
assert "Today" in html or "Aujourd" in html or "Heute" in html
def test_changed_mind_badge_appears_after_flip(self, auth_client):
"""After flipping yes→no, a changed-mind indicator should render."""
auth_client.post("/respond", data={"meal_type": "lunch", "status": "yes"})
auth_client.post("/respond", data={"meal_type": "lunch", "status": "no"})
resp = auth_client.get("/dashboard")
# The changed-mind CSS class or the ↻ symbol
assert b"changed-mind" in resp.data or b"\\xe2\\x86\\xbb" in resp.data or b"changed" in resp.data.lower()
def test_history_mode_flag(self, auth_client):
"""History page should have the history_mode flag / different heading."""
resp = auth_client.get("/history")
html = resp.data.decode()
assert "History" in html or "Historique" in html or "Verlauf" in html
# ═══════════════════════════════════════════════════════════════════════════════
# Account settings page (password change, delete)
# ═══════════════════════════════════════════════════════════════════════════════
class TestAccountSettingsUI:
def test_account_section_visible(self, auth_client):
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert "Account Settings" in html or "Paramètres du compte" in html or "Kontoeinstellungen" in html
def test_change_password_form_present(self, auth_client):
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert 'name="current_password"' in html
assert 'name="new_password"' in html
assert 'name="confirm_password"' in html
def test_change_password_wrong_current(self, auth_client):
resp = auth_client.post("/change-password", data={
"current_password": "wrongpass",
"new_password": "newpass123",
"confirm_password": "newpass123",
}, follow_redirects=True)
assert b"incorrect" in resp.data.lower()
def test_change_password_success(self, auth_client):
"""Bob changes password, then logs in with it."""
resp = auth_client.post("/change-password", data={
"current_password": "bobpass",
"new_password": "newbobpass",
"confirm_password": "newbobpass",
}, follow_redirects=True)
assert b"success" in resp.data.lower() or b"changed" in resp.data.lower()
# Verify new password works
auth_client.get("/logout")
login_resp = auth_client.post("/login", data={
"username": "bob", "password": "newbobpass",
}, follow_redirects=True)
assert login_resp.status_code == 200
def test_change_password_mismatch(self, auth_client):
resp = auth_client.post("/change-password", data={
"current_password": "bobpass",
"new_password": "aaa1234",
"confirm_password": "bbb5678",
}, follow_redirects=True)
assert b"do not match" in resp.data.lower()
def test_change_password_too_short(self, auth_client):
resp = auth_client.post("/change-password", data={
"current_password": "bobpass",
"new_password": "ab",
"confirm_password": "ab",
}, follow_redirects=True)
assert b"at least 4" in resp.data.lower()
def test_delete_account_form_requires_password(self, auth_client):
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert "Delete My Account" in html or "Supprimer mon compte" in html or "Mein Konto" in html
assert 'action="/delete-account"' in html or 'action="/dashboard' in html
# ═══════════════════════════════════════════════════════════════════════════════
# Household settings page (admin only)
# ═══════════════════════════════════════════════════════════════════════════════
class TestHouseholdSettingsUI:
def test_household_section_hidden_from_regular_user(self, auth_client):
"""Bob is not admin — household settings should not appear."""
resp = auth_client.get("/dashboard")
html = resp.data.decode()
assert "Household Settings" not in html
assert "Paramètres du foyer" not in html
assert "Haushaltseinstellungen" not in html
def test_admin_sees_household_section(self, admin_client):
resp = admin_client.get("/dashboard")
html = resp.data.decode()
assert "Household Settings" in html or "Paramètres du foyer" in html or "Haushaltseinstellungen" in html
def test_admin_sees_public_link_section(self, admin_client):
resp = admin_client.get("/dashboard")
html = resp.data.decode()
assert "Public Sharing Link" in html or "Lien de partage" in html or "Freigabelink" in html
def test_admin_sees_timezone_section(self, admin_client):
resp = admin_client.get("/dashboard")
html = resp.data.decode()
assert 'name="timezone"' in html
def test_admin_sees_delete_household_section(self, admin_client):
resp = admin_client.get("/dashboard")
html = resp.data.decode()
assert "Delete Household" in html or "Supprimer le foyer" in html or "Haushalt löschen" in html
def test_generate_public_link_displays_url(self, admin_client):
resp = admin_client.post("/admin/public-link", data={
"action": "generate",
}, follow_redirects=True)
assert resp.status_code == 200
assert b"public/" in resp.data
def test_disable_public_link_hides_url(self, admin_client):
# First generate
admin_client.post("/admin/public-link", data={"action": "generate"})
# Then disable
resp = admin_client.post("/admin/public-link", data={
"action": "disable",
}, follow_redirects=True)
assert b"disabled" in resp.data.lower()
def test_enable_public_link_after_disable(self, admin_client):
admin_client.post("/admin/public-link", data={"action": "generate"})
admin_client.post("/admin/public-link", data={"action": "disable"})
resp = admin_client.post("/admin/public-link", data={
"action": "enable",
}, follow_redirects=True)
assert ("enabled" in resp.data.decode().lower()
or "activé" in resp.data.decode().lower())
# ═══════════════════════════════════════════════════════════════════════════════
# Public sharing page
# ═══════════════════════════════════════════════════════════════════════════════
class TestPublicPageUI:
@pytest.fixture
def public_token(self, admin_client):
"""Create a household with a public token and return it."""
from models import get_db
db = get_db()
row = db.execute(
"SELECT h.id, h.public_token FROM households h "
"JOIN users u ON u.household_id = h.id WHERE u.username = ?",
("admin",),
).fetchone()
# Generate if missing
if not row["public_token"]:
token = generate_public_token(row["id"])
else:
token = row["public_token"]
return token
def test_public_page_shows_household_name(self, client, admin_client, public_token):
resp = client.get(f"/public/{public_token}")
assert resp.status_code == 200
assert b"Admin Household" in resp.data
def test_public_page_has_no_response_buttons(self, client, public_token):
"""Public page is read-only — no forms to respond to meals."""
resp = client.get(f"/public/{public_token}")
html = resp.data.decode()
assert 'name="status"' not in html
assert 'name="meal_type"' not in html
def test_public_page_has_date_nav(self, client, public_token):
resp = client.get(f"/public/{public_token}")
html = resp.data.decode()
assert "Prev" in html or "Préc" in html or "Zurück" in html
assert "Next" in html or "Suiv" in html or "Weiter" in html
def test_invalid_token_returns_404(self, client):
resp = client.get("/public/this-token-does-not-exist")
assert resp.status_code == 404
def test_disabled_token_returns_404(self, client, admin_client):
admin_client.post("/admin/public-link", data={"action": "generate"})
from models import get_db
db = get_db()
row = db.execute(
"SELECT h.public_token FROM households h "
"JOIN users u ON u.household_id = h.id WHERE u.username = ?",
("admin",),
).fetchone()
token = row["public_token"]
# Disable it
admin_client.post("/admin/public-link", data={"action": "disable"})
resp = client.get(f"/public/{token}")
assert resp.status_code == 404
# ═══════════════════════════════════════════════════════════════════════════════
# Language / i18n
# ═══════════════════════════════════════════════════════════════════════════════
class TestLanguageUI:
def test_default_is_english(self, client):
resp = client.get("/login")
assert b"Log In" in resp.data or b"Username" in resp.data
def test_french_language_switch(self, client):
resp = client.get("/login?lang=fr")
assert b"Se connecter" in resp.data or b"Nom d" in resp.data
def test_german_language_switch(self, client):
resp = client.get("/login?lang=de")
assert b"Anmelden" in resp.data or b"Benutzername" in resp.data
def test_language_persists_in_session(self, client):
# Set language, then request another page
client.get("/login?lang=fr")
resp = client.get("/register")
# Should still show French
html = resp.data.decode()
assert ("Créer" in html or "S'inscrire" in html
or "compte" in html.lower())
def test_public_page_supports_language(self, client, admin_client):
admin_client.post("/admin/public-link", data={"action": "generate"})
from models import get_db
db = get_db()
row = db.execute(
"SELECT h.public_token FROM households h "
"JOIN users u ON u.household_id = h.id WHERE u.username = ?",
("admin",),
).fetchone()
token = row["public_token"]
resp = client.get(f"/public/{token}?lang=fr")
assert resp.status_code == 200
# Should show French content
html = resp.data.decode()
assert ("Déjeuner" in html or "Dîner" in html
or "Présent" in html)
def test_language_footer_links_present(self, client):
resp = client.get("/login")
html = resp.data.decode()
assert "EN" in html and "FR" in html and "DE" in html