diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..81e2f5a --- /dev/null +++ b/tests/test_ui.py @@ -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