Add household support: multi-household isolation, each with own admin and users, 38 tests
This commit is contained in:
+32
-12
@@ -6,7 +6,7 @@ import tempfile
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from models import init_db, get_db
|
||||
from models import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -35,35 +35,55 @@ def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _register_and_login(client, username, password, household_action="create",
|
||||
household_name=None, join_household_id=None):
|
||||
"""Helper: register (and auto-login) a user."""
|
||||
if not household_name:
|
||||
household_name = f"{username}-house"
|
||||
resp = client.post("/register", data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"confirm": password,
|
||||
"household_action": household_action,
|
||||
"new_household_name": household_name,
|
||||
"household_id": str(join_household_id) if join_household_id else "",
|
||||
}, follow_redirects=True)
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client):
|
||||
"""Test client with a logged-in regular user."""
|
||||
client.post("/register", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
"confirm": "bobpass",
|
||||
})
|
||||
client.get("/logout")
|
||||
"""Test client with a logged-in regular user in a 2-person household."""
|
||||
# Admin creates household
|
||||
client.post("/register", data={
|
||||
"username": "alice",
|
||||
"password": "alicepass",
|
||||
"confirm": "alicepass",
|
||||
"household_action": "create",
|
||||
"new_household_name": "Test Household",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Log in as bob (second user, not admin)
|
||||
client.post("/login", data={
|
||||
# Bob joins the same household
|
||||
from models import get_household_by_name
|
||||
hh = get_household_by_name("Test Household")
|
||||
client.post("/register", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
"confirm": "bobpass",
|
||||
"household_action": "join",
|
||||
"household_id": str(hh["id"]),
|
||||
})
|
||||
return client
|
||||
return client # Logged in as bob (regular user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(client):
|
||||
"""Test client with the admin user logged in."""
|
||||
"""Test client with the admin user logged in (created their own household)."""
|
||||
client.post("/register", data={
|
||||
"username": "admin",
|
||||
"password": "adminpass",
|
||||
"confirm": "adminpass",
|
||||
"household_action": "create",
|
||||
"new_household_name": "Admin Household",
|
||||
})
|
||||
return client
|
||||
|
||||
+96
-7
@@ -5,24 +5,31 @@ def test_register_page(client):
|
||||
resp = client.get("/register")
|
||||
assert resp.status_code == 200
|
||||
assert b"Create Account" in resp.data
|
||||
assert b"Household" in resp.data
|
||||
|
||||
|
||||
def test_register_success(client):
|
||||
def test_register_create_household(client):
|
||||
"""Registering with 'create household' should succeed and make admin."""
|
||||
resp = client.post("/register", data={
|
||||
"username": "testuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
"household_action": "create",
|
||||
"new_household_name": "My House",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Account created" in resp.data
|
||||
assert b"My House" in resp.data # Shows in nav
|
||||
|
||||
|
||||
def test_register_first_user_is_admin(client):
|
||||
"""First registered user should be admin."""
|
||||
def test_register_first_in_household_is_admin(client):
|
||||
"""First user in a new household should be admin."""
|
||||
client.post("/register", data={
|
||||
"username": "firstuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
"household_action": "create",
|
||||
"new_household_name": "First House",
|
||||
})
|
||||
from models import get_user_by_username
|
||||
user = get_user_by_username("firstuser")
|
||||
@@ -30,17 +37,91 @@ def test_register_first_user_is_admin(client):
|
||||
assert user["is_admin"] == 1
|
||||
|
||||
|
||||
def test_register_join_household_not_admin(client):
|
||||
"""Joining an existing household makes you a non-admin."""
|
||||
# First user creates household
|
||||
client.post("/register", data={
|
||||
"username": "creator",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
"household_action": "create",
|
||||
"new_household_name": "Shared Home",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Second user joins
|
||||
client.post("/register", data={
|
||||
"username": "joiner",
|
||||
"password": "pass4321",
|
||||
"confirm": "pass4321",
|
||||
"household_action": "join",
|
||||
"household_id": "1",
|
||||
})
|
||||
from models import get_user_by_username
|
||||
joiner = get_user_by_username("joiner")
|
||||
assert joiner is not None
|
||||
assert joiner["is_admin"] == 0
|
||||
assert joiner["household_id"] == 1
|
||||
|
||||
|
||||
def test_register_create_duplicate_household_name(client):
|
||||
"""Creating a household with an existing name should fail."""
|
||||
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_register_missing_household_name(client):
|
||||
"""Creating a household without a name should fail."""
|
||||
resp = client.post("/register", data={
|
||||
"username": "u3",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
"household_action": "create",
|
||||
"new_household_name": "",
|
||||
}, follow_redirects=True)
|
||||
assert b"Household name is required" in resp.data
|
||||
|
||||
|
||||
def test_register_no_household_selected(client):
|
||||
"""Joining without selecting a household should fail."""
|
||||
resp = client.post("/register", data={
|
||||
"username": "u4",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
"household_action": "join",
|
||||
"household_id": "",
|
||||
}, follow_redirects=True)
|
||||
assert b"Please select a household" in resp.data
|
||||
|
||||
|
||||
def test_register_duplicate_username(client):
|
||||
client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
"household_action": "create",
|
||||
"new_household_name": "DupHouse",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "other5678",
|
||||
"confirm": "other5678",
|
||||
"household_action": "create",
|
||||
"new_household_name": "OtherHouse",
|
||||
}, follow_redirects=True)
|
||||
assert b"Username already taken" in resp.data
|
||||
|
||||
@@ -50,6 +131,8 @@ def test_register_password_mismatch(client):
|
||||
"username": "someone",
|
||||
"password": "abc12345",
|
||||
"confirm": "abc12346",
|
||||
"household_action": "create",
|
||||
"new_household_name": "AnyHouse",
|
||||
}, follow_redirects=True)
|
||||
assert b"Passwords do not match" in resp.data
|
||||
|
||||
@@ -59,6 +142,8 @@ def test_register_short_username(client):
|
||||
"username": "x",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
"household_action": "create",
|
||||
"new_household_name": "AnyHouse",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 2 characters" in resp.data
|
||||
|
||||
@@ -68,6 +153,8 @@ def test_register_short_password(client):
|
||||
"username": "validname",
|
||||
"password": "ab",
|
||||
"confirm": "ab",
|
||||
"household_action": "create",
|
||||
"new_household_name": "AnyHouse",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 4 characters" in resp.data
|
||||
|
||||
@@ -79,20 +166,20 @@ def test_login_page(client):
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
# Register first
|
||||
client.post("/register", data={
|
||||
"username": "logintest",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
"household_action": "create",
|
||||
"new_household_name": "LoginHouse",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Then login
|
||||
resp = client.post("/login", data={
|
||||
"username": "logintest",
|
||||
"password": "mypassword",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Today" in resp.data # Dashboard
|
||||
assert b"Today" in resp.data
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
@@ -100,6 +187,8 @@ def test_login_wrong_password(client):
|
||||
"username": "logintest2",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
"household_action": "create",
|
||||
"new_household_name": "LoginHouse2",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/login", data={
|
||||
@@ -120,7 +209,7 @@ def test_login_nonexistent_user(client):
|
||||
def test_logout(auth_client):
|
||||
resp = auth_client.get("/logout", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data # Redirected to login
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
|
||||
def test_unauthenticated_redirect(client):
|
||||
|
||||
+70
-37
@@ -1,4 +1,4 @@
|
||||
"""Tests for meal periods, responses, and changed-mind logic."""
|
||||
"""Tests for meal periods, responses, changed-mind logic, and household scoping."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
@@ -10,10 +10,25 @@ from models import (
|
||||
ensure_meal_periods,
|
||||
get_dashboard_data,
|
||||
upsert_response,
|
||||
get_user_by_username,
|
||||
create_user,
|
||||
create_household,
|
||||
get_all_households,
|
||||
get_household_users,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _setup_household_and_user(app, username="test", household_name="Test House"):
|
||||
"""Create a household + user. Returns (household, user)."""
|
||||
with app.app_context():
|
||||
hh = create_household(household_name)
|
||||
user = create_user(username, "hash", hh["id"], is_admin=True)
|
||||
return hh, user
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMealPeriods:
|
||||
def test_ensure_creates_periods(self, app):
|
||||
with app.app_context():
|
||||
@@ -34,94 +49,112 @@ class TestMealPeriods:
|
||||
|
||||
class TestResponses:
|
||||
def test_first_response_no_changed_at(self, app):
|
||||
hh, user = _setup_household_and_user(app)
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("test", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
resp = upsert_response(user["id"], lunch["id"], "yes")
|
||||
assert resp["status"] == "yes"
|
||||
assert resp["changed_at"] is None
|
||||
|
||||
def test_flip_yes_to_no_sets_changed_at(self, app):
|
||||
hh, user = _setup_household_and_user(app)
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("flipper", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
resp = upsert_response(user["id"], lunch["id"], "no")
|
||||
assert resp["status"] == "no"
|
||||
assert resp["changed_at"] is not None
|
||||
|
||||
def test_flip_twice_updates_changed_at(self, app):
|
||||
hh, user = _setup_household_and_user(app, "flipper")
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("flipper2", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
r1 = upsert_response(user["id"], lunch["id"], "no")
|
||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
|
||||
# changed_at should reflect the latest flip
|
||||
assert r2["changed_at"] is not None
|
||||
# The second flip should have a different timestamp (or at least not earlier)
|
||||
assert r2["changed_at"] >= r1["changed_at"]
|
||||
|
||||
def test_same_status_no_change(self, app):
|
||||
hh, user = _setup_household_and_user(app, "same")
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("same", "hash", is_admin=False)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = periods[0]
|
||||
|
||||
r1 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
r2 = upsert_response(user["id"], lunch["id"], "yes")
|
||||
assert r1["status"] == r2["status"]
|
||||
assert r1["changed_at"] == r2["changed_at"]
|
||||
|
||||
|
||||
class TestDashboardData:
|
||||
def test_dashboard_includes_all_users(self, app):
|
||||
class TestHouseholdScoping:
|
||||
def test_users_isolation(self, app):
|
||||
"""Users in different households should not see each other."""
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
hh1 = create_household("House Alpha")
|
||||
hh2 = create_household("House Beta")
|
||||
u1 = create_user("alice_a", "h", hh1["id"], is_admin=True)
|
||||
u2 = create_user("bob_b", "h", hh2["id"], is_admin=True)
|
||||
|
||||
create_user("u1", "h1", is_admin=True)
|
||||
create_user("u2", "h2", is_admin=False)
|
||||
data1 = get_dashboard_data(date.today(), hh1["id"])
|
||||
data2 = get_dashboard_data(date.today(), hh2["id"])
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
assert len(data1["users"]) == 1
|
||||
assert data1["users"][0]["username"] == "alice_a"
|
||||
assert len(data2["users"]) == 1
|
||||
assert data2["users"][0]["username"] == "bob_b"
|
||||
|
||||
def test_join_household_starts_fresh(self, app):
|
||||
"""New joiner sees household members but no prior responses."""
|
||||
with app.app_context():
|
||||
hh = create_household("Shared")
|
||||
u1 = create_user("alice", "h", hh["id"], is_admin=True)
|
||||
# Alice responds
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
||||
upsert_response(u1["id"], lunch["id"], "yes")
|
||||
|
||||
# Bob joins
|
||||
u2 = create_user("bob", "h", hh["id"], is_admin=False)
|
||||
data = get_dashboard_data(date.today(), hh["id"])
|
||||
assert len(data["users"]) == 2
|
||||
assert data["users"][0]["username"] == "u1"
|
||||
assert data["users"][1]["username"] == "u2"
|
||||
# Bob's default status
|
||||
bob_resp = [u for u in data["users"] if u["username"] == "bob"][0]
|
||||
assert bob_resp["responses"]["lunch"]["status"] == "not_answered"
|
||||
|
||||
|
||||
class TestDashboardData:
|
||||
def test_dashboard_includes_all_household_users(self, app):
|
||||
with app.app_context():
|
||||
hh = create_household("DashHouse")
|
||||
create_user("u1", "h1", hh["id"], is_admin=True)
|
||||
create_user("u2", "h2", hh["id"], is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today(), hh["id"])
|
||||
assert len(data["users"]) == 2
|
||||
usernames = {u["username"] for u in data["users"]}
|
||||
assert usernames == {"u1", "u2"}
|
||||
|
||||
def test_dashboard_shows_responses(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
user = create_user("responder", "h", is_admin=True)
|
||||
hh = create_household("RespHouse")
|
||||
user = create_user("responder", "h", hh["id"], is_admin=True)
|
||||
periods = ensure_meal_periods(date.today())
|
||||
lunch = next(p for p in periods if p["meal_type"] == "lunch")
|
||||
upsert_response(user["id"], lunch["id"], "yes")
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
data = get_dashboard_data(date.today(), hh["id"])
|
||||
resp = data["users"][0]["responses"]["lunch"]
|
||||
assert resp["status"] == "yes"
|
||||
|
||||
def test_dashboard_defaults_not_answered(self, app):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
hh = create_household("SilentHouse")
|
||||
create_user("silent", "h", hh["id"], is_admin=False)
|
||||
|
||||
create_user("silent", "h", is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
data = get_dashboard_data(date.today(), hh["id"])
|
||||
resp = data["users"][0]["responses"]["dinner"]
|
||||
assert resp["status"] == "not_answered"
|
||||
assert resp["changed_at"] is None
|
||||
|
||||
+23
-4
@@ -15,12 +15,34 @@ class TestDashboardRoute:
|
||||
|
||||
def test_dashboard_renders_users(self, auth_client):
|
||||
resp = auth_client.get("/dashboard")
|
||||
# bob and alice were registered
|
||||
# bob and alice were registered in the same household
|
||||
assert b"alice" in resp.data
|
||||
assert b"bob" in resp.data
|
||||
assert b"Lunch" in resp.data
|
||||
assert b"Dinner" in resp.data
|
||||
|
||||
def test_dashboard_hides_other_households(self, auth_client):
|
||||
"""Bob should NOT see users from other households."""
|
||||
# Register another user in a different household
|
||||
auth_client.get("/logout")
|
||||
auth_client.post("/register", data={
|
||||
"username": "stranger",
|
||||
"password": "stranger1",
|
||||
"confirm": "stranger1",
|
||||
"household_action": "create",
|
||||
"new_household_name": "Stranger House",
|
||||
})
|
||||
# Log back in as bob (Test Household)
|
||||
auth_client.get("/logout")
|
||||
auth_client.post("/login", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
})
|
||||
resp = auth_client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
assert b"stranger" not in resp.data
|
||||
assert b"Stranger House" not in resp.data
|
||||
|
||||
|
||||
class TestRespondRoute:
|
||||
def test_respond_sets_status(self, auth_client):
|
||||
@@ -29,16 +51,13 @@ class TestRespondRoute:
|
||||
"status": "yes",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
# After responding, the "Home" chip should be active
|
||||
assert b"Home" in resp.data
|
||||
|
||||
def test_respond_changes_mind(self, auth_client):
|
||||
# First respond yes
|
||||
auth_client.post("/respond", data={
|
||||
"meal_type": "dinner",
|
||||
"status": "yes",
|
||||
})
|
||||
# Then change to no
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "dinner",
|
||||
"status": "no",
|
||||
|
||||
Reference in New Issue
Block a user