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
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user