The old UI was two pages of raw pandas tables with no navigation, no styling and no way to see a run's comments. This replaces it with a small, testable application and a beer-themed design. What changed ------------ Restructure - The domain logic now lives in `brewing.py` as pure functions over plain data, with no Flask or Mongo dependency. The gravity maths, the ABV formula and the run-aggregation rules are unit-testable on their own. - `app_factory.py` replaces the module-level app, and the routes move into blueprints under `views/`. A request-scoped Mongo repository is created per request and closed at teardown. - `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`. Reading a document no longer requires a second query to fetch the fields. Interface - A dark, beer-themed design system: warm amber `#be7f39` on near-black, a condensed display face (Anton) for headings and Inter for body text. Both fonts and the chart library are vendored into `static/`, so the container needs no outbound network at runtime — the old templates pulled Bootstrap from a CDN. - A real navigation bar, a login page, a brew-process page with a proper form, and a run-detail page listing every reading with its comment. - The run summary tables now show the beer name and the latest comment, which the old pandas tables did not. - Native form controls (checkbox, number spinners, date icon, scrollbars) are restyled for the dark theme. Correctness - The alcohol calculation now uses the standard homebrew formula, `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is not a recognised brewing formula and overstated every result. - Gravity points and decimal readings now convert correctly in both directions (`1039` <-> `1.039`). - A run's display name resolves to the most-used label across its readings, because `beer_type` is stored per-reading and drifts across a run. - Re-submitting a reading for the same run and day updates it in place rather than creating a duplicate. Security / production - The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an interactive console on any unhandled error. It now runs waitress with debugging off. - `SECRET_KEY` comes from the environment. - The app secret is no longer a hard-coded string in source. Housekeeping - Removes the bokeh/duckdb/pandas dependency chain and the static file it generated at import time; removes the dead `add_user.py` and `test.py`. - Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests). - Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the image is only built when the tests pass. - Adds a README describing the data model and a RUNBOOK for the cluster side. Data compatibility ------------------ The existing `beer_db.beer_data` documents are read as they are and are not migrated or rewritten. No collection is dropped, and no document is modified by this change other than through the explicit "record a reading" form, which keeps the original `(date, beer_run_id)` uniqueness rule.
182 lines
5.9 KiB
Python
182 lines
5.9 KiB
Python
"""Tests for the web layer: login, the brew-process page and the API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from conftest import seed_readings
|
|
|
|
|
|
@pytest.fixture()
|
|
def logged_in(client, repo):
|
|
"""A client with an authenticated session and a user in the database."""
|
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
|
|
|
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
|
follow_redirects=True)
|
|
assert response.status_code == 200
|
|
return client
|
|
|
|
|
|
# ------------------------------------------------------------------ public #
|
|
|
|
def test_healthz_is_public(client):
|
|
response = client.get("/healthz")
|
|
assert response.status_code == 200
|
|
assert response.json == {"status": "ok"}
|
|
|
|
|
|
def test_root_redirects_to_login(client):
|
|
response = client.get("/")
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_login_page_renders(client):
|
|
response = client.get("/login")
|
|
assert response.status_code == 200
|
|
assert b"Brew Log" in response.data
|
|
|
|
|
|
def test_login_with_bad_credentials_is_rejected(client, repo):
|
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
|
|
|
response = client.post("/login", data={"username": "beer", "password": "wrong"},
|
|
follow_redirects=True)
|
|
assert response.status_code == 200
|
|
assert b"not recognised" in response.data
|
|
|
|
|
|
def test_protected_pages_redirect_when_logged_out(client):
|
|
for path in ("/brews", "/runs/1"):
|
|
response = client.get(path)
|
|
assert response.status_code == 302, path
|
|
assert "/login" in response.headers["Location"], path
|
|
|
|
|
|
# ------------------------------------------------------------- brew pages #
|
|
|
|
def test_brews_page_lists_runs(logged_in, repo):
|
|
seed_readings(repo, [
|
|
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
|
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
|
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
|
])
|
|
|
|
response = logged_in.get("/brews")
|
|
assert response.status_code == 200
|
|
body = response.data.decode()
|
|
assert "Pale Ale" in body
|
|
assert "Ginger Beer" in body
|
|
# The comment on the reading should be surfaced.
|
|
assert "Keghing" in body
|
|
|
|
|
|
def test_run_detail_page_renders(logged_in, repo):
|
|
seed_readings(repo, [
|
|
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
|
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
|
])
|
|
|
|
response = logged_in.get("/runs/1")
|
|
assert response.status_code == 200
|
|
body = response.data.decode()
|
|
assert "Pale Ale" in body
|
|
assert "Keghing" in body
|
|
assert "5.25%" in body
|
|
|
|
|
|
# ------------------------------------------------------------- write paths #
|
|
|
|
def test_recording_a_reading_stores_it(logged_in, repo):
|
|
response = logged_in.post("/brews", data={
|
|
"beer_run_id": "5",
|
|
"beer_run_type": "Test Porter",
|
|
"Date": "2026-03-04",
|
|
"sg": "1045",
|
|
"comment": "Hops in",
|
|
}, follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
doc = repo.readings.find_one({"beer_run_id": "5"})
|
|
assert doc is not None
|
|
assert doc["sg"] == "1045"
|
|
assert doc["beer_type"] == "Test Porter"
|
|
assert doc["comment"] == "Hops in"
|
|
assert doc["final_reading"] == "False"
|
|
|
|
|
|
def test_recording_a_reading_twice_updates_in_place(logged_in, repo):
|
|
"""The unique key is (date, beer_run_id) — no duplicate rows."""
|
|
for sg in ("1045", "1035"):
|
|
logged_in.post("/brews", data={
|
|
"beer_run_id": "6",
|
|
"beer_run_type": "Test",
|
|
"Date": "2026-03-04",
|
|
"sg": sg,
|
|
})
|
|
|
|
count = repo.readings.count_documents({"beer_run_id": "6"})
|
|
doc = repo.readings.find_one({"beer_run_id": "6"})
|
|
assert count == 1
|
|
assert doc["sg"] == "1035"
|
|
|
|
|
|
def test_setting_a_final_reading_closes_the_run(logged_in, repo):
|
|
seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")])
|
|
|
|
response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"},
|
|
follow_redirects=True)
|
|
assert response.status_code == 200
|
|
|
|
doc = repo.readings.find_one({"beer_run_id": "7"})
|
|
assert doc["final_reading"] == "True"
|
|
|
|
|
|
def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
|
seed_readings(repo, [
|
|
("7", "Ale", "1045", "2026-03-01", False, ""),
|
|
("7", "Ale", "1010", "2026-03-20", True, ""),
|
|
])
|
|
|
|
logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True)
|
|
|
|
doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"})
|
|
assert doc["final_reading"] == "False"
|
|
|
|
|
|
# -------------------------------------------------------------------- API #
|
|
|
|
|
|
# ----------------------------------------------------------------- misc #
|
|
|
|
|
|
def test_static_assets_are_served(client):
|
|
for path in ("/static/js/app.js", "/static/css/app.css",
|
|
"/static/img/logo.svg"):
|
|
response = client.get(path)
|
|
assert response.status_code == 200, path
|
|
assert len(response.data) > 100, path
|
|
|
|
|
|
def test_logout_clears_the_session(logged_in):
|
|
response = logged_in.post("/logout", follow_redirects=True)
|
|
assert response.status_code == 200
|
|
# After logging out the protected page bounces back to the login page.
|
|
response = logged_in.get("/brews")
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
|
response = logged_in.post("/brews", data={
|
|
"beer_run_id": "12",
|
|
"beer_run_type": "Odd",
|
|
"Date": "2026-05-01",
|
|
"sg": "950",
|
|
}, follow_redirects=True)
|
|
assert response.status_code == 200
|
|
assert b"outside the usual hydrometer range" in response.data
|
|
# It is still stored — the warning is advisory, not a rejection.
|
|
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
|