Add meal tracker app: Flask + SQLite with auth, dashboard, 31 passing tests, Docker Compose
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Shared test fixtures for the meal tracker."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from models import init_db, get_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create a Flask app with a temporary database."""
|
||||
db_fd, db_path = tempfile.mkstemp(suffix=".db")
|
||||
os.environ["DB_PATH"] = db_path
|
||||
|
||||
app = create_app()
|
||||
app.config["SECRET_KEY"] = "test-secret"
|
||||
app.config["TESTING"] = True
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
|
||||
yield app
|
||||
|
||||
os.close(db_fd)
|
||||
os.unlink(db_path)
|
||||
os.environ.pop("DB_PATH", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Flask test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@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")
|
||||
client.post("/register", data={
|
||||
"username": "alice",
|
||||
"password": "alicepass",
|
||||
"confirm": "alicepass",
|
||||
})
|
||||
client.get("/logout")
|
||||
# Log in as bob (second user, not admin)
|
||||
client.post("/login", data={
|
||||
"username": "bob",
|
||||
"password": "bobpass",
|
||||
})
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(client):
|
||||
"""Test client with the admin user logged in."""
|
||||
client.post("/register", data={
|
||||
"username": "admin",
|
||||
"password": "adminpass",
|
||||
"confirm": "adminpass",
|
||||
})
|
||||
return client
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for authentication: registration, login, logout."""
|
||||
|
||||
|
||||
def test_register_page(client):
|
||||
resp = client.get("/register")
|
||||
assert resp.status_code == 200
|
||||
assert b"Create Account" in resp.data
|
||||
|
||||
|
||||
def test_register_success(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "testuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Account created" in resp.data
|
||||
|
||||
|
||||
def test_register_first_user_is_admin(client):
|
||||
"""First registered user should be admin."""
|
||||
client.post("/register", data={
|
||||
"username": "firstuser",
|
||||
"password": "secret123",
|
||||
"confirm": "secret123",
|
||||
})
|
||||
from models import get_user_by_username
|
||||
user = get_user_by_username("firstuser")
|
||||
assert user is not None
|
||||
assert user["is_admin"] == 1
|
||||
|
||||
|
||||
def test_register_duplicate_username(client):
|
||||
client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/register", data={
|
||||
"username": "dup",
|
||||
"password": "other5678",
|
||||
"confirm": "other5678",
|
||||
}, follow_redirects=True)
|
||||
assert b"Username already taken" in resp.data
|
||||
|
||||
|
||||
def test_register_password_mismatch(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "someone",
|
||||
"password": "abc12345",
|
||||
"confirm": "abc12346",
|
||||
}, follow_redirects=True)
|
||||
assert b"Passwords do not match" in resp.data
|
||||
|
||||
|
||||
def test_register_short_username(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "x",
|
||||
"password": "pass1234",
|
||||
"confirm": "pass1234",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 2 characters" in resp.data
|
||||
|
||||
|
||||
def test_register_short_password(client):
|
||||
resp = client.post("/register", data={
|
||||
"username": "validname",
|
||||
"password": "ab",
|
||||
"confirm": "ab",
|
||||
}, follow_redirects=True)
|
||||
assert b"at least 4 characters" in resp.data
|
||||
|
||||
|
||||
def test_login_page(client):
|
||||
resp = client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
# Register first
|
||||
client.post("/register", data={
|
||||
"username": "logintest",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
client.post("/register", data={
|
||||
"username": "logintest2",
|
||||
"password": "mypassword",
|
||||
"confirm": "mypassword",
|
||||
})
|
||||
client.get("/logout")
|
||||
resp = client.post("/login", data={
|
||||
"username": "logintest2",
|
||||
"password": "wrongpassword",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid username or password" in resp.data
|
||||
|
||||
|
||||
def test_login_nonexistent_user(client):
|
||||
resp = client.post("/login", data={
|
||||
"username": "nobody",
|
||||
"password": "whatever",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid username or password" in resp.data
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_unauthenticated_redirect(client):
|
||||
"""Dashboard and root should redirect to login when not logged in."""
|
||||
for path in ["/", "/dashboard", "/history"]:
|
||||
resp = client.get(path, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Log In" in resp.data
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for meal periods, responses, and changed-mind logic."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from models import (
|
||||
init_db,
|
||||
get_db,
|
||||
ensure_meal_periods,
|
||||
get_dashboard_data,
|
||||
upsert_response,
|
||||
get_user_by_username,
|
||||
)
|
||||
|
||||
|
||||
class TestMealPeriods:
|
||||
def test_ensure_creates_periods(self, app):
|
||||
with app.app_context():
|
||||
periods = ensure_meal_periods(date(2025, 6, 15))
|
||||
assert len(periods) == 2
|
||||
types = {p["meal_type"] for p in periods}
|
||||
assert types == {"lunch", "dinner"}
|
||||
for p in periods:
|
||||
assert p["date"] == "2025-06-15"
|
||||
|
||||
def test_ensure_is_idempotent(self, app):
|
||||
with app.app_context():
|
||||
p1 = ensure_meal_periods(date(2025, 6, 15))
|
||||
p2 = ensure_meal_periods(date(2025, 6, 15))
|
||||
assert p1[0]["id"] == p2[0]["id"]
|
||||
assert p1[1]["id"] == p2[1]["id"]
|
||||
|
||||
|
||||
class TestResponses:
|
||||
def test_first_response_no_changed_at(self, 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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
with app.app_context():
|
||||
from models import create_user
|
||||
|
||||
create_user("u1", "h1", is_admin=True)
|
||||
create_user("u2", "h2", is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
assert len(data["users"]) == 2
|
||||
assert data["users"][0]["username"] == "u1"
|
||||
assert data["users"][1]["username"] == "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)
|
||||
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())
|
||||
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
|
||||
|
||||
create_user("silent", "h", is_admin=False)
|
||||
|
||||
data = get_dashboard_data(date.today())
|
||||
resp = data["users"][0]["responses"]["dinner"]
|
||||
assert resp["status"] == "not_answered"
|
||||
assert resp["changed_at"] is None
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Integration tests for HTTP routes."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TestDashboardRoute:
|
||||
def test_dashboard_requires_login(self, client):
|
||||
resp = client.get("/dashboard", follow_redirects=True)
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
def test_dashboard_shows_today(self, auth_client):
|
||||
resp = auth_client.get("/dashboard")
|
||||
assert resp.status_code == 200
|
||||
assert b"Today" in resp.data
|
||||
|
||||
def test_dashboard_renders_users(self, auth_client):
|
||||
resp = auth_client.get("/dashboard")
|
||||
# bob and alice were registered
|
||||
assert b"alice" in resp.data
|
||||
assert b"bob" in resp.data
|
||||
assert b"Lunch" in resp.data
|
||||
assert b"Dinner" in resp.data
|
||||
|
||||
|
||||
class TestRespondRoute:
|
||||
def test_respond_sets_status(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "lunch",
|
||||
"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",
|
||||
}, follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_respond_invalid_meal_type(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "breakfast",
|
||||
"status": "yes",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid meal type" in resp.data
|
||||
|
||||
def test_respond_invalid_status(self, auth_client):
|
||||
resp = auth_client.post("/respond", data={
|
||||
"meal_type": "lunch",
|
||||
"status": "maybe",
|
||||
}, follow_redirects=True)
|
||||
assert b"Invalid status" in resp.data
|
||||
|
||||
|
||||
class TestHistoryRoute:
|
||||
def test_history_requires_login(self, client):
|
||||
resp = client.get("/history", follow_redirects=True)
|
||||
assert b"Log In" in resp.data
|
||||
|
||||
def test_history_shows_past_date(self, auth_client):
|
||||
past = date.today().replace(year=date.today().year - 1)
|
||||
resp = auth_client.get(f"/history?date={past.isoformat()}")
|
||||
assert resp.status_code == 200
|
||||
assert b"History" in resp.data
|
||||
Reference in New Issue
Block a user