Files
battery-tracker-app/tests/test_acceptance.py
T
iterminate 81e87d2fe2 Add inline assign from dashboard, specific battery picker on device, dynamic install rows
- Dashboard: replace Assign link with device dropdown + arrow button for
  quick inline assignment without leaving the page
- Device detail: replace hardcoded 4-row install form with 1 row + '+ Add
  brand' button that clones rows dynamically
- Device detail: add 'Install Specific Battery' card with dropdown of all
  available batteries (label, brand, size, notes) via new /device/<id>/install-one route
- Tests: 4 new acceptance tests covering dashboard quick-assign and
  install-one, including capacity enforcement on both paths (39 total)
2026-04-12 20:15:29 -05:00

429 lines
16 KiB
Python

"""
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: BrandX 001 (id=1, available), BrandY 001 (id=2, available),
BrandX 002 (id=3, retired)
"""
import pytest
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def get_location(resp):
"""Return the redirect Location header (without host)."""
loc = resp.headers.get("Location", "")
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"BrandX 001" in resp.data
assert b"BrandY 001" in resp.data
assert b"BrandX 002" in resp.data
# ------------------------------------------------------------------ #
# Battery — bulk add
# ------------------------------------------------------------------ #
def test_bulk_add_batteries(client):
resp = client.post("/battery/add",
data={"brand": "Eneloop", "count": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Eneloop 001" in resp.data
assert b"Eneloop 002" in resp.data
assert b"Eneloop 003" in resp.data
def test_bulk_add_autogenerates_sequential_labels(client):
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
resp = client.post("/battery/add", data={"brand": "Eneloop", "count": "2"},
follow_redirects=True)
assert b"Eneloop 003" in resp.data
assert b"Eneloop 004" in resp.data
def test_bulk_add_missing_brand(client):
resp = client.post("/battery/add", data={"brand": "", "count": "1"})
assert resp.status_code == 400
assert b"required" in resp.data.lower()
# ------------------------------------------------------------------ #
# Battery — detail
# ------------------------------------------------------------------ #
def test_battery_detail(seeded_client):
resp = seeded_client.get("/battery/1")
assert resp.status_code == 200
assert b"BrandX 001" 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-details", data={"notes": "test note here"})
resp = seeded_client.get("/battery/1")
assert b"test note here" in resp.data
# ------------------------------------------------------------------ #
# Battery — assign (per-battery, kept for special cases)
# ------------------------------------------------------------------ #
def test_assign_battery(seeded_client):
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):
# id=3 is retired
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 id=1
seeded_client.post("/battery/1/assign", data={"device_id": "2"})
# Try to assign id=2 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 BrandX 001 (id=1) to Device A
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
# Assign BrandY 001 (id=2) 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
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
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):
# id=3 is already retired by fixture
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"BrandX 001" 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
# ------------------------------------------------------------------ #
# Battery — bulk actions
# ------------------------------------------------------------------ #
def test_bulk_retire(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "retire"},
follow_redirects=True)
assert resp.status_code == 200
assert b"retired" in client.get("/battery/1").data.lower()
assert b"retired" in client.get("/battery/2").data.lower()
def test_bulk_delete(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "delete"},
follow_redirects=True)
assert resp.status_code == 200
assert client.get("/battery/1").status_code == 404
assert client.get("/battery/2").status_code == 404
def test_bulk_unassign(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "TestBrand", "count": "2"})
client.post("/device/1/install", data={"brand[]": "TestBrand", "qty[]": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "unassign"},
follow_redirects=True)
assert resp.status_code == 200
assert b"available" in client.get("/battery/1").data.lower()
def test_bulk_set_brand(client):
client.post("/battery/add", data={"brand": "OldBrand", "count": "2"})
resp = client.post("/battery/bulk-action",
data={"battery_ids": ["1", "2"], "action": "set_brand",
"new_brand": "NewBrand"},
follow_redirects=True)
assert resp.status_code == 200
assert b"NewBrand" in client.get("/battery/1").data
def test_bulk_action_no_selection(client):
client.post("/battery/add", data={"brand": "TestBrand", "count": "1"})
resp = client.post("/battery/bulk-action",
data={"action": "retire"},
follow_redirects=True)
assert resp.status_code == 200
assert b"No batteries selected" in resp.data
# ------------------------------------------------------------------ #
# Device — install batteries
# ------------------------------------------------------------------ #
def test_device_install_autoselects(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
follow_redirects=True)
assert resp.status_code == 200
# Device detail should show 2 installed batteries
assert b"Eneloop" in resp.data
assert b"2 / 2" in resp.data
def test_device_install_mixed_brands(client):
client.post("/device/add", data={"name": "Remote", "battery_slots": "4"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/battery/add", data={"brand": "Energizer", "count": "2"})
resp = client.post("/device/1/install",
data={"brand[]": ["Eneloop", "Energizer"], "qty[]": ["2", "2"]},
follow_redirects=True)
assert resp.status_code == 200
assert b"Eneloop" in resp.data
assert b"Energizer" in resp.data
def test_device_install_insufficient_batteries(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "4"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"available" in resp.data.lower()
def test_device_install_over_capacity(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "3"})
resp = client.post("/device/1/install",
data={"brand[]": "Eneloop", "qty[]": "3"},
follow_redirects=True)
assert resp.status_code == 200
assert b"slot" in resp.data.lower()
# ------------------------------------------------------------------ #
# Dashboard — quick-assign
# ------------------------------------------------------------------ #
def test_dashboard_quick_assign(client):
"""Quick-assign from dashboard (battery_assign POST) succeeds."""
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/battery/1/assign", data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Box" in resp.data
def test_dashboard_quick_assign_full_device_blocked(client):
"""Quick-assign from dashboard to a full device is blocked."""
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/battery/1/assign", data={"device_id": "1"}) # fills the slot
resp = client.post("/battery/2/assign", data={"device_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"full" in resp.data.lower()
# ------------------------------------------------------------------ #
# Device — install-one (specific battery)
# ------------------------------------------------------------------ #
def test_install_one_specific_battery(client):
"""device_install_one installs a chosen battery."""
client.post("/device/add", data={"name": "Box", "battery_slots": "2"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "1"})
resp = client.post("/device/1/install-one", data={"battery_id": "1"},
follow_redirects=True)
assert resp.status_code == 200
assert b"Eneloop 001" in resp.data
def test_install_one_over_capacity_blocked(client):
"""device_install_one to a full device is blocked."""
client.post("/device/add", data={"name": "Box", "battery_slots": "1"})
client.post("/battery/add", data={"brand": "Eneloop", "count": "2"})
client.post("/device/1/install-one", data={"battery_id": "1"}) # fills slot
resp = client.post("/device/1/install-one", data={"battery_id": "2"},
follow_redirects=True)
assert resp.status_code == 200
assert b"full" in resp.data.lower()
# ------------------------------------------------------------------ #
# 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):
seeded_client.post("/battery/1/assign", data={"device_id": "1"})
resp = seeded_client.post("/device/1/delete", follow_redirects=True)
assert resp.status_code == 200
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_install_delete_battery(client):
client.post("/device/add", data={"name": "Gadget", "battery_slots": "1"})
client.post("/battery/add", data={"brand": "AcmeBrand", "count": "1"})
resp = client.post("/device/1/install",
data={"brand[]": "AcmeBrand", "qty[]": "1"},
follow_redirects=True)
assert b"Gadget" in resp.data
client.post("/battery/1/delete")
assert client.get("/battery/1").status_code == 404