Initial commit: Flask battery tracker app
- Flask + SQLAlchemy (MariaDB-compatible schema) battery tracking web app - 40 pre-seeded batteries (Eneloop, BONAI, Energizer NiMH) across 5 devices - Business rules: block retired assignment, brand-mix warnings, capacity checks - Mobile-friendly Jinja2 templates with inline CSS - waitress WSGI server via systemd user service (sbin/install-service.sh) - SQLite → MariaDB migration script (migrate_to_mariadb.py) - 26 passing acceptance tests (pytest + Flask test client) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Acceptance tests for Battery Tracker.
|
||||
|
||||
Each test gets a fresh in-memory SQLite database via the fixtures in conftest.py.
|
||||
The `seeded_client` fixture pre-populates:
|
||||
Devices: Device A (2 slots), Device B (1 slot)
|
||||
Batteries: TST-01 (BrandX, available), TST-02 (BrandY, available), TST-03 (BrandX, retired)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_location(resp):
|
||||
"""Return the redirect Location header (without host)."""
|
||||
loc = resp.headers.get("Location", "")
|
||||
# Strip scheme+host if present
|
||||
if loc.startswith("http"):
|
||||
from urllib.parse import urlparse
|
||||
loc = urlparse(loc).path
|
||||
return loc
|
||||
|
||||
|
||||
def follow(client, resp):
|
||||
"""Follow a single redirect."""
|
||||
return client.get(get_location(resp))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Dashboard
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_dashboard_loads(seeded_client):
|
||||
resp = seeded_client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b"TST-01" in resp.data
|
||||
assert b"TST-02" in resp.data
|
||||
assert b"TST-03" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — add
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_battery(client):
|
||||
resp = client.post("/battery/add",
|
||||
data={"label": "NEW-01", "brand": "TestBrand", "status": "available"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"NEW-01" in resp.data
|
||||
|
||||
|
||||
def test_add_battery_duplicate_label(seeded_client):
|
||||
resp = seeded_client.post("/battery/add",
|
||||
data={"label": "TST-01", "brand": "OtherBrand", "status": "available"})
|
||||
assert resp.status_code == 400
|
||||
assert b"already exists" in resp.data
|
||||
|
||||
|
||||
def test_add_battery_missing_fields(client):
|
||||
resp = client.post("/battery/add", data={"label": "", "brand": ""})
|
||||
assert resp.status_code == 400
|
||||
assert b"required" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — detail
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_battery_detail(seeded_client):
|
||||
resp = seeded_client.get("/battery/1")
|
||||
assert resp.status_code == 200
|
||||
assert b"TST-01" in resp.data
|
||||
assert b"BrandX" in resp.data
|
||||
|
||||
|
||||
def test_battery_detail_not_found(client):
|
||||
resp = client.get("/battery/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — edit notes
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_edit_notes(seeded_client):
|
||||
seeded_client.post("/battery/1/edit-notes", data={"notes": "test note here"})
|
||||
resp = seeded_client.get("/battery/1")
|
||||
assert b"test note here" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — assign
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_assign_battery(seeded_client):
|
||||
# TST-01 is battery id=1, Device A is device id=1
|
||||
resp = seeded_client.post("/battery/1/assign",
|
||||
data={"device_id": "1"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"Device A" in resp.data
|
||||
|
||||
|
||||
def test_assign_retired_battery_blocked(seeded_client):
|
||||
# TST-03 is retired (id=3)
|
||||
resp = seeded_client.post("/battery/3/assign",
|
||||
data={"device_id": "1"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"retired" in resp.data.lower()
|
||||
|
||||
|
||||
def test_assign_over_capacity_blocked(seeded_client):
|
||||
# Fill Device B (1 slot) with TST-01
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
|
||||
# Try to assign TST-02 to the same full device
|
||||
resp = seeded_client.post("/battery/2/assign",
|
||||
data={"device_id": "2"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"full" in resp.data.lower()
|
||||
|
||||
|
||||
def test_brand_mix_warning(seeded_client):
|
||||
# Assign TST-01 (BrandX) to Device A
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
# Assign TST-02 (BrandY) to same Device A → brand mix warning, but succeeds
|
||||
resp = seeded_client.post("/battery/2/assign",
|
||||
data={"device_id": "1"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
# Warning flash should mention mixing
|
||||
assert b"mixing" in resp.data.lower() or b"mix" in resp.data.lower() or b"brand" in resp.data.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — unassign
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_unassign_battery(seeded_client):
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
resp = seeded_client.post("/battery/1/unassign", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
# Battery should show as available on dashboard
|
||||
resp2 = seeded_client.get("/battery/1")
|
||||
assert b"available" in resp2.data.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — retire
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_retire_battery(seeded_client):
|
||||
resp = seeded_client.post("/battery/1/retire", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
resp2 = seeded_client.get("/battery/1")
|
||||
assert b"retired" in resp2.data.lower()
|
||||
|
||||
|
||||
def test_retire_clears_device(seeded_client):
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
seeded_client.post("/battery/1/retire")
|
||||
resp = seeded_client.get("/battery/1")
|
||||
assert b"None" in resp.data or b"retired" in resp.data.lower()
|
||||
|
||||
|
||||
def test_retire_already_retired(seeded_client):
|
||||
# TST-03 is already retired (id=3)
|
||||
resp = seeded_client.post("/battery/3/retire", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"already retired" in resp.data.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Battery — delete
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_delete_battery_confirmation_page(seeded_client):
|
||||
resp = seeded_client.get("/battery/1/delete")
|
||||
assert resp.status_code == 200
|
||||
assert b"Confirm Delete" in resp.data
|
||||
assert b"TST-01" in resp.data
|
||||
|
||||
|
||||
def test_delete_battery(seeded_client):
|
||||
seeded_client.post("/battery/1/delete")
|
||||
resp = seeded_client.get("/battery/1")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Device — list
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_device_list(seeded_client):
|
||||
resp = seeded_client.get("/device/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Device A" in resp.data
|
||||
assert b"Device B" in resp.data
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Device — add
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_device(client):
|
||||
resp = client.post("/device/add",
|
||||
data={"name": "My Gadget", "battery_slots": "3"},
|
||||
follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
assert b"My Gadget" in resp.data
|
||||
|
||||
|
||||
def test_add_device_duplicate_name(seeded_client):
|
||||
resp = seeded_client.post("/device/add",
|
||||
data={"name": "Device A", "battery_slots": "2"})
|
||||
assert resp.status_code == 400
|
||||
assert b"already exists" in resp.data
|
||||
|
||||
|
||||
def test_add_device_missing_name(client):
|
||||
resp = client.post("/device/add", data={"name": "", "battery_slots": "1"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Device — detail
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_device_detail(seeded_client):
|
||||
resp = seeded_client.get("/device/1")
|
||||
assert resp.status_code == 200
|
||||
assert b"Device A" in resp.data
|
||||
assert b"2" in resp.data # battery_slots
|
||||
|
||||
|
||||
def test_device_detail_not_found(client):
|
||||
resp = client.get("/device/9999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Device — delete
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_delete_device_frees_batteries(seeded_client):
|
||||
# Assign TST-01 to Device A
|
||||
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
|
||||
# Delete Device A
|
||||
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
# TST-01 should now be available
|
||||
resp2 = seeded_client.get("/battery/1")
|
||||
assert b"available" in resp2.data.lower()
|
||||
|
||||
|
||||
def test_delete_device_removed(seeded_client):
|
||||
seeded_client.post("/device/1/delete")
|
||||
resp = seeded_client.get("/device/1")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Full round-trip
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_assign_delete_battery(client):
|
||||
# Add a device
|
||||
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
|
||||
# Add a battery
|
||||
client.post("/battery/add", data={"label": "RT-01", "brand": "AcmeBrand", "status": "available"})
|
||||
# Assign it
|
||||
resp = client.post("/battery/1/assign", data={"device_id": "1"}, follow_redirects=True)
|
||||
assert b"Gadget" in resp.data
|
||||
# Delete it
|
||||
client.post("/battery/1/delete")
|
||||
assert client.get("/battery/1").status_code == 404
|
||||
Reference in New Issue
Block a user