Files
sowai-fiscal-svc/tests/shared/test_for_update_populate_existing.py
jonatanritter 836c267e09 feat: fiscal_series, fiscal_certificates, fiscal_documents models + migrations
Ports the three tables that change owner per the design spec (decision #2):
FiscalSeries (was tenants.FiscalDocumentSeries), FiscalCertificate,
FiscalDocument (both from fiscal.models) — organization_id/branch_id
replaced by product_id (FK products) + tenant_ref/branch_ref (opaque
strings) per the porte table. sale_id/service_order_id dropped (no Sale
concept here). document_model is now a plain String(2) + Python enum,
never a Postgres enum (Global Constraints: zero enum PG — the auto's own
version of this column was a real PG enum, a documented debt not repeated
here).

Constraints preserved: UNIQUE chave_acesso, partial-unique
cert-vivo-per-(product_id, branch_ref) (product-scoped in addition to the
auto's branch_id, since branch_ref is an opaque string two different
products could coincidentally share), UNIQUE (product_id, tenant_ref,
branch_ref, document_model, serie) on the series.

documents.service.allocate_fiscal_number ported verbatim (mechanism +
contract): SELECT ... FOR UPDATE + populate_existing=True, no-commit
contract (caller commits together with the FiscalDocument insert, Task 5).
The next_number regression guard is deliberately deferred to Task 4's
PATCH /v1/series endpoint (needs FiscalDocument, which now exists).

AST guard (tests/shared/test_for_update_populate_existing.py) ported and
adapted to scan src/fiscal_svc/, plus two new self-tests proving the
detection logic itself in both directions (flags a missing fix, does not
false-positive on a correctly fixed multi-line chain) — the ported guard
alone only proves "currently green", not "actually detects".

33 tests green via `make k8s-test` (real-migration round trips + unique
constraint violations, N=10 concurrency, identity-map staleness repro,
tenancy-scoping not-found across product/tenant_ref/branch_ref).
2026-07-22 16:25:41 -03:00

190 lines
8.2 KiB
Python

"""Ported verbatim (mechanism unchanged, only the scanned directory) from
`auto/backend/tests/shared/test_for_update_populate_existing.py` -- the
static AST guard for the exact bug `documents.service.allocate_fiscal_number`
exists to avoid: `SELECT ... FOR UPDATE` locks the row in Postgres, but if
the calling session already has that row's object in its identity map (an
earlier unlocked fetch of the same object, same session), SQLAlchemy's
default behavior hands back the CACHED object instead of repopulating it
from the freshly-(re)locked row. The Postgres lock is real; the in-memory
object the code decides against is a stale snapshot.
`.execution_options(populate_existing=True)` is the fix -- see
`fiscal_svc.documents.service.allocate_fiscal_number`'s docstring for the
full write-up (ported from the auto's own `tenants.service.
allocate_fiscal_number`).
Scans every `.py` file under `src/fiscal_svc/` (never `tests/`) with the
`ast` module -- deliberately not regex over the source text, because a
fluent call chain like
`select(...).where(...).with_for_update().execution_options(...)` routinely
breaks across several lines/parens, and a text-based check would either
miss those or need to reimplement a parser badly. Requires that every
`.with_for_update()` call has a `.execution_options(populate_existing=
True)` call somewhere in the SAME chain of method calls."""
from __future__ import annotations
import ast
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[2] / "src" / "fiscal_svc"
REPO_DIR = SRC_DIR.parents[1]
_FIX_EXPLANATION = """
WHY: `.with_for_update()` alone locks the row in Postgres, but if this
session already has an object for that row in its identity map (e.g. an
earlier unlocked lookup of the same row, same session, done for a 404/
access check before calling into the locking code), SQLAlchemy's default
identity-map behavior returns that CACHED object instead of repopulating it
from the row `FOR UPDATE` just (re)read. The lock becomes decorative: the
code holds a real Postgres lock on a row it never actually re-reads, and
makes its decision against a stale in-memory snapshot instead.
HOW TO FIX: chain `.execution_options(populate_existing=True)` onto the
SAME query as the `.with_for_update()`, e.g.:
query = query.with_for_update().execution_options(populate_existing=True)
or, split across lines/parens, as long as it's the same chain:
result = await session.execute(
select(Model)
.where(Model.id == model_id)
.with_for_update()
.execution_options(populate_existing=True)
)
If you have found a `FOR UPDATE` that must NOT populate_existing (none exist
today -- this would be unusual), do not just delete this check or special-
case your file/line here. Talk to the team about adding an explicit,
commented opt-out marker to this test first, so the next reader still gets
an explanation instead of a silently-shrinking guard.
""".strip()
def _is_populate_existing_true_kwarg(call: ast.Call) -> bool:
"""True if `call` is `.execution_options(...)` carrying a literal
`populate_existing=True` keyword argument."""
if not (isinstance(call.func, ast.Attribute) and call.func.attr == "execution_options"):
return False
return any(
kw.arg == "populate_existing" and isinstance(kw.value, ast.Constant) and kw.value.value is True
for kw in call.keywords
)
def _build_parent_map(tree: ast.AST) -> dict[int, ast.AST]:
parents: dict[int, ast.AST] = {}
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
parents[id(child)] = parent
return parents
def _chain_root(node: ast.AST, parents: dict[int, ast.AST]) -> ast.AST:
"""Walk up from `node` while still inside the same fluent method-call
chain -- i.e. while each successive parent is itself a `Call` or
`Attribute` node, the two node shapes chaining (`a.b().c().d()`) is
built from in the AST. This is what lets a chain split across many
lines/parens (still a single expression to the parser) be treated as
one unit, and stops at the first non-chain boundary (assignment,
statement, argument to an unrelated call, ...).
"""
current = node
while True:
parent = parents.get(id(current))
if parent is None or not isinstance(parent, (ast.Call, ast.Attribute)):
return current
current = parent
def _find_violations(src_dir: Path) -> list[str]:
"""Returns one `path:line` string per `.with_for_update()` call site
under `src_dir` that does not have a `.execution_options(
populate_existing=True)` call in the same method-call chain."""
violations: list[str] = []
for path in sorted(src_dir.rglob("*.py")):
source = path.read_text()
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError:
continue
parents = _build_parent_map(tree)
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "with_for_update"
):
continue
root = _chain_root(node, parents)
has_populate_existing = any(
isinstance(candidate, ast.Call) and _is_populate_existing_true_kwarg(candidate)
for candidate in ast.walk(root)
)
if not has_populate_existing:
# `path.relative_to(REPO_DIR)` when `src_dir` is actually
# inside this repo (the real guard below); falls back to the
# absolute path when it isn't (the synthetic-`tmp_path`
# self-tests further down, which scan a throwaway directory
# outside the repo entirely).
try:
rel = path.relative_to(REPO_DIR)
except ValueError:
rel = path
# `end_lineno`, not `lineno`: for a multi-line chain, a
# Call/Attribute node's `lineno` is inherited from where the
# WHOLE expression starts (e.g. the `select(...)` at the top
# of the chain), while `end_lineno` lands on the line the
# `.with_for_update()` text itself is on -- the line the
# next developer actually needs to look at.
violations.append(f"{rel}:{node.end_lineno}")
return violations
def test_every_with_for_update_call_site_chains_populate_existing():
violations = _find_violations(SRC_DIR)
assert not violations, (
"Found `.with_for_update()` call site(s) missing "
"`.execution_options(populate_existing=True)` in the same call "
"chain:\n " + "\n ".join(violations) + "\n\n" + _FIX_EXPLANATION
)
# --- Detection proven in BOTH directions (Global Constraints) --------------
#
# The test above only proves the guard is currently GREEN against this
# repo's real code -- on its own that's equally consistent with "the guard
# actually detects the bug" and "the guard is a no-op that always passes".
# These two exercise `_find_violations` directly against synthetic files in
# a throwaway directory, so the guard's OWN detection logic is proven both
# ways: it flags the missing fix, and it does not false-positive on the
# fixed shape (including a chain split across lines, the exact shape
# `allocate_fiscal_number`'s real call site uses).
def test_find_violations_flags_with_for_update_missing_populate_existing(tmp_path):
(tmp_path / "offender.py").write_text(
"async def f(session):\n"
" return await session.execute(\n"
" select(Model).where(Model.id == x).with_for_update()\n"
" )\n"
)
violations = _find_violations(tmp_path)
assert len(violations) == 1
assert "offender.py" in violations[0]
def test_find_violations_does_not_flag_a_correctly_fixed_multiline_chain(tmp_path):
(tmp_path / "fixed.py").write_text(
"async def f(session):\n"
" return await session.execute(\n"
" select(Model)\n"
" .where(Model.id == x)\n"
" .with_for_update()\n"
" .execution_options(populate_existing=True)\n"
" )\n"
)
assert _find_violations(tmp_path) == []