mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.46.1: re-check of v1.46.0 — HP-1460-01, -02, -03
HP-1460-01: v1.46.0 stopped overwriting attachments, but picking a free name and taking it were two steps. Two uploads racing between them agreed on the same name, both answered 200, and one set of bytes replaced the other; files/migrate had the same check-then-copy gap. reserve_filename now claims the name with O_CREAT|O_EXCL as it picks it, and both paths use it. It also splits the extension off the RAW name and budgets the stem against MAX_FILENAME including the collision tag — a maximal name lost its '.pdf' and then grew past the limit, so the view sanitised the request back to a different name and the attachment 404'd for good. HP-1460-02: cleanup lived in an 'except Exception', which CancelledError walks past, only one tmp_path was tracked, promotion had no finally, and the collector only walks marker folders — an aborted transfer stranded a .upload-* that nothing would ever remove. An outer finally owns every temporary, a second 'file' part is refused, promotion failure cleans up, and sweep_upload_temps runs at setup, daily, and inside the commit-scoped collector. Chunks are batched to 1 MB per disk task instead of one per 64 KB. HP-1460-03: the layout event reached the static card and not the full one, so two full cards diverged until a reload. The full card subscribes now and re-reads ONLY the layout, keyed on its revision. Two hazards handled: it records revisions it produced itself, and the reaction is deferred ~200 ms because the event can beat the reply to our own write over the same socket; positions dragged but not yet sent are flushed and merged on top, so a fix for a stale UI cannot become a lost drag. Tests: smoke_layout_sync (fails on a v1.46.0 build), four pure tests for atomic reservation incl. 20-thread concurrency and the length boundary, a backend test walking every failing exit path of an upload, and — as the report asked — an HA-harness test that a repair issue disappears with its space. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -696,3 +696,90 @@ async def test_upload_never_overwrites_an_existing_attachment(
|
||||
assert await got.read() == b"ONE", "the first file is untouched"
|
||||
got2 = await http.get(second)
|
||||
assert await got2.read() == b"TWO"
|
||||
|
||||
|
||||
async def test_upload_leaves_no_temporary_behind(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1460-02: every exit path must take its temporary file with it.
|
||||
|
||||
The streaming rewrite kept one `tmp_path` and cleaned it in an
|
||||
`except Exception`, which cancellation (a BaseException) walks straight
|
||||
past — and the attachment collector only ever looks inside marker folders,
|
||||
so a stranded `.upload-*` was never seen again.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan import http_api
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
from custom_components.houseplan.plans import TMP_PREFIX
|
||||
|
||||
await _setup(hass)
|
||||
http = await hass_client()
|
||||
root = hass.config.path(FILES_DIR)
|
||||
|
||||
def temps() -> list[str]:
|
||||
return [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)]
|
||||
|
||||
import aiohttp
|
||||
|
||||
def form(*files, marker="m8"):
|
||||
w = aiohttp.FormData()
|
||||
w.add_field("marker_id", marker)
|
||||
for name, data in files:
|
||||
w.add_field("file", data, filename=name)
|
||||
return w
|
||||
|
||||
# two file parts: refused, and nothing left over
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("a.pdf", b"A"), ("b.pdf", b"B")))
|
||||
assert resp.status == 400 and (await resp.json())["error"] == "one_file_only"
|
||||
assert temps() == []
|
||||
|
||||
# a rejected extension after the temporary already exists
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("evil.exe", b"X")))
|
||||
assert resp.status == 400
|
||||
assert temps() == []
|
||||
|
||||
# promotion itself blows up
|
||||
real = http_api.reserve_filename
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("disk on fire")
|
||||
|
||||
monkeypatch.setattr(http_api, "reserve_filename", _boom)
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("c.pdf", b"C")))
|
||||
assert resp.status == 500
|
||||
assert temps() == [], "a failed promotion must not strand the upload"
|
||||
monkeypatch.setattr(http_api, "reserve_filename", real)
|
||||
|
||||
# and the happy path leaves nothing either
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("d.pdf", b"D")))
|
||||
assert resp.status == 200
|
||||
assert temps() == []
|
||||
|
||||
|
||||
async def test_repair_issue_goes_when_its_space_does(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1454-09: the cleanup used to walk only spaces that still exist.
|
||||
|
||||
So deleting or renaming a space with a missing plan left its warning in
|
||||
Repairs with nothing able to clear it.
|
||||
"""
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN as HP_DOMAIN
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
|
||||
|
||||
# the space is deleted entirely — the warning must not outlive it
|
||||
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
|
||||
|
||||
Reference in New Issue
Block a user