diff --git a/README.md b/README.md index 32e742a..8777a0c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Battery Tracker gives you a single source of truth: | Database | SQLite (dev) / MariaDB (prod) | | WSGI server | Waitress | | Process manager | systemd user service | -| Tests | pytest (145 tests) | +| Tests | pytest (255 tests, 99% coverage) | --- diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..c394b86 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,1229 @@ +""" +Tests targeting uncovered lines from coverage analysis. +Covers: 404 paths, validation errors, edge cases, model reprs, +ha_client/ha_poller branches, export/import edge cases, bulk actions. +""" + +import io +import json +import pytest +from unittest.mock import patch, MagicMock + + +# ------------------------------------------------------------------ # +# Helpers (mirrors test_acceptance.py) +# ------------------------------------------------------------------ # + +def get_location(resp): + loc = resp.headers.get("Location", "") + if loc.startswith("http"): + from urllib.parse import urlparse + loc = urlparse(loc).path + return loc + + +def follow(client, resp): + return client.get(get_location(resp)) + + +def _make_json(devices=None, batteries=None, charge_logs=None, + capacity_tests=None, pct_logs=None): + return json.dumps({ + "exported_at": "2026-01-01", + "devices": devices or [], + "batteries": batteries or [], + "charge_logs": charge_logs or [], + "capacity_tests": capacity_tests or [], + "pct_logs": pct_logs or [], + }).encode() + + +def _post_import(client, raw_bytes, filename="export.json"): + return client.post( + "/import", + data={"file": (io.BytesIO(raw_bytes), filename)}, + content_type="multipart/form-data", + ) + + +# ------------------------------------------------------------------ # +# _parse_date helper (lines 18, 22-23) +# ------------------------------------------------------------------ # + +def test_parse_date_valid(client): + """Valid date passes through battery_add without error.""" + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post( + "/battery/1/edit-details", + data={"purchase_date": "2026-01-15"}, + follow_redirects=True, + ) + assert resp.status_code == 200 + + +def test_parse_date_invalid_returns_none(client): + """Invalid date string is treated as None (no crash, details updated).""" + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post( + "/battery/1/edit-details", + data={"purchase_date": "not-a-date"}, + follow_redirects=True, + ) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------ # +# service_worker route (line 105) +# ------------------------------------------------------------------ # + +def test_service_worker_served(client): + resp = client.get("/sw.js") + assert resp.status_code in (200, 404) # 404 if file absent; route exists + + +# ------------------------------------------------------------------ # +# export page (line 1236) +# ------------------------------------------------------------------ # + +def test_export_page_loads(client): + resp = client.get("/export") + assert resp.status_code == 200 + + +# ------------------------------------------------------------------ # +# battery_add POST — count ValueError and _int ValueError (163-164, 177-178) +# ------------------------------------------------------------------ # + +def test_battery_add_invalid_count_falls_back_to_one(client): + """Non-integer count silently falls back to 1.""" + resp = client.post("/battery/add", + data={"brand": "Eneloop", "count": "abc"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"Eneloop 001" in resp.data + assert b"Eneloop 002" not in resp.data + + +def test_battery_add_invalid_capacity_ignored(client): + """Non-integer capacity_mah in add form is stored as None (no crash).""" + resp = client.post("/battery/add", + data={"brand": "Eneloop", "count": "1", + "capacity_mah": "not-a-number"}, + follow_redirects=True) + assert resp.status_code == 200 + detail = client.get("/battery/1") + assert detail.status_code == 200 + + +# ------------------------------------------------------------------ # +# battery_add GET — size-prefix numbering (lines 216, 221-223) +# ------------------------------------------------------------------ # + +def test_battery_add_get_size_prefix_numbering(client): + """GET /battery/add includes size-prefixed prefix_max_nums.""" + client.post("/battery/add", + data={"brand": "Eneloop", "count": "2", "size": "AA", + "include_size_in_label": "on"}) + resp = client.get("/battery/add") + assert resp.status_code == 200 + assert b"prefixMaxNums" in resp.data + + +# ------------------------------------------------------------------ # +# battery_edit_details — 404 and _int ValueError (284, 291-292) +# ------------------------------------------------------------------ # + +def test_battery_edit_details_404(client): + resp = client.post("/battery/9999/edit-details", data={"notes": "x"}) + assert resp.status_code == 404 + + +def test_battery_edit_details_invalid_int_field(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/edit-details", + data={"capacity_mah": "xyz"}, + follow_redirects=True) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------ # +# battery_capacity_test_add — 404, missing fields, invalid mah (336-349) +# ------------------------------------------------------------------ # + +def test_capacity_test_add_404(client): + resp = client.post("/battery/9999/capacity-test/add", + data={"tested_capacity_mah": "1000", "tested_date": "2026-01-01"}) + assert resp.status_code == 404 + + +def test_capacity_test_add_missing_date(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "1000", "tested_date": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() or b"valid date" in resp.data.lower() + + +def test_capacity_test_add_missing_mah(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "", "tested_date": "2026-01-01"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() or b"valid date" in resp.data.lower() + + +def test_capacity_test_add_zero_mah(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "0", "tested_date": "2026-01-01"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"positive" in resp.data.lower() + + +def test_capacity_test_add_negative_mah(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "-500", "tested_date": "2026-01-01"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"positive" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# battery_capacity_test_delete — 404 (363) +# ------------------------------------------------------------------ # + +def test_capacity_test_delete_404(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/capacity-test/9999/delete") + assert resp.status_code == 404 + + +def test_capacity_test_delete_wrong_battery(client): + """Delete attempt where test belongs to a different battery → 404.""" + client.post("/battery/add", data={"brand": "B", "count": "2"}) + client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "1000", "tested_date": "2026-01-01"}) + # test id=1 belongs to battery id=1; try deleting it via battery id=2 + resp = client.post("/battery/2/capacity-test/1/delete") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# battery_charge_log_add — 404 and missing date (379, 382-383) +# ------------------------------------------------------------------ # + +def test_charge_log_add_404(client): + resp = client.post("/battery/9999/charge-log/add", + data={"charged_date": "2026-01-01"}) + assert resp.status_code == 404 + + +def test_charge_log_add_missing_date(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/charge-log/add", + data={"charged_date": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"valid date" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# battery_charge_log_delete — 404 (396) +# ------------------------------------------------------------------ # + +def test_charge_log_delete_404_battery(client): + resp = client.post("/battery/9999/charge-log/1/delete") + assert resp.status_code == 404 + + +def test_charge_log_delete_wrong_battery(client): + client.post("/battery/add", data={"brand": "B", "count": "2"}) + client.post("/battery/1/charge-log/add", + data={"charged_date": "2026-01-01"}) + # log id=1 belongs to battery 1; try deleting via battery 2 + resp = client.post("/battery/2/charge-log/1/delete") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# battery_assign — 404, device not found, device has children (412, 425-430) +# ------------------------------------------------------------------ # + +def test_battery_assign_get_404(client): + resp = client.get("/battery/9999/assign") + assert resp.status_code == 404 + + +def test_battery_assign_post_device_not_found(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/assign", + data={"device_id": "9999"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"not found" in resp.data.lower() + + +def test_battery_assign_to_device_with_children_blocked(client): + """Assigning to a parent device (has sub-components) is blocked.""" + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/assign", + data={"device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# battery_unassign — 404 (469) +# ------------------------------------------------------------------ # + +def test_battery_unassign_404(client): + resp = client.post("/battery/9999/unassign") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# battery_retire — 404 (484) +# ------------------------------------------------------------------ # + +def test_battery_retire_404(client): + resp = client.post("/battery/9999/retire") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# battery_unretire — 404 (502) +# ------------------------------------------------------------------ # + +def test_battery_unretire_404(client): + resp = client.post("/battery/9999/unretire") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# battery_delete — 404 GET (519) +# ------------------------------------------------------------------ # + +def test_battery_delete_get_404(client): + resp = client.get("/battery/9999/delete") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# bulk_action — set_brand no brand, install_device no device_id, +# device not found, has_children, no eligible, brand mix, +# already there + retired skipped notes (565-621) +# ------------------------------------------------------------------ # + +def test_bulk_set_brand_empty_brand(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "set_brand", "new_brand": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() + + +def test_bulk_install_device_no_device_id(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "install_device"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"select a device" in resp.data.lower() + + +def test_bulk_install_device_not_found(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "install_device", + "device_id": "9999"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"not found" in resp.data.lower() + + +def test_bulk_install_device_has_children_blocked(client): + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "install_device", + "device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +def test_bulk_install_no_eligible_batteries(client): + """All selected batteries are retired → no eligible batteries.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/retire") + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "install_device", + "device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no eligible" in resp.data.lower() + + +def test_bulk_install_brand_mix_warning(client): + """Bulk install produces brand-mix warning when mixing brands.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "4", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "Alpha", "count": "1"}) + client.post("/battery/add", data={"brand": "Beta", "count": "1"}) + client.post("/battery/1/assign", data={"device_id": "1"}) # install Alpha first + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["2"], "action": "install_device", + "device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"mixing" in resp.data.lower() or b"brand" in resp.data.lower() + + +def test_bulk_install_already_there_note(client): + """Battery already in target device → message notes 'already there'.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "2"}) + client.post("/battery/1/assign", data={"device_id": "1"}) # already in Box + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1", "2"], "action": "install_device", + "device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"already there" in resp.data.lower() + + +def test_bulk_install_retired_skipped_note(client): + """Retired battery in selection → 'retired skipped' note in message.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "2"}) + client.post("/battery/2/retire") + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1", "2"], "action": "install_device", + "device_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"retired skipped" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# bulk_action — set_field, log_charged, unknown (624-655) +# ------------------------------------------------------------------ # + +def test_bulk_set_field_storage_location(client): + client.post("/battery/add", data={"brand": "B", "count": "2"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1", "2"], "action": "set_field", + "field_name": "storage_location", "field_value": "Drawer"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"Drawer" in client.get("/battery/1").data + + +def test_bulk_set_field_invalid_field(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "set_field", + "field_name": "status", "field_value": "retired"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"invalid field" in resp.data.lower() + + +def test_bulk_set_field_brand_empty(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "set_field", + "field_name": "brand", "field_value": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() + + +def test_bulk_log_charged(client): + client.post("/battery/add", data={"brand": "B", "count": "2"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1", "2"], "action": "log_charged", + "charged_date": "2026-01-15"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"logged charge" in resp.data.lower() + + +def test_bulk_log_charged_with_cycles(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "log_charged", + "charged_date": "2026-01-15", "increment_cycles": "on"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"+cycles" in resp.data.lower() or b"cycles" in resp.data.lower() + + +def test_bulk_log_charged_missing_date(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "log_charged", + "charged_date": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"valid date" in resp.data.lower() + + +def test_bulk_unknown_action(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/bulk-action", + data={"battery_ids": ["1"], "action": "bogus_action"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"unknown action" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# ha_entities — when HA not enabled (line 666) +# ------------------------------------------------------------------ # + +def test_ha_entities_disabled(client): + resp = client.get("/ha/entities") + assert resp.status_code == 200 + assert resp.json == [] + + +# ------------------------------------------------------------------ # +# device_add GET (line 782) +# ------------------------------------------------------------------ # + +def test_device_add_get(client): + resp = client.get("/device/add") + assert resp.status_code == 200 + + +def test_device_add_get_with_invalid_parent_id(client): + """parent_id that is not a digit → prefill_parent remains None, no crash.""" + resp = client.get("/device/add?parent_id=abc") + assert resp.status_code == 200 + + +# ------------------------------------------------------------------ # +# device_add POST — parent validation (710-727, 743-746) +# ------------------------------------------------------------------ # + +def test_device_add_parent_not_found(client): + resp = client.post("/device/add", + data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "9999"}) + assert resp.status_code == 400 + assert b"not found" in resp.data.lower() + + +def test_device_add_parent_is_subcomponent_blocked(client): + """Cannot nest sub-components more than one level.""" + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + resp = client.post("/device/add", + data={"name": "GrandChild", "battery_slots": "1", + "battery_size": "AA", "parent_id": "2"}) + assert resp.status_code == 400 + assert b"one level" in resp.data.lower() + + +def test_device_add_parent_has_slots_blocked(client): + """Parent must have battery_slots=0.""" + client.post("/device/add", data={"name": "NotAParent", "battery_slots": "2", + "battery_size": "AA"}) + resp = client.post("/device/add", + data={"name": "Sub", "battery_slots": "1", + "battery_size": "AA", "parent_id": "1"}) + assert resp.status_code == 400 + + +def test_device_add_invalid_slots(client): + resp = client.post("/device/add", + data={"name": "Box", "battery_slots": "-1", "battery_size": "AA"}) + assert resp.status_code == 400 + assert b"non-negative" in resp.data.lower() + + +def test_device_add_invalid_slots_text(client): + resp = client.post("/device/add", + data={"name": "Box", "battery_slots": "abc", "battery_size": "AA"}) + assert resp.status_code == 400 + + +# ------------------------------------------------------------------ # +# device_edit — validation errors (858-910) +# ------------------------------------------------------------------ # + +def test_device_edit_404(client): + resp = client.post("/device/9999/edit", data={"name": "X", "battery_slots": "1"}) + assert resp.status_code == 404 + + +def test_device_edit_empty_name(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/edit", + data={"name": "", "battery_slots": "2"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() + + +def test_device_edit_invalid_slots_negative(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/edit", + data={"name": "Box", "battery_slots": "-1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"positive" in resp.data.lower() + + +def test_device_edit_zero_slots_without_children(client): + """Setting slots to 0 on a device without children is invalid.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/edit", + data={"name": "Box", "battery_slots": "0"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"positive" in resp.data.lower() + + +def test_device_edit_duplicate_name(client): + client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"}) + client.post("/device/add", data={"name": "Box B", "battery_slots": "1", "battery_size": "AA"}) + resp = client.post("/device/2/edit", + data={"name": "Box A", "battery_slots": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"already exists" in resp.data.lower() + + +def test_device_edit_parent_not_found(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/edit", + data={"name": "Box", "battery_slots": "2", "parent_id": "9999"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"not found" in resp.data.lower() + + +def test_device_edit_own_parent(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/edit", + data={"name": "Box", "battery_slots": "2", "parent_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"own parent" in resp.data.lower() + + +def test_device_edit_cannot_make_parent_into_subcomponent(client): + """A device that already has children cannot become a sub-component.""" + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/device/add", data={"name": "Other", "battery_slots": "0", "battery_size": ""}) + resp = client.post("/device/1/edit", + data={"name": "Parent", "battery_slots": "0", "parent_id": "3"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +def test_device_edit_new_parent_is_subcomponent(client): + """Cannot use a sub-component as a parent (no double nesting).""" + client.post("/device/add", data={"name": "P", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "C", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/device/add", data={"name": "X", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/3/edit", + data={"name": "X", "battery_slots": "2", "parent_id": "2"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"one level" in resp.data.lower() + + +def test_device_edit_new_parent_has_slots(client): + """New parent must have battery_slots=0.""" + client.post("/device/add", data={"name": "NotParent", "battery_slots": "2", "battery_size": "AA"}) + client.post("/device/add", data={"name": "Child", "battery_slots": "1", "battery_size": "AA"}) + resp = client.post("/device/2/edit", + data={"name": "Child", "battery_slots": "1", "parent_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"battery slots set to 0" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# device_install — 404 and has_children and no pairs (946-967) +# ------------------------------------------------------------------ # + +def test_device_install_404(client): + resp = client.post("/device/9999/install", + data={"brand[]": "B", "qty[]": "1"}) + assert resp.status_code == 404 + + +def test_device_install_has_children_blocked(client): + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + resp = client.post("/device/1/install", + data={"brand[]": "B", "qty[]": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +def test_device_install_no_pairs(client): + """Submit form with empty brand/qty → 'No batteries specified'.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/install", + data={"brand[]": "", "qty[]": "0"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no batteries specified" in resp.data.lower() + + +def test_device_install_invalid_qty_falls_to_zero(client): + """Non-integer qty treated as 0 → no pairs → 'No batteries specified'.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "2"}) + resp = client.post("/device/1/install", + data={"brand[]": "B", "qty[]": "abc"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no batteries specified" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# device_install_one — 404, has_children, battery not found, +# brand mix warning, size mismatch warning (1021-1045) +# ------------------------------------------------------------------ # + +def test_device_install_one_404(client): + resp = client.post("/device/9999/install-one", data={"battery_id": "1"}) + assert resp.status_code == 404 + + +def test_device_install_one_has_children_blocked(client): + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/device/1/install-one", + data={"battery_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +def test_device_install_one_battery_not_found(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/install-one", + data={"battery_id": "9999"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"not found" in resp.data.lower() or b"not available" in resp.data.lower() + + +def test_device_install_one_brand_mix_warning(client): + """Installing a different brand produces a warning flash.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "3", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "Alpha", "count": "1"}) + client.post("/battery/add", data={"brand": "Beta", "count": "1"}) + client.post("/device/1/install-one", data={"battery_id": "1"}) + resp = client.post("/device/1/install-one", + data={"battery_id": "2"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"mixing" in resp.data.lower() or b"brand" in resp.data.lower() + + +def test_device_install_one_size_mismatch_warning(client): + """Installing wrong-size battery produces a warning flash.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/edit-details", data={"size": "AAA"}) + resp = client.post("/device/1/install-one", + data={"battery_id": "1"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"aaa" in resp.data.lower() or b"size" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# device_delete — 404 (1064) +# ------------------------------------------------------------------ # + +def test_device_delete_404(client): + resp = client.post("/device/9999/delete") + assert resp.status_code == 404 + + +def test_device_delete_with_children_detaches_them(client): + """Deleting a parent frees sub-components as independent devices.""" + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + resp = client.post("/device/1/delete", follow_redirects=True) + assert resp.status_code == 200 + assert b"independent" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# device_unassign_all — 404 (1088) +# ------------------------------------------------------------------ # + +def test_device_unassign_all_404(client): + resp = client.post("/device/9999/unassign-all") + assert resp.status_code == 404 + + +# ------------------------------------------------------------------ # +# device_install_batch — 404, has_children, no batteries, +# overflow, none installed (1110-1136) +# ------------------------------------------------------------------ # + +def test_device_install_batch_404(client): + resp = client.post("/device/9999/install-batch", + data={"battery_ids": ["1"]}) + assert resp.status_code == 404 + + +def test_device_install_batch_has_children_blocked(client): + client.post("/device/add", data={"name": "Parent", "battery_slots": "0", "battery_size": ""}) + client.post("/device/add", data={"name": "Child", "battery_slots": "2", + "battery_size": "AA", "parent_id": "1"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/device/1/install-batch", + data={"battery_ids": ["1"]}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"sub-component" in resp.data.lower() + + +def test_device_install_batch_no_batteries_selected(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/install-batch", + data={}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no batteries selected" in resp.data.lower() + + +def test_device_install_batch_none_installed_warning(client): + """Selecting an already-installed battery → none actually installed.""" + client.post("/device/add", data={"name": "Box A", "battery_slots": "2", "battery_size": "AA"}) + client.post("/device/add", data={"name": "Box B", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/assign", data={"device_id": "1"}) + resp = client.post("/device/2/install-batch", + data={"battery_ids": ["1"]}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no batteries were installed" in resp.data.lower() + + +def test_device_install_batch_respects_free_slots(client): + """Batch stops once free slots are exhausted (line 1122).""" + client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "3"}) + resp = client.post("/device/1/install-batch", + data={"battery_ids": ["1", "2", "3"]}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"1 / 1" in resp.data + + +# ------------------------------------------------------------------ # +# device_charge_all — 404 and missing date (1147-1151) +# ------------------------------------------------------------------ # + +def test_device_charge_all_404(client): + resp = client.post("/device/9999/charge-all", + data={"charged_date": "2026-01-01"}) + assert resp.status_code == 404 + + +def test_device_charge_all_missing_date(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/assign", data={"device_id": "1"}) + resp = client.post("/device/1/charge-all", + data={"charged_date": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"valid date" in resp.data.lower() + + +def test_device_charge_all_no_installed_batteries(client): + """charge-all on a device with no installed batteries → warning.""" + client.post("/device/add", data={"name": "Box", "battery_slots": "2", "battery_size": "AA"}) + resp = client.post("/device/1/charge-all", + data={"charged_date": "2026-01-01"}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"no installed batteries" in resp.data.lower() + + +# ------------------------------------------------------------------ # +# CSV exports — charge_logs, capacity_tests, pct_logs (1210, 1220, 1230) +# ------------------------------------------------------------------ # + +def test_export_charge_logs_csv(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/charge-log/add", data={"charged_date": "2026-01-01"}) + resp = client.get("/export/charge-logs.csv") + assert resp.status_code == 200 + assert b"charged_date" in resp.data + assert b"2026-01-01" in resp.data + + +def test_export_capacity_tests_csv(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/capacity-test/add", + data={"tested_capacity_mah": "1800", "tested_date": "2026-01-01"}) + resp = client.get("/export/capacity-tests.csv") + assert resp.status_code == 200 + assert b"tested_capacity_mah" in resp.data + assert b"1800" in resp.data + + +def test_export_pct_logs_csv(client): + client.post("/battery/add", data={"brand": "B", "count": "1"}) + client.post("/battery/1/edit-details", data={"battery_percentage": "75"}) + resp = client.get("/export/pct-logs.csv") + assert resp.status_code == 200 + assert b"percentage" in resp.data + assert b"75" in resp.data + + +# ------------------------------------------------------------------ # +# Import edge cases (1347-1511) +# ------------------------------------------------------------------ # + +def test_import_file_too_large(client): + big = b"x" * (10 * 1024 * 1024 + 1) + resp = _post_import(client, big) + assert resp.status_code == 400 + assert b"too large" in resp.data.lower() + + +def test_import_missing_required_keys(client): + payload = json.dumps({"exported_at": "2026-01-01"}).encode() + resp = _post_import(client, payload) + assert resp.status_code == 400 + + +def test_import_battery_empty_label_skipped(client): + payload = _make_json(batteries=[ + {"id": 1, "label": "", "brand": "B", "status": "available", + "device_id": None, "size": None, "chemistry": None, + "capacity_mah": None, "tested_capacity_mah": None, "tested_date": None, + "charge_cycles": None, "purchase_date": None, "storage_location": None, + "battery_percentage": None, "notes": None}, + ]) + resp = _post_import(client, payload) + assert resp.status_code == 200 + assert client.get("/battery/1").status_code == 404 + + +def test_import_capacity_test_unresolved_battery_skipped(client): + """capacity_test referencing a battery not in the payload is skipped.""" + payload = _make_json( + batteries=[], + capacity_tests=[{"id": 1, "battery_id": 999, "battery_label": "X", + "tested_capacity_mah": 1800, "tested_date": "2026-01-01", + "notes": None}], + ) + resp = _post_import(client, payload) + assert resp.status_code == 200 + + +def test_import_pct_log_unresolved_battery_skipped(client): + """pct_log referencing a battery not in the payload is skipped.""" + payload = _make_json( + batteries=[], + pct_logs=[{"id": 1, "battery_id": 999, "battery_label": "X", + "percentage": 80, "recorded_at": "2026-01-01 00:00:00", + "source": "manual"}], + ) + resp = _post_import(client, payload) + assert resp.status_code == 200 + + +def test_import_pct_logs_appended(client): + """pct_logs for an imported battery are appended.""" + payload = _make_json( + batteries=[{"id": 5, "label": "Foo 001", "brand": "Foo", "status": "available", + "device_id": None, "size": None, "chemistry": None, + "capacity_mah": None, "tested_capacity_mah": None, "tested_date": None, + "charge_cycles": None, "purchase_date": None, "storage_location": None, + "battery_percentage": None, "notes": None}], + pct_logs=[{"id": 1, "battery_id": 5, "battery_label": "Foo 001", + "percentage": 80, "recorded_at": "2026-01-01 00:00:00", + "source": "manual"}], + ) + resp = _post_import(client, payload) + assert resp.status_code == 200 + csv_resp = client.get("/export/pct-logs.csv") + assert b"80" in csv_resp.data + + +def test_import_device_with_subcomponent(client): + """Import correctly links sub-components to their parent's new ID.""" + payload = _make_json( + devices=[ + {"id": 10, "name": "Hub", "battery_slots": 0, "battery_size": None, + "device_type": None, "location": None, "ha_entity_id": None, + "notes": None, "parent_id": None}, + {"id": 11, "name": "Module", "battery_slots": 2, "battery_size": "AA", + "device_type": None, "location": None, "ha_entity_id": None, + "notes": None, "parent_id": 10}, + ], + ) + resp = _post_import(client, payload) + assert resp.status_code == 200 + device_list = client.get("/device/") + assert b"Hub" in device_list.data + + +# ------------------------------------------------------------------ # +# Logbook — 404 paths (1539, 1555, 1567, 1583) +# ------------------------------------------------------------------ # + +def test_battery_logbook_add_404(client): + resp = client.post("/battery/9999/logbook/add", data={"body": "test"}) + assert resp.status_code == 404 + + +def test_battery_logbook_delete_404(client): + resp = client.post("/battery/9999/logbook/1/delete") + assert resp.status_code == 404 + + +def test_device_logbook_add_404(client): + resp = client.post("/device/9999/logbook/add", data={"body": "test"}) + assert resp.status_code == 404 + + +def test_device_logbook_delete_404(client): + resp = client.post("/device/9999/logbook/1/delete") + assert resp.status_code == 404 + + +def test_battery_logbook_add_empty_body(client): + """Empty logbook body is rejected with flash error (no 404).""" + client.post("/battery/add", data={"brand": "B", "count": "1"}) + resp = client.post("/battery/1/logbook/add", + data={"body": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() + + +def test_device_logbook_add_empty_body(client): + client.post("/device/add", data={"name": "Box", "battery_slots": "1", "battery_size": "AA"}) + resp = client.post("/device/1/logbook/add", + data={"body": ""}, + follow_redirects=True) + assert resp.status_code == 200 + assert b"required" in resp.data.lower() + + +def test_battery_logbook_delete_wrong_battery(client): + """Logbook entry not belonging to the battery is silently ignored (no 404).""" + client.post("/battery/add", data={"brand": "B", "count": "2"}) + client.post("/battery/1/logbook/add", data={"body": "Entry"}) + resp = client.post("/battery/2/logbook/1/delete", follow_redirects=True) + assert resp.status_code == 200 + + +def test_device_logbook_delete_wrong_device(client): + client.post("/device/add", data={"name": "A", "battery_slots": "1", "battery_size": "AA"}) + client.post("/device/add", data={"name": "B", "battery_slots": "1", "battery_size": "AA"}) + client.post("/device/1/logbook/add", data={"body": "Entry"}) + resp = client.post("/device/2/logbook/1/delete", follow_redirects=True) + assert resp.status_code == 200 + + +# ------------------------------------------------------------------ # +# Model __repr__ methods (models.py lines 100, 157, 172, 187, 202, 217) +# ------------------------------------------------------------------ # + +def test_model_reprs(app): + from models import Battery, Device, CapacityTest, ChargeLog, BatteryPctLog, Logbook + d = Device(name="TestDevice", battery_slots=2) + assert "TestDevice" in repr(d) + + b = Battery(label="Test 001", brand="B", status="available") + assert "Test 001" in repr(b) + + ct = CapacityTest(battery_id=1, tested_capacity_mah=1800, tested_date="2026-01-01") + assert "1800" in repr(ct) + + cl = ChargeLog(battery_id=1, charged_date="2026-01-01", increment_cycles=0) + assert "2026-01-01" in repr(cl) + + pl = BatteryPctLog(battery_id=1, percentage=80, + recorded_at="2026-01-01 00:00:00") + assert "80" in repr(pl) + + lb = Logbook(body="note", recorded_at="2026-01-01 00:00:00") + assert "2026-01-01" in repr(lb) + + +# ------------------------------------------------------------------ # +# ha_client — disabled path and error path (lines 49, 67-69) +# ------------------------------------------------------------------ # + +def test_ha_client_list_battery_entities_disabled(): + from ha_client import HomeAssistantClient + c = HomeAssistantClient(url=None, api_key=None) + assert c.list_battery_entities() == [] + + +def test_ha_client_list_battery_entities_error(): + from ha_client import HomeAssistantClient + import requests as req_lib + c = HomeAssistantClient(url="http://fake", api_key="key") + with patch("ha_client.requests.get", + side_effect=req_lib.ConnectionError("boom")): + result = c.list_battery_entities() + assert result == [] + + +# ------------------------------------------------------------------ # +# ha_poller — stop() and exception path (lines 32, 64-66) +# ------------------------------------------------------------------ # + +def test_ha_poller_stop(): + from ha_poller import HaPoller + from ha_client import HomeAssistantClient + client = HomeAssistantClient(url=None, api_key=None) + poller = HaPoller(client, MagicMock(), interval=300) + poller.stop() + assert poller._stop.is_set() + + +def test_ha_poller_poll_once_exception_path(app): + """_poll_once rolls back and logs on DB exception without crashing.""" + from ha_poller import HaPoller + from ha_client import HomeAssistantClient + + ha = HomeAssistantClient(url="http://fake", api_key="key") + + broken_session = MagicMock() + broken_session.query.side_effect = RuntimeError("db error") + + poller = HaPoller(ha, lambda: broken_session, interval=300) + poller._poll_once() + + broken_session.rollback.assert_called_once() + broken_session.close.assert_called_once() + + +# ------------------------------------------------------------------ # +# battery_add GET — non-numeric label (line 216) +# ------------------------------------------------------------------ # + +def test_battery_add_get_non_numeric_label_skipped(client): + """battery_add GET skips prefix numbering for labels without trailing digits.""" + payload = _make_json(batteries=[ + {"id": 1, "label": "Custom", "brand": "B", "status": "available", + "device_id": None, "size": None, "chemistry": None, + "capacity_mah": None, "tested_capacity_mah": None, "tested_date": None, + "charge_cycles": None, "purchase_date": None, "storage_location": None, + "battery_percentage": None, "notes": None}, + ]) + _post_import(client, payload) + resp = client.get("/battery/add") + assert resp.status_code == 200 + assert b"prefixMaxNums" in resp.data + + +# ------------------------------------------------------------------ # +# import — device with empty name is skipped (lines 1377-1378) +# ------------------------------------------------------------------ # + +def test_import_device_empty_name_skipped(client): + """Import skips devices whose name is empty.""" + payload = _make_json(devices=[ + {"id": 1, "name": "", "battery_slots": 2, "battery_size": "AA", + "device_type": None, "location": None, "ha_entity_id": None, + "notes": None, "parent_id": None}, + ]) + resp = _post_import(client, payload) + assert resp.status_code == 200 + assert client.get("/device/9999").status_code == 404 + + +# ------------------------------------------------------------------ # +# import — exception handler (lines 1508-1511) +# ------------------------------------------------------------------ # + +def test_import_exception_handler(client): + """Import with malformed batteries list triggers the 500 exception handler.""" + payload = json.dumps({ + "devices": [], + "batteries": "not-a-list", + }).encode() + resp = _post_import(client, payload) + assert resp.status_code == 500 + assert b"Import failed" in resp.data + + +# ------------------------------------------------------------------ # +# ha_poller _run loop (line 37) +# ------------------------------------------------------------------ # + +def test_ha_poller_run_loop_executes_poll(app): + """_run loop calls _poll_once at least once before being stopped.""" + from ha_poller import HaPoller + from ha_client import HomeAssistantClient + + ha = HomeAssistantClient(url=None, api_key=None) + call_count = [0] + + poller = HaPoller(ha, MagicMock(), interval=0.01) + + def fake_poll(): + call_count[0] += 1 + poller.stop() + + poller._poll_once = fake_poll + poller._run() + + assert call_count[0] >= 1 + + +# ------------------------------------------------------------------ # +# can_have_subcomponents model method +# ------------------------------------------------------------------ # + +def test_can_have_subcomponents_true(app): + from models import Device + d = Device(name="Parent", battery_slots=0, parent_id=None, parent_key=-1) + assert d.can_have_subcomponents() is True + + +def test_can_have_subcomponents_false_has_slots(app): + from models import Device + d = Device(name="Regular", battery_slots=2, parent_id=None, parent_key=-1) + assert d.can_have_subcomponents() is False + + +def test_can_have_subcomponents_false_is_subcomponent(app): + from models import Device + d = Device(name="Child", battery_slots=0, parent_id=1, parent_key=1) + assert d.can_have_subcomponents() is False