fix(e2e): run case init() inside try so partial-init failures tear down

run_case called case.init() outside the try/finally that runs teardown(), so a
case that registers cleanups progressively (create team, then user, then key)
and then fails partway through init() would leak the already-created entities on
the long-lived shared proxy. Move init() inside the try so teardown always runs.

Add a regression test that registers a cleanup then raises mid-init and asserts
the resource is still released.
This commit is contained in:
mateo-berri 2026-06-20 07:41:48 +00:00
parent 0c57b54d77
commit 87d1f5b41c
No known key found for this signature in database
2 changed files with 54 additions and 4 deletions

View file

@ -22,7 +22,8 @@ class E2ECase(Protocol):
"""A stateful e2e check run against a long-lived proxy.
init() acquires resources, run() exercises behaviour and asserts, teardown()
releases everything init() created. teardown() must run even if run() raises.
releases everything init() created. teardown() must run even if init() fails
partway or run() raises.
"""
def init(self) -> None: ...
@ -35,11 +36,14 @@ class E2ECase(Protocol):
def run_case(case: E2ECase) -> None:
"""Drive a case through its lifecycle: init -> run -> teardown.
teardown always runs, even if run() raises (or skips), so resources the case
created on the long-lived proxy are released.
teardown always runs - even when init() fails partway or run() raises (or
skips) - so resources the case already registered on the long-lived proxy are
released. init() is inside the try because cases register cleanups
progressively (e.g. create team, then user, then key), and a failure after
the first creation must still release what came before.
"""
case.init()
try:
case.init()
case.run()
finally:
case.teardown()

View file

@ -0,0 +1,46 @@
"""Unit coverage for the lifecycle harness (lifecycle.run_case).
Cases register cleanups progressively during init() (create team, then user, then
key), so a failure partway through init() must still release whatever was already
created on the long-lived shared proxy. This guards that contract.
"""
from dataclasses import dataclass, field
from typing import Callable, List
import pytest
from lifecycle import run_case
@dataclass
class _PartialInitCase:
"""init() registers a cleanup, then raises before finishing - mirroring a real
case that creates a resource, registers its delete, then fails on the next
step."""
released: List[str] = field(default_factory=list)
_undo: List[Callable[[], None]] = field(default_factory=list)
def init(self) -> None:
self._undo.append(lambda: self.released.append("first"))
raise RuntimeError("init failed after registering the first resource")
def run(self) -> None:
raise AssertionError("run() must not execute when init() failed")
def teardown(self) -> None:
for undo in reversed(self._undo):
undo()
def test_run_case_releases_resources_when_init_fails_partway() -> None:
case = _PartialInitCase()
with pytest.raises(RuntimeError, match="init failed"):
run_case(case)
assert case.released == ["first"], (
"a resource registered before init() failed must still be released, or it "
"leaks on the long-lived shared proxy"
)