From d58828e0ef313c16ca47a8785ea35b21a2c0e946 Mon Sep 17 00:00:00 2001 From: agentbox Date: Fri, 31 Jul 2026 21:25:59 +0000 Subject: [PATCH] Add meal tracker app: Flask + SQLite with auth, dashboard, 31 passing tests, Docker Compose --- .gitignore | 3 + Dockerfile.meal | 24 ++++ app.py | 27 +++++ auth.py | 71 ++++++++++++ docker-compose.meal.yml | 15 +++ meals.py | 119 ++++++++++++++++++++ models.py | 229 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + static/style.css | 178 ++++++++++++++++++++++++++++++ templates/base.html | 34 ++++++ templates/dashboard.html | 71 ++++++++++++ templates/login.html | 19 ++++ templates/register.html | 22 ++++ tests/conftest.py | 69 ++++++++++++ tests/test_auth.py | 131 ++++++++++++++++++++++ tests/test_meals.py | 127 ++++++++++++++++++++++ tests/test_routes.py | 72 ++++++++++++ 17 files changed, 1214 insertions(+) create mode 100644 Dockerfile.meal create mode 100644 app.py create mode 100644 auth.py create mode 100644 docker-compose.meal.yml create mode 100644 meals.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/register.html create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_meals.py create mode 100644 tests/test_routes.py diff --git a/.gitignore b/.gitignore index 4c49bd7..e4a2e85 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .env +meals.db +__pycache__/ +.pytest_cache/ diff --git a/Dockerfile.meal b/Dockerfile.meal new file mode 100644 index 0000000..6efbb1c --- /dev/null +++ b/Dockerfile.meal @@ -0,0 +1,24 @@ +FROM python:3.13-alpine + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY app.py models.py auth.py meals.py ./ +COPY templates/ templates/ +COPY static/ static/ + +# Create persistent data directory +RUN mkdir -p /data +ENV DB_PATH=/data/meals.db + +EXPOSE 5000 + +# Generate a default secret key at build time (override in prod) +ENV SECRET_KEY=change-me-in-production + +# Run in production mode (no debug, use a WSGI server) +CMD ["flask", "--app", "app:create_app()", "run", "--host=0.0.0.0", "--port=5000"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..46426af --- /dev/null +++ b/app.py @@ -0,0 +1,27 @@ +"""Meal Tracker — Flask application entry point.""" + +import os + +from flask import Flask + +from models import init_db +from auth import auth_bp +from meals import meals_bp + + +def create_app() -> Flask: + app = Flask(__name__) + app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me") + + app.register_blueprint(auth_bp) + app.register_blueprint(meals_bp) + + with app.app_context(): + init_db() + + return app + + +if __name__ == "__main__": + app = create_app() + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ee8fce5 --- /dev/null +++ b/auth.py @@ -0,0 +1,71 @@ +"""Authentication blueprint — login, register, logout.""" + +from flask import Blueprint, request, render_template, redirect, url_for, session, flash +from werkzeug.security import generate_password_hash, check_password_hash + +from models import get_user_by_username, create_user, count_users + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash("Username and password are required.", "error") + return render_template("login.html") + + user = get_user_by_username(username) + if user is None or not check_password_hash(user["password_hash"], password): + flash("Invalid username or password.", "error") + return render_template("login.html") + + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(url_for("meals.dashboard")) + + return render_template("login.html") + + +@auth_bp.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + confirm = request.form.get("confirm", "") + + errors = [] + if not username or len(username) < 2: + errors.append("Username must be at least 2 characters.") + if not password or len(password) < 4: + errors.append("Password must be at least 4 characters.") + if password != confirm: + errors.append("Passwords do not match.") + if get_user_by_username(username): + errors.append("Username already taken.") + + if errors: + for e in errors: + flash(e, "error") + return render_template("register.html") + + # First user to register becomes admin + is_admin = count_users() == 0 + password_hash = generate_password_hash(password) + create_user(username, password_hash, is_admin=is_admin) + + flash("Account created! Please log in.", "success") + return redirect(url_for("auth.login")) + + return render_template("register.html") + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("auth.login")) diff --git a/docker-compose.meal.yml b/docker-compose.meal.yml new file mode 100644 index 0000000..643c664 --- /dev/null +++ b/docker-compose.meal.yml @@ -0,0 +1,15 @@ +services: + meal-tracker: + build: + context: . + dockerfile: Dockerfile.meal + ports: + - "5000:5000" + volumes: + - meal-data:/data + environment: + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + restart: unless-stopped + +volumes: + meal-data: diff --git a/meals.py b/meals.py new file mode 100644 index 0000000..8d895a6 --- /dev/null +++ b/meals.py @@ -0,0 +1,119 @@ +"""Meals blueprint — dashboard, responses, history.""" + +from datetime import date, datetime, timedelta + +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + session, + flash, + jsonify, +) + +from models import get_dashboard_data, upsert_response, ensure_meal_periods + +meals_bp = Blueprint("meals", __name__) + + +def login_required(f): + """Decorator: redirect to login if not authenticated.""" + from functools import wraps + + @wraps(f) + def wrapper(*args, **kwargs): + if "user_id" not in session: + return redirect(url_for("auth.login")) + return f(*args, **kwargs) + + return wrapper + + +@meals_bp.route("/") +def index(): + if "user_id" in session: + return redirect(url_for("meals.dashboard")) + return redirect(url_for("auth.login")) + + +@meals_bp.route("/dashboard") +@login_required +def dashboard(): + today = date.today() + + # Allow viewing other dates via query param + date_str = request.args.get("date") + if date_str: + try: + today = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + flash("Invalid date format. Use YYYY-MM-DD.", "error") + + data = get_dashboard_data(today) + return render_template( + "dashboard.html", + dashboard=data, + viewing_date=today, + today=date.today(), + prev_date=today - timedelta(days=1), + next_date=today + timedelta(days=1), + ) + + +@meals_bp.route("/respond", methods=["POST"]) +@login_required +def respond(): + meal_type = request.form.get("meal_type", "").strip() + status = request.form.get("status", "").strip() + date_str = request.form.get("date", date.today().isoformat()) + + if meal_type not in ("lunch", "dinner"): + flash("Invalid meal type.", "error") + return redirect(url_for("meals.dashboard")) + + if status not in ("yes", "no"): + flash("Invalid status.", "error") + return redirect(url_for("meals.dashboard")) + + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + flash("Invalid date.", "error") + return redirect(url_for("meals.dashboard")) + + # Only allow responding to today (and not in the past) + if target_date < date.today(): + flash("Cannot change responses for past dates.", "error") + return redirect(url_for("meals.dashboard")) + + periods = ensure_meal_periods(target_date) + period = next((p for p in periods if p["meal_type"] == meal_type), None) + if period is None: + flash("Meal period not found.", "error") + return redirect(url_for("meals.dashboard")) + + upsert_response(session["user_id"], period["id"], status) + return redirect(url_for("meals.dashboard")) + + +@meals_bp.route("/history") +@login_required +def history(): + date_str = request.args.get("date", date.today().isoformat()) + try: + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + target_date = date.today() + + data = get_dashboard_data(target_date) + return render_template( + "dashboard.html", + dashboard=data, + viewing_date=target_date, + today=date.today(), + prev_date=target_date - timedelta(days=1), + next_date=target_date + timedelta(days=1), + history_mode=True, + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..5d6a8a3 --- /dev/null +++ b/models.py @@ -0,0 +1,229 @@ +"""Database models and helpers for the meal tracker.""" + +import sqlite3 +import os +from datetime import date, datetime + +def _db_path() -> str: + return os.environ.get( + "DB_PATH", os.path.join(os.path.dirname(__file__), "meals.db") + ) + + +def get_db() -> sqlite3.Connection: + """Return a connection to the SQLite database with foreign keys enabled.""" + conn = sqlite3.connect(_db_path()) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db() -> None: + """Create tables if they don't exist.""" + with get_db() as db: + db.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS meal_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + meal_type TEXT NOT NULL CHECK(meal_type IN ('lunch', 'dinner')), + UNIQUE(date, meal_type) + ); + + CREATE TABLE IF NOT EXISTS responses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + period_id INTEGER NOT NULL REFERENCES meal_periods(id), + status TEXT NOT NULL DEFAULT 'not_answered' + CHECK(status IN ('yes', 'no', 'not_answered')), + changed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, period_id) + ); + + CREATE INDEX IF NOT EXISTS idx_responses_user + ON responses(user_id); + CREATE INDEX IF NOT EXISTS idx_responses_period + ON responses(period_id); + CREATE INDEX IF NOT EXISTS idx_meal_periods_date + ON meal_periods(date); + """) + + +def ensure_meal_periods(for_date: date) -> list[dict]: + """Ensure lunch and dinner periods exist for a given date. + + Returns the two period rows (as dicts) for that date. + """ + date_str = for_date.isoformat() + with get_db() as db: + db.execute( + "INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'lunch')", + (date_str,), + ) + db.execute( + "INSERT OR IGNORE INTO meal_periods (date, meal_type) VALUES (?, 'dinner')", + (date_str,), + ) + rows = db.execute( + "SELECT id, date, meal_type FROM meal_periods WHERE date = ? ORDER BY meal_type", + (date_str,), + ).fetchall() + return [dict(r) for r in rows] + + +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, 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: + { + "date": "YYYY-MM-DD", + "periods": [{"id": 1, "date": "...", "meal_type": "lunch"}, ...], + "users": [ + { + "id": 1, + "username": "alice", + "responses": { + "lunch": {"status": "yes", "changed_at": None}, + "dinner": {"status": "no", "changed_at": "2025-01-15 16:30:00"}, + } + }, + ... + ] + } + """ + periods = ensure_meal_periods(for_date) + users = get_all_users() + + with get_db() as db: + period_ids = [p["id"] for p in periods] + if not period_ids: + return {"date": for_date.isoformat(), "periods": periods, "users": []} + + placeholders = ",".join("?" * len(period_ids)) + resp_rows = db.execute( + f"SELECT user_id, period_id, status, changed_at " + f"FROM responses " + f"WHERE period_id IN ({placeholders})", + period_ids, + ).fetchall() + + # Build a lookup: (user_id, period_id) → response + resp_map = {} + for r in resp_rows: + resp_map[(r["user_id"], r["period_id"])] = { + "status": r["status"], + "changed_at": r["changed_at"], + } + + period_map = {p["meal_type"]: p["id"] for p in periods} + + enriched_users = [] + for u in users: + user_responses = {} + for meal_type, pid in period_map.items(): + key = (u["id"], pid) + if key in resp_map: + user_responses[meal_type] = resp_map[key] + else: + user_responses[meal_type] = {"status": "not_answered", "changed_at": None} + enriched_users.append({**u, "responses": user_responses}) + + return { + "date": for_date.isoformat(), + "periods": periods, + "users": enriched_users, + } + + +def upsert_response(user_id: int, period_id: int, new_status: str) -> dict | None: + """Insert or update a response. Handles changed_at logic. + + changed_at is set when flipping between 'yes' and 'no'. + It stays NULL on first answer or when status doesn't change. + """ + with get_db() as db: + existing = db.execute( + "SELECT status, changed_at FROM responses WHERE user_id = ? AND period_id = ?", + (user_id, period_id), + ).fetchone() + + if existing is None: + # First response ever + db.execute( + "INSERT INTO responses (user_id, period_id, status) VALUES (?, ?, ?)", + (user_id, period_id, new_status), + ) + elif existing["status"] == new_status: + # No change — nothing to update + pass + 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") + db.execute( + "UPDATE responses SET status = ?, changed_at = ? " + "WHERE user_id = ? AND period_id = ?", + (new_status, now, user_id, period_id), + ) + else: + # Transition from 'not_answered' → 'yes'/'no' (first answer) + db.execute( + "UPDATE responses SET status = ? WHERE user_id = ? AND period_id = ?", + (new_status, user_id, period_id), + ) + + row = db.execute( + "SELECT * FROM responses WHERE user_id = ? AND period_id = ?", + (user_id, period_id), + ).fetchone() + return dict(row) if row else None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0405816 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +Werkzeug>=3.0 +pytest>=7.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1d188d7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,178 @@ +/* ── Reset & Base ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f5f5f5; + --surface: #fff; + --text: #222; + --muted: #888; + --border: #ddd; + --green: #2e7d32; + --green-bg: #e8f5e9; + --red: #c62828; + --red-bg: #ffebee; + --orange: #e65100; + --orange-bg: #fff3e0; + --gray-bg: #f0f0f0; + --primary: #1565c0; + --radius: 8px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +/* ── Nav ── */ +.navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.brand { font-size: 1.2rem; font-weight: 700; } +.nav-right { display: flex; align-items: center; gap: 12px; } +.nav-user { font-weight: 500; color: var(--muted); } + +/* ── Buttons ── */ +.btn { + display: inline-block; + padding: 8px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + font-size: 0.9rem; + text-decoration: none; + color: var(--text); + transition: background 0.15s; +} +.btn:hover { background: #eee; } +.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); } +.btn-primary:hover { background: #0d47a1; } +.btn-sm { padding: 4px 10px; font-size: 0.8rem; } + +/* ── Container ── */ +.container { max-width: 720px; margin: 32px auto; padding: 0 16px; } + +/* ── Forms ── */ +.form-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + max-width: 380px; + margin: 40px auto; +} +.form-card h2 { margin-bottom: 20px; } +.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="password"] { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 1rem; +} +.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); } + +/* ── Flashes ── */ +.flashes { margin-bottom: 16px; } +.flash { + padding: 10px 16px; + border-radius: var(--radius); + margin-bottom: 8px; + font-size: 0.9rem; +} +.flash-error { background: #ffebee; color: #c62828; border: 1px solid #ef9a9a; } +.flash-success { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; } + +/* ── Dashboard ── */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} +.dashboard-header h2 { font-size: 1.3rem; } +.date-nav { display: flex; align-items: center; gap: 8px; } +.date-display { font-weight: 600; font-size: 0.95rem; } + +.table-wrapper { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow-x: auto; +} +.meal-table { width: 100%; border-collapse: collapse; } +.meal-table th, .meal-table td { + padding: 12px 16px; + text-align: center; + border-bottom: 1px solid var(--border); +} +.meal-table th { + background: #fafafa; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} +.meal-table th:first-child, .meal-table td:first-child { text-align: left; } +.user-cell { font-weight: 600; min-width: 100px; } +.meal-col { min-width: 180px; } + +/* ── Response Cells ── */ +.response-cell { vertical-align: middle; } +.response-cell.status-yes { background: var(--green-bg); } +.response-cell.status-no { background: var(--red-bg); } +.response-cell.status-not_answered { background: var(--gray-bg); } + +.btn-row { display: flex; gap: 6px; justify-content: center; } + +/* ── Chips ── */ +.chip { + padding: 6px 18px; + border: 2px solid var(--border); + border-radius: 20px; + background: var(--surface); + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: all 0.15s; +} +.chip:hover:not(:disabled) { border-color: #999; } +.chip:disabled { opacity: 0.4; cursor: not-allowed; } +.chip-yes.active { background: var(--green); color: #fff; border-color: var(--green); } +.chip-no.active { background: var(--red); color: #fff; border-color: var(--red); } + +/* ── Changed-mind badge ── */ +.changed-badge { + font-size: 0.7rem; + color: var(--orange); + font-weight: 600; + margin-top: 4px; +} +.response-cell.changed-mind { + box-shadow: inset 0 0 0 2px var(--orange); +} + +/* ── Static badge (history view for other users) ── */ +.badge { + display: inline-block; + padding: 4px 14px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; +} +.badge-yes { background: var(--green); color: #fff; } +.badge-no { background: var(--red); color: #fff; } +.muted { color: var(--muted); } + +.empty-state { text-align: center; color: var(--muted); margin: 40px 0; } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f77deb7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ + + + + + + {% block title %}Meal Tracker{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..5c35145 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Meal Tracker{% endblock %} +{% block content %} + +
+

