"""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