Add household support: multi-household isolation, each with own admin and users, 38 tests

This commit is contained in:
agentbox
2026-07-31 21:44:29 +00:00
parent d58828e0ef
commit c48cd265ca
10 changed files with 459 additions and 126 deletions
+52 -8
View File
@@ -3,7 +3,15 @@
from flask import Blueprint, request, render_template, redirect, url_for, session, flash from flask import Blueprint, request, render_template, redirect, url_for, session, flash
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from models import get_user_by_username, create_user, count_users from models import (
get_user_by_username,
create_user,
create_household,
get_household_by_name,
get_household_by_id,
get_all_households,
count_household_users,
)
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
@@ -23,10 +31,14 @@ def login():
flash("Invalid username or password.", "error") flash("Invalid username or password.", "error")
return render_template("login.html") return render_template("login.html")
household = get_household_by_id(user["household_id"])
session.clear() session.clear()
session["user_id"] = user["id"] session["user_id"] = user["id"]
session["username"] = user["username"] session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"]) session["is_admin"] = bool(user["is_admin"])
session["household_id"] = user["household_id"]
session["household_name"] = household["name"] if household else "Unknown"
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
return render_template("login.html") return render_template("login.html")
@@ -34,10 +46,15 @@ def login():
@auth_bp.route("/register", methods=["GET", "POST"]) @auth_bp.route("/register", methods=["GET", "POST"])
def register(): def register():
households = get_all_households()
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
confirm = request.form.get("confirm", "") confirm = request.form.get("confirm", "")
household_action = request.form.get("household_action", "join") # "join" or "create"
household_id = request.form.get("household_id", "").strip()
new_household_name = request.form.get("new_household_name", "").strip()
errors = [] errors = []
if not username or len(username) < 2: if not username or len(username) < 2:
@@ -49,20 +66,47 @@ def register():
if get_user_by_username(username): if get_user_by_username(username):
errors.append("Username already taken.") errors.append("Username already taken.")
target_household_id = None
if household_action == "create":
if not new_household_name:
errors.append("Household name is required.")
elif get_household_by_name(new_household_name):
errors.append("A household with that name already exists.")
else:
household = create_household(new_household_name)
target_household_id = household["id"]
is_admin = True # First user in a new household is admin
else:
if not household_id:
errors.append("Please select a household.")
else:
hh = get_household_by_id(int(household_id))
if hh is None:
errors.append("Selected household does not exist.")
else:
target_household_id = hh["id"]
is_admin = False
if errors: if errors:
for e in errors: for e in errors:
flash(e, "error") flash(e, "error")
return render_template("register.html") return render_template("register.html", households=households)
# First user to register becomes admin
is_admin = count_users() == 0
password_hash = generate_password_hash(password) password_hash = generate_password_hash(password)
create_user(username, password_hash, is_admin=is_admin) user = create_user(username, password_hash, target_household_id, is_admin=is_admin)
flash("Account created! Please log in.", "success") session.clear()
return redirect(url_for("auth.login")) session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
session["household_id"] = user["household_id"]
session["household_name"] = get_household_by_id(target_household_id)["name"]
return render_template("register.html") flash("Account created! Welcome to your household.", "success")
return redirect(url_for("meals.dashboard"))
return render_template("register.html", households=households)
@auth_bp.route("/logout") @auth_bp.route("/logout")
+3 -4
View File
@@ -10,7 +10,6 @@ from flask import (
url_for, url_for,
session, session,
flash, flash,
jsonify,
) )
from models import get_dashboard_data, upsert_response, ensure_meal_periods from models import get_dashboard_data, upsert_response, ensure_meal_periods
@@ -42,6 +41,7 @@ def index():
@login_required @login_required
def dashboard(): def dashboard():
today = date.today() today = date.today()
household_id = session["household_id"]
# Allow viewing other dates via query param # Allow viewing other dates via query param
date_str = request.args.get("date") date_str = request.args.get("date")
@@ -51,7 +51,7 @@ def dashboard():
except ValueError: except ValueError:
flash("Invalid date format. Use YYYY-MM-DD.", "error") flash("Invalid date format. Use YYYY-MM-DD.", "error")
data = get_dashboard_data(today) data = get_dashboard_data(today, household_id)
return render_template( return render_template(
"dashboard.html", "dashboard.html",
dashboard=data, dashboard=data,
@@ -83,7 +83,6 @@ def respond():
flash("Invalid date.", "error") flash("Invalid date.", "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
# Only allow responding to today (and not in the past)
if target_date < date.today(): if target_date < date.today():
flash("Cannot change responses for past dates.", "error") flash("Cannot change responses for past dates.", "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
@@ -107,7 +106,7 @@ def history():
except ValueError: except ValueError:
target_date = date.today() target_date = date.today()
data = get_dashboard_data(target_date) data = get_dashboard_data(target_date, session["household_id"])
return render_template( return render_template(
"dashboard.html", "dashboard.html",
dashboard=data, dashboard=data,
+107 -51
View File
@@ -4,6 +4,7 @@ import sqlite3
import os import os
from datetime import date, datetime from datetime import date, datetime
def _db_path() -> str: def _db_path() -> str:
return os.environ.get( return os.environ.get(
"DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db") "DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db")
@@ -19,14 +20,21 @@ def get_db() -> sqlite3.Connection:
def init_db() -> None: def init_db() -> None:
"""Create tables if they don't exist.""" """Create tables if they don't exist, including migration for household support."""
with get_db() as db: with get_db() as db:
db.executescript(""" db.executescript("""
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
household_id INTEGER REFERENCES households(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
@@ -54,8 +62,100 @@ def init_db() -> None:
ON responses(period_id); ON responses(period_id);
CREATE INDEX IF NOT EXISTS idx_meal_periods_date CREATE INDEX IF NOT EXISTS idx_meal_periods_date
ON meal_periods(date); ON meal_periods(date);
CREATE INDEX IF NOT EXISTS idx_users_household
ON users(household_id);
""") """)
# Migration: if users table exists but lacks household_id column, add it.
cols = [r["name"] for r in db.execute("PRAGMA table_info(users)").fetchall()]
if "household_id" not in cols:
db.execute("ALTER TABLE users ADD COLUMN household_id INTEGER REFERENCES households(id)")
# ── Households ────────────────────────────────────────────────────────────────
def create_household(name: str) -> dict:
"""Create a new household. Returns the row as a dict."""
with get_db() as db:
cur = db.execute("INSERT INTO households (name) VALUES (?)", (name,))
db.commit()
row = db.execute("SELECT * FROM households WHERE id = ?", (cur.lastrowid,)).fetchone()
return dict(row)
def get_household_by_id(household_id: int) -> dict | None:
"""Fetch a household by ID."""
with get_db() as db:
row = db.execute("SELECT * FROM households WHERE id = ?", (household_id,)).fetchone()
return dict(row) if row else None
def get_household_by_name(name: str) -> dict | None:
"""Fetch a household by name."""
with get_db() as db:
row = db.execute("SELECT * FROM households WHERE name = ?", (name,)).fetchone()
return dict(row) if row else None
def get_all_households() -> list[dict]:
"""Return all households."""
with get_db() as db:
rows = db.execute("SELECT * FROM households ORDER BY name").fetchall()
return [dict(r) for r in rows]
def count_household_users(household_id: int) -> int:
"""Return number of users in a household."""
with get_db() as db:
return db.execute(
"SELECT COUNT(*) FROM users WHERE household_id = ?", (household_id,)
).fetchone()[0]
# ── Users ─────────────────────────────────────────────────────────────────────
def get_user_by_id(user_id: int) -> dict | None:
"""Fetch a single user by ID."""
with get_db() as db:
row = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def get_user_by_username(username: str) -> dict | None:
"""Fetch a single user by username."""
with get_db() as db:
row = db.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def create_user(username: str, password_hash: str, household_id: int,
is_admin: bool = False) -> dict:
"""Insert a new user into a household. Returns the user row as a dict."""
with get_db() as db:
cur = db.execute(
"INSERT INTO users (username, password_hash, is_admin, household_id) "
"VALUES (?, ?, ?, ?)",
(username, password_hash, int(is_admin), household_id),
)
user_id = cur.lastrowid
db.commit()
return get_user_by_id(user_id)
def get_household_users(household_id: int) -> list[dict]:
"""Return all users in a given household."""
with get_db() as db:
rows = db.execute(
"SELECT id, username, is_admin, created_at "
"FROM users WHERE household_id = ? ORDER BY username",
(household_id,),
).fetchall()
return [dict(r) for r in rows]
# ── Meal periods ──────────────────────────────────────────────────────────────
def ensure_meal_periods(for_date: date) -> list[dict]: def ensure_meal_periods(for_date: date) -> list[dict]:
"""Ensure lunch and dinner periods exist for a given date. """Ensure lunch and dinner periods exist for a given date.
@@ -79,51 +179,10 @@ def ensure_meal_periods(for_date: date) -> list[dict]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_user_by_id(user_id: int) -> dict | None: # ── Dashboard ─────────────────────────────────────────────────────────────────
"""Fetch a single user by ID."""
with get_db() as db:
row = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def get_dashboard_data(for_date: date, household_id: int) -> dict:
def get_user_by_username(username: str) -> dict | None: """Return all users and their responses for a given date, scoped to a household.
"""Fetch a single user by username."""
with get_db() as db:
row = db.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def create_user(username: str, password_hash: str, is_admin: bool = False) -> dict:
"""Insert a new user. Returns the user row as a dict."""
with get_db() as db:
cur = db.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(username, password_hash, int(is_admin)),
)
user_id = cur.lastrowid
db.commit() # Make visible to get_user_by_id (opens a new connection)
return get_user_by_id(user_id)
def get_all_users() -> list[dict]:
"""Return all registered users."""
with get_db() as db:
rows = db.execute(
"SELECT id, username, is_admin, created_at FROM users ORDER BY username"
).fetchall()
return [dict(r) for r in rows]
def count_users() -> int:
"""Return total number of registered users."""
with get_db() as db:
return db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
def get_dashboard_data(for_date: date) -> dict:
"""Return all users and their responses for a given date.
Returns: Returns:
{ {
@@ -143,7 +202,7 @@ def get_dashboard_data(for_date: date) -> dict:
} }
""" """
periods = ensure_meal_periods(for_date) periods = ensure_meal_periods(for_date)
users = get_all_users() users = get_household_users(household_id)
with get_db() as db: with get_db() as db:
period_ids = [p["id"] for p in periods] period_ids = [p["id"] for p in periods]
@@ -158,7 +217,6 @@ def get_dashboard_data(for_date: date) -> dict:
period_ids, period_ids,
).fetchall() ).fetchall()
# Build a lookup: (user_id, period_id) → response
resp_map = {} resp_map = {}
for r in resp_rows: for r in resp_rows:
resp_map[(r["user_id"], r["period_id"])] = { resp_map[(r["user_id"], r["period_id"])] = {
@@ -186,6 +244,8 @@ def get_dashboard_data(for_date: date) -> dict:
} }
# ── Responses ─────────────────────────────────────────────────────────────────
def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None: def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None:
"""Insert or update a response. Handles changed_at logic. """Insert or update a response. Handles changed_at logic.
@@ -199,16 +259,13 @@ def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | Non
).fetchone() ).fetchone()
if existing is None: if existing is None:
# First response ever
db.execute( db.execute(
"INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)", "INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)",
(user_id, period_id, new_status), (user_id, period_id, new_status),
) )
elif existing["status"] == new_status: elif existing["status"] == new_status:
# No change — nothing to update
pass pass
elif existing["status"] in ("yes", "no") and new_status in ("yes", "no"): elif existing["status"] in ("yes", "no") and new_status in ("yes", "no"):
# Flipping between yes/no → mind changed
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db.execute( db.execute(
"UPDATE responses SET status = ?, changed_at = ? " "UPDATE responses SET status = ?, changed_at = ? "
@@ -216,7 +273,6 @@ def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | Non
(new_status, now, user_id, period_id), (new_status, now, user_id, period_id),
) )
else: else:
# Transition from 'not_answered' → 'yes'/'no' (first answer)
db.execute( db.execute(
"UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?", "UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?",
(new_status, user_id, period_id), (new_status, user_id, period_id),
+37 -1
View File
@@ -36,6 +36,7 @@ body {
} }
.brand { font-size: 1.2rem; font-weight: 700; } .brand { font-size: 1.2rem; font-weight: 700; }
.nav-right { display: flex; align-items: center; gap: 12px; } .nav-right { display: flex; align-items: center; gap: 12px; }
.nav-household { font-weight: 600; font-size: 0.85rem; color: var(--primary); background: #e3f2fd; padding: 2px 10px; border-radius: 12px; }
.nav-user { font-weight: 500; color: var(--muted); } .nav-user { font-weight: 500; color: var(--muted); }
/* ── Buttons ── */ /* ── Buttons ── */
@@ -65,9 +66,10 @@ body {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 32px; padding: 32px;
max-width: 380px; max-width: 420px;
margin: 40px auto; margin: 40px auto;
} }
.register-card { max-width: 480px; }
.form-card h2 { margin-bottom: 20px; } .form-card h2 { margin-bottom: 20px; }
.form-card label { display: block; margin: 12px 0 4px; font-weight: 500; font-size: 0.9rem; } .form-card label { display: block; margin: 12px 0 4px; font-weight: 500; font-size: 0.9rem; }
.form-card input[type="text"], .form-card input[type="text"],
@@ -81,6 +83,40 @@ body {
.form-card button[type="submit"] { margin-top: 20px; width: 100%; } .form-card button[type="submit"] { margin-top: 20px; width: 100%; }
.form-footer { margin-top: 16px; text-align: center; font-size: 0.85rem; color: var(--muted); } .form-footer { margin-top: 16px; text-align: center; font-size: 0.85rem; color: var(--muted); }
/* Household section */
.household-section {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin: 16px 0;
}
.household-section legend {
font-weight: 600;
font-size: 0.9rem;
padding: 0 8px;
}
.radio-label {
display: flex !important;
align-items: center;
gap: 8px;
margin: 12px 0 4px !important;
cursor: pointer;
font-weight: 400 !important;
}
.radio-label input[type="radio"] { margin: 0; }
.household-block {
margin: 4px 0 12px 24px;
}
.select-input, .text-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 1rem;
background: var(--surface);
}
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
/* ── Flashes ── */ /* ── Flashes ── */
.flashes { margin-bottom: 16px; } .flashes { margin-bottom: 16px; }
.flash { .flash {
+2 -1
View File
@@ -11,7 +11,8 @@
<span class="brand">🍽 Meal Tracker</span> <span class="brand">🍽 Meal Tracker</span>
{% if session.user_id %} {% if session.user_id %}
<div class="nav-right"> <div class="nav-right">
<span class="nav-user">{{ session.username }}</span> <span class="nav-household">{{ session.household_name }}</span>
<span class="nav-user">{{ session.username }}{% if session.is_admin %} (admin){% endif %}</span>
<a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a> <a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a>
</div> </div>
{% endif %} {% endif %}
+37 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Register — Meal Tracker{% endblock %} {% block title %}Register — Meal Tracker{% endblock %}
{% block content %} {% block content %}
<div class="form-card"> <div class="form-card register-card">
<h2>Create Account</h2> <h2>Create Account</h2>
<form method="POST"> <form method="POST">
<label for="username">Username</label> <label for="username">Username</label>
@@ -13,6 +13,42 @@
<label for="confirm">Confirm Password</label> <label for="confirm">Confirm Password</label>
<input type="password" id="confirm" name="confirm" required> <input type="password" id="confirm" name="confirm" required>
<fieldset class="household-section">
<legend>Household</legend>
<label class="radio-label">
<input type="radio" name="household_action" value="join" checked
onchange="document.getElementById('join-block').hidden=false;
document.getElementById('create-block').hidden=true;">
Join an existing household
</label>
<div id="join-block" class="household-block">
<select name="household_id" class="select-input">
<option value="">— Select —</option>
{% for h in households %}
<option value="{{ h.id }}">{{ h.name }}</option>
{% endfor %}
</select>
{% if not households %}
<p class="hint">No households yet. Create one below!</p>
{% endif %}
</div>
<label class="radio-label">
<input type="radio" name="household_action" value="create"
onchange="document.getElementById('join-block').hidden=true;
document.getElementById('create-block').hidden=false;"
{% if not households %}checked{% endif %}>
Create a new household
</label>
<div id="create-block" class="household-block" hidden>
<input type="text" name="new_household_name"
placeholder="Household name (e.g. The Smiths)"
class="text-input">
<p class="hint">You'll be the admin of this household.</p>
</div>
</fieldset>
<button type="submit" class="btn btn-primary">Register</button> <button type="submit" class="btn btn-primary">Register</button>
</form> </form>
<p class="form-footer"> <p class="form-footer">
+32 -12
View File
@@ -6,7 +6,7 @@ import tempfile
import pytest import pytest
from app import create_app from app import create_app
from models import init_db, get_db from models import init_db
@pytest.fixture @pytest.fixture
@@ -35,35 +35,55 @@ def client(app):
return app.test_client() 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 @pytest.fixture
def auth_client(client): def auth_client(client):
"""Test client with a logged-in regular user.""" """Test client with a logged-in regular user in a 2-person household."""
client.post("/register", data={ # Admin creates household
"username": "bob",
"password": "bobpass",
"confirm": "bobpass",
})
client.get("/logout")
client.post("/register", data={ client.post("/register", data={
"username": "alice", "username": "alice",
"password": "alicepass", "password": "alicepass",
"confirm": "alicepass", "confirm": "alicepass",
"household_action": "create",
"new_household_name": "Test Household",
}) })
client.get("/logout") client.get("/logout")
# Log in as bob (second user, not admin) # Bob joins the same household
client.post("/login", data={ from models import get_household_by_name
hh = get_household_by_name("Test Household")
client.post("/register", data={
"username": "bob", "username": "bob",
"password": "bobpass", "password": "bobpass",
"confirm": "bobpass",
"household_action": "join",
"household_id": str(hh["id"]),
}) })
return client return client # Logged in as bob (regular user)
@pytest.fixture @pytest.fixture
def admin_client(client): 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={ client.post("/register", data={
"username": "admin", "username": "admin",
"password": "adminpass", "password": "adminpass",
"confirm": "adminpass", "confirm": "adminpass",
"household_action": "create",
"new_household_name": "Admin Household",
}) })
return client return client
+96 -7
View File
@@ -5,24 +5,31 @@ def test_register_page(client):
resp = client.get("/register") resp = client.get("/register")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Create Account" in resp.data 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={ resp = client.post("/register", data={
"username": "testuser", "username": "testuser",
"password": "secret123", "password": "secret123",
"confirm": "secret123", "confirm": "secret123",
"household_action": "create",
"new_household_name": "My House",
}, follow_redirects=True) }, follow_redirects=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Account created" in resp.data 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): def test_register_first_in_household_is_admin(client):
"""First registered user should be admin.""" """First user in a new household should be admin."""
client.post("/register", data={ client.post("/register", data={
"username": "firstuser", "username": "firstuser",
"password": "secret123", "password": "secret123",
"confirm": "secret123", "confirm": "secret123",
"household_action": "create",
"new_household_name": "First House",
}) })
from models import get_user_by_username from models import get_user_by_username
user = get_user_by_username("firstuser") user = get_user_by_username("firstuser")
@@ -30,17 +37,91 @@ def test_register_first_user_is_admin(client):
assert user["is_admin"] == 1 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): def test_register_duplicate_username(client):
client.post("/register", data={ client.post("/register", data={
"username": "dup", "username": "dup",
"password": "pass1234", "password": "pass1234",
"confirm": "pass1234", "confirm": "pass1234",
"household_action": "create",
"new_household_name": "DupHouse",
}) })
client.get("/logout") client.get("/logout")
resp = client.post("/register", data={ resp = client.post("/register", data={
"username": "dup", "username": "dup",
"password": "other5678", "password": "other5678",
"confirm": "other5678", "confirm": "other5678",
"household_action": "create",
"new_household_name": "OtherHouse",
}, follow_redirects=True) }, follow_redirects=True)
assert b"Username already taken" in resp.data assert b"Username already taken" in resp.data
@@ -50,6 +131,8 @@ def test_register_password_mismatch(client):
"username": "someone", "username": "someone",
"password": "abc12345", "password": "abc12345",
"confirm": "abc12346", "confirm": "abc12346",
"household_action": "create",
"new_household_name": "AnyHouse",
}, follow_redirects=True) }, follow_redirects=True)
assert b"Passwords do not match" in resp.data assert b"Passwords do not match" in resp.data
@@ -59,6 +142,8 @@ def test_register_short_username(client):
"username": "x", "username": "x",
"password": "pass1234", "password": "pass1234",
"confirm": "pass1234", "confirm": "pass1234",
"household_action": "create",
"new_household_name": "AnyHouse",
}, follow_redirects=True) }, follow_redirects=True)
assert b"at least 2 characters" in resp.data assert b"at least 2 characters" in resp.data
@@ -68,6 +153,8 @@ def test_register_short_password(client):
"username": "validname", "username": "validname",
"password": "ab", "password": "ab",
"confirm": "ab", "confirm": "ab",
"household_action": "create",
"new_household_name": "AnyHouse",
}, follow_redirects=True) }, follow_redirects=True)
assert b"at least 4 characters" in resp.data assert b"at least 4 characters" in resp.data
@@ -79,20 +166,20 @@ def test_login_page(client):
def test_login_success(client): def test_login_success(client):
# Register first
client.post("/register", data={ client.post("/register", data={
"username": "logintest", "username": "logintest",
"password": "mypassword", "password": "mypassword",
"confirm": "mypassword", "confirm": "mypassword",
"household_action": "create",
"new_household_name": "LoginHouse",
}) })
client.get("/logout") client.get("/logout")
# Then login
resp = client.post("/login", data={ resp = client.post("/login", data={
"username": "logintest", "username": "logintest",
"password": "mypassword", "password": "mypassword",
}, follow_redirects=True) }, follow_redirects=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Today" in resp.data # Dashboard assert b"Today" in resp.data
def test_login_wrong_password(client): def test_login_wrong_password(client):
@@ -100,6 +187,8 @@ def test_login_wrong_password(client):
"username": "logintest2", "username": "logintest2",
"password": "mypassword", "password": "mypassword",
"confirm": "mypassword", "confirm": "mypassword",
"household_action": "create",
"new_household_name": "LoginHouse2",
}) })
client.get("/logout") client.get("/logout")
resp = client.post("/login", data={ resp = client.post("/login", data={
@@ -120,7 +209,7 @@ def test_login_nonexistent_user(client):
def test_logout(auth_client): def test_logout(auth_client):
resp = auth_client.get("/logout", follow_redirects=True) resp = auth_client.get("/logout", follow_redirects=True)
assert resp.status_code == 200 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): def test_unauthenticated_redirect(client):
+70 -37
View File
@@ -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 from datetime import date, datetime
@@ -10,10 +10,25 @@ from models import (
ensure_meal_periods, ensure_meal_periods,
get_dashboard_data, get_dashboard_data,
upsert_response, 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: class TestMealPeriods:
def test_ensure_creates_periods(self, app): def test_ensure_creates_periods(self, app):
with app.app_context(): with app.app_context():
@@ -34,94 +49,112 @@ class TestMealPeriods:
class TestResponses: class TestResponses:
def test_first_response_no_changed_at(self, app): def test_first_response_no_changed_at(self, app):
hh, user = _setup_household_and_user(app)
with app.app_context(): with app.app_context():
from models import create_user
user = create_user("test", "hash", is_admin=False)
periods = ensure_meal_periods(date.today()) periods = ensure_meal_periods(date.today())
lunch = periods[0] lunch = periods[0]
resp = upsert_response(user["id"], lunch["id"], "yes") resp = upsert_response(user["id"], lunch["id"], "yes")
assert resp["status"] == "yes" assert resp["status"] == "yes"
assert resp["changed_at"] is None assert resp["changed_at"] is None
def test_flip_yes_to_no_sets_changed_at(self, app): def test_flip_yes_to_no_sets_changed_at(self, app):
hh, user = _setup_household_and_user(app)
with app.app_context(): with app.app_context():
from models import create_user
user = create_user("flipper", "hash", is_admin=False)
periods = ensure_meal_periods(date.today()) periods = ensure_meal_periods(date.today())
lunch = periods[0] lunch = periods[0]
upsert_response(user["id"], lunch["id"], "yes") upsert_response(user["id"], lunch["id"], "yes")
resp = upsert_response(user["id"], lunch["id"], "no") resp = upsert_response(user["id"], lunch["id"], "no")
assert resp["status"] == "no" assert resp["status"] == "no"
assert resp["changed_at"] is not None assert resp["changed_at"] is not None
def test_flip_twice_updates_changed_at(self, app): def test_flip_twice_updates_changed_at(self, app):
hh, user = _setup_household_and_user(app, "flipper")
with app.app_context(): with app.app_context():
from models import create_user
user = create_user("flipper2", "hash", is_admin=False)
periods = ensure_meal_periods(date.today()) periods = ensure_meal_periods(date.today())
lunch = periods[0] lunch = periods[0]
upsert_response(user["id"], lunch["id"], "yes") upsert_response(user["id"], lunch["id"], "yes")
r1 = upsert_response(user["id"], lunch["id"], "no") r1 = upsert_response(user["id"], lunch["id"], "no")
r2 = upsert_response(user["id"], lunch["id"], "yes") r2 = upsert_response(user["id"], lunch["id"], "yes")
# changed_at should reflect the latest flip
assert r2["changed_at"] is not None 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"] assert r2["changed_at"] >= r1["changed_at"]
def test_same_status_no_change(self, app): def test_same_status_no_change(self, app):
hh, user = _setup_household_and_user(app, "same")
with app.app_context(): with app.app_context():
from models import create_user
user = create_user("same", "hash", is_admin=False)
periods = ensure_meal_periods(date.today()) periods = ensure_meal_periods(date.today())
lunch = periods[0] lunch = periods[0]
r1 = upsert_response(user["id"], lunch["id"], "yes") r1 = upsert_response(user["id"], lunch["id"], "yes")
r2 = upsert_response(user["id"], lunch["id"], "yes") r2 = upsert_response(user["id"], lunch["id"], "yes")
assert r1["status"] == r2["status"] assert r1["status"] == r2["status"]
assert r1["changed_at"] == r2["changed_at"] assert r1["changed_at"] == r2["changed_at"]
class TestDashboardData: class TestHouseholdScoping:
def test_dashboard_includes_all_users(self, app): def test_users_isolation(self, app):
"""Users in different households should not see each other."""
with app.app_context(): 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) data1 = get_dashboard_data(date.today(), hh1["id"])
create_user("u2", "h2", is_admin=False) 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 len(data["users"]) == 2
assert data["users"][0]["username"] == "u1" # Bob's default status
assert data["users"][1]["username"] == "u2" 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): def test_dashboard_shows_responses(self, app):
with app.app_context(): with app.app_context():
from models import create_user hh = create_household("RespHouse")
user = create_user("responder", "h", hh["id"], is_admin=True)
user = create_user("responder", "h", is_admin=True)
periods = ensure_meal_periods(date.today()) periods = ensure_meal_periods(date.today())
lunch = next(p for p in periods if p["meal_type"] == "lunch") lunch = next(p for p in periods if p["meal_type"] == "lunch")
upsert_response(user["id"], lunch["id"], "yes") 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"] resp = data["users"][0]["responses"]["lunch"]
assert resp["status"] == "yes" assert resp["status"] == "yes"
def test_dashboard_defaults_not_answered(self, app): def test_dashboard_defaults_not_answered(self, app):
with app.app_context(): 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(), hh["id"])
data = get_dashboard_data(date.today())
resp = data["users"][0]["responses"]["dinner"] resp = data["users"][0]["responses"]["dinner"]
assert resp["status"] == "not_answered" assert resp["status"] == "not_answered"
assert resp["changed_at"] is None assert resp["changed_at"] is None
+23 -4
View File
@@ -15,12 +15,34 @@ class TestDashboardRoute:
def test_dashboard_renders_users(self, auth_client): def test_dashboard_renders_users(self, auth_client):
resp = auth_client.get("/dashboard") 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"alice" in resp.data
assert b"bob" in resp.data assert b"bob" in resp.data
assert b"Lunch" in resp.data assert b"Lunch" in resp.data
assert b"Dinner" 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: class TestRespondRoute:
def test_respond_sets_status(self, auth_client): def test_respond_sets_status(self, auth_client):
@@ -29,16 +51,13 @@ class TestRespondRoute:
"status": "yes", "status": "yes",
}, follow_redirects=True) }, follow_redirects=True)
assert resp.status_code == 200 assert resp.status_code == 200
# After responding, the "Home" chip should be active
assert b"Home" in resp.data assert b"Home" in resp.data
def test_respond_changes_mind(self, auth_client): def test_respond_changes_mind(self, auth_client):
# First respond yes
auth_client.post("/respond", data={ auth_client.post("/respond", data={
"meal_type": "dinner", "meal_type": "dinner",
"status": "yes", "status": "yes",
}) })
# Then change to no
resp = auth_client.post("/respond", data={ resp = auth_client.post("/respond", data={
"meal_type": "dinner", "meal_type": "dinner",
"status": "no", "status": "no",