+ {% if history_mode %}History — {{ viewing_date.isoformat() }}{% else %}Today's Meals{% endif %} +

+
+ ◀ Prev + {{ viewing_date.isoformat() }} + Next ▶ + {% if viewing_date != today %} + Today + {% endif %} +
+
+ +{% if not dashboard.users %} +

No registered users yet. Ask household members to register!

+{% else %} +
+ + + + + {% for period in dashboard.periods %} + + {% endfor %} + + + + {% for user in dashboard.users %} + + + {% for period in dashboard.periods %} + {% set resp = user.responses[period.meal_type] %} + + {% endfor %} + + {% endfor %} + +
User{{ period.meal_type|capitalize }}
{{ user.username }} + {% if viewing_date >= today or session.is_admin %} +
+ + +
+ + +
+
+ {% if resp.changed_at %} +
↻ {{ resp.changed_at[11:16] }}
+ {% endif %} + {% elif resp.status == 'not_answered' %} + + {% else %} + {{ 'Home' if resp.status == 'yes' else 'Out' }} + {% endif %} +
+
+{% endif %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..71d3435 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Login — Meal Tracker{% endblock %} +{% block content %} +
+

Log In

+
+ + + + + + + +
+ +
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..38cdda3 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Register — Meal Tracker{% endblock %} +{% block content %} +
+

Create Account

+
+ + + + + + + + + + +
+ +
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2250bc1 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..e8b7397 --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_meals.py b/tests/test_meals.py new file mode 100644 index 0000000..be6fb0e --- /dev/null +++ b/tests/test_meals.py @@ -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 diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..75d24c8 --- /dev/null +++ b/tests/test_routes.py @@ -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