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).
This commit is contained in:
jonatanritter
2026-07-22 16:25:41 -03:00
parent 1791435a8d
commit 836c267e09
10 changed files with 1237 additions and 1 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ from fiscal_svc.core.db import Base
# Uncomment as modules gain SQLAlchemy models, so autogenerate can see them # Uncomment as modules gain SQLAlchemy models, so autogenerate can see them
# (mirrors auto/backend/alembic/env.py's own convention): # (mirrors auto/backend/alembic/env.py's own convention):
from fiscal_svc.tenancy import models as tenancy_models # noqa: F401 from fiscal_svc.tenancy import models as tenancy_models # noqa: F401
# from fiscal_svc.documents import models as documents_models # noqa: F401 from fiscal_svc.documents import models as documents_models # noqa: F401
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@@ -0,0 +1,126 @@
"""fiscal_series + fiscal_certificates + fiscal_documents (Task 3): the
three tables that "change owner" per the design spec (decision #2) --
ported from the auto's `fiscal_document_series` (tenants), `fiscal_
certificates`/`fiscal_documents` (fiscal), with product_id/tenant_ref/
branch_ref replacing organization_id/branch_id (porte table).
Revision ID: 523235d06bd5
Revises: fd0ffe65c993
Create Date: 2026-07-22 00:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "523235d06bd5"
down_revision: Union[str, Sequence[str], None] = "fd0ffe65c993"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"fiscal_series",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("document_model", sa.String(length=2), nullable=False),
sa.Column("serie", sa.Integer(), nullable=False),
sa.Column("next_number", sa.Integer(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"product_id", "tenant_ref", "branch_ref", "document_model", "serie",
name="uq_fiscal_series_product_tenant_branch_model_serie",
),
)
op.create_table(
"fiscal_certificates",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("cnpj", sa.String(length=14), nullable=False),
sa.Column("pfx_encrypted", sa.LargeBinary(), nullable=False),
sa.Column("password_encrypted", sa.LargeBinary(), nullable=False),
sa.Column("subject_cn", sa.String(length=255), nullable=False),
sa.Column("cnpj_certificado", sa.String(length=14), nullable=False),
sa.Column("not_valid_before", sa.DateTime(timezone=True), nullable=False),
sa.Column("not_valid_after", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_fiscal_certificates_product_branch_live",
"fiscal_certificates",
["product_id", "branch_ref"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_table(
"fiscal_documents",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("product_id", sa.UUID(), nullable=False),
sa.Column("tenant_ref", sa.String(length=64), nullable=False),
sa.Column("branch_ref", sa.String(length=64), nullable=False),
sa.Column("series_id", sa.UUID(), nullable=False),
sa.Column("document_model", sa.String(length=2), nullable=False),
sa.Column("serie", sa.Integer(), nullable=False),
sa.Column("numero", sa.Integer(), nullable=False),
sa.Column("chave_acesso", sa.String(length=44), nullable=False),
sa.Column("codigo_numerico", sa.String(length=8), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("ambiente", sa.String(length=12), nullable=False),
sa.Column("xml_assinado", sa.Text(), nullable=False),
sa.Column("rejeicao_codigo", sa.String(length=10), nullable=True),
sa.Column("rejeicao_motivo", sa.String(length=500), nullable=True),
sa.Column("protocolo", sa.String(length=20), nullable=True),
sa.Column("autorizada_em", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.ForeignKeyConstraint(["series_id"], ["fiscal_series.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("chave_acesso", name="uq_fiscal_documents_chave_acesso"),
)
op.create_index(
"ix_fiscal_documents_product_tenant_branch_status",
"fiscal_documents",
["product_id", "tenant_ref", "branch_ref", "status"],
)
def downgrade() -> None:
op.drop_index("ix_fiscal_documents_product_tenant_branch_status", table_name="fiscal_documents")
op.drop_table("fiscal_documents")
op.drop_index("ix_fiscal_certificates_product_branch_live", table_name="fiscal_certificates")
op.drop_table("fiscal_certificates")
op.drop_table("fiscal_series")
+217
View File
@@ -0,0 +1,217 @@
"""Task 3: the three fiscal tables that "change owner" per the design spec
(decision #2) -- they used to live in the auto (`app/modules/tenants/
models.py`'s `FiscalDocumentSeries`, `app/modules/fiscal/models.py`'s
`FiscalCertificate`/`FiscalDocument`) and now live HERE, as the source of
truth this service was built to be. Ported with the porte table
(2026-07-17-fiscal-svc-f2-servico.md) applied throughout:
| No auto | No serviço |
|-----------------------------------|-----------------------------------|
| organization_id (FK organizations)| product_id (FK products) + |
| | tenant_ref: String(64) |
| branch_id (FK branches) | branch_ref: String(64) + cnpj |
| | (the strong link) |
| Sale/rotas por venda | não existem (sale_id/ |
| | service_order_id DROPPED -- |
| | emissão recebe DadosEmissao |
| | completo, sem correlação a uma |
| | linha de venda deste lado) |
Global Constraints (zero enum PG): unlike the auto's `FiscalDocumentSeries.
document_model` (a REAL Postgres `Enum` -- a documented, deliberately
un-repeated debt there), `document_model` here is plain `String(2)` +
Python enum at the Pydantic border ONLY, same convention the auto's own
`FiscalDocument.document_model`/`.status` already used (see their
docstrings) -- this port does not re-introduce the PG enum anywhere."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from fiscal_svc.core.db import Base
from fiscal_svc.shared.base_model import SoftDeleteMixin, TimestampMixin, UUIDPKMixin
class FiscalDocumentModel(str, enum.Enum):
"""Ported from `auto/backend/app/modules/tenants/models.py`. Python
enum only -- see this module's docstring for why the auto's Postgres
`Enum` column for this same enum is NOT repeated here."""
NFE_55 = "55"
NFCE_65 = "65"
class FiscalDocumentStatus(str, enum.Enum):
"""Ported verbatim from `auto/backend/app/modules/fiscal/models.py` --
already `String` + Python enum there (ZERO Postgres enum), so no
adaptation needed. State machine: `ASSINADO -> TRANSMITINDO ->
AUTORIZADA | REJEITADA | PENDENTE_CONSULTA` (+ `CANCELADA`/`DENEGADA`).
Task 3 (this port) only ever WRITES `ASSINADO` -- transmission (F4)
writes the rest; the full vocabulary exists now so a later phase never
needs a column-shape migration for a new status."""
ASSINADO = "ASSINADO"
TRANSMITINDO = "TRANSMITINDO"
AUTORIZADA = "AUTORIZADA"
REJEITADA = "REJEITADA"
PENDENTE_CONSULTA = "PENDENTE_CONSULTA"
CANCELADA = "CANCELADA"
DENEGADA = "DENEGADA"
class FiscalSeries(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalDocumentSeries` (`tenants/models.py`) --
per the design spec this table is the NEW owner of fiscal numbering
(decision #2: "mudam de dono"). `product_id`/`tenant_ref`/`branch_ref`
replace `organization_id`/`branch_id` per the porte table (this
module's docstring).
Atomic allocation (`SELECT ... FOR UPDATE` + `populate_existing`) is
`documents.service.allocate_fiscal_number` (Task 3, ported with its
exact no-commit contract -- see that function's docstring). The
"guard retroativo" (`next_number` can never regress below the highest
`numero` this series has already emitted, per `FiscalDocument.numero`)
is deliberately NOT implemented in this task -- it belongs to the
`PATCH /v1/series` endpoint (Task 4), which needs this exact model
plus `FiscalDocument` (both now exist, Task 3) to check against. Same
"documented now, wired later" shape as the auto's own
`FiscalDocumentSeries` docstring ("a emissão em si nasce no módulo
fiscal da 1b")."""
__tablename__ = "fiscal_series"
__table_args__ = (
UniqueConstraint(
"product_id", "tenant_ref", "branch_ref", "document_model", "serie",
name="uq_fiscal_series_product_tenant_branch_model_serie",
),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
document_model: Mapped[str] = mapped_column(String(2), nullable=False)
serie: Mapped[int] = mapped_column(Integer, nullable=False)
next_number: Mapped[int] = mapped_column(Integer, nullable=False)
class FiscalCertificate(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalCertificate` (`app/modules/fiscal/
models.py`) -- A1 certificate (.pfx) for a `branch_ref`. Per the porte
table, the emitter is `(product_id, branch_ref)`, with `cnpj` carried
on the row itself (the "vínculo forte", design spec decision #4: the
CNPJ is what's validated against the certificate at upload and against
the emitente at emission -- `branch_ref` alone is an opaque string this
service never interprets). No fallback to a tenant-level or
product-level certificate -- fail-closed by construction, same as the
auto: a branch_ref without its own live certificate cannot emit.
One VIVO (`deleted_at IS NULL`) row per `(product_id, branch_ref)`: a
second upload soft-deletes the previous live row (Task 4's
`certificates.service`). `ix_fiscal_certificates_product_branch_live`
is a PARTIAL UNIQUE index enforcing that at the database level (same
"two concurrent uploads must not both land a live row" reasoning as the
auto's own migration `a1b2c3d4e5f6`'s fix) -- scoped by `product_id` in
ADDITION to `branch_ref` (the auto's version only needed `branch_id`,
already product-scoped by being a real FK; here `branch_ref` is an
OPAQUE string owned by the calling product, so two DIFFERENT products
could coincidentally pick the identical string for two DIFFERENT real
branches -- scoping the uniqueness by `product_id` too is what keeps
that from cross-contaminating one product's certificate slot with
another's).
`pfx_encrypted`/`password_encrypted` are Fernet ciphertext (Task 4,
`FISCAL_CERT_ENCRYPTION_KEY` env var, own key -- never
`app.core.config.Settings`, same "secrets never live in Settings"
convention as the auto)."""
__tablename__ = "fiscal_certificates"
__table_args__ = (
Index(
"ix_fiscal_certificates_product_branch_live",
"product_id",
"branch_ref",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
cnpj: Mapped[str] = mapped_column(String(14), nullable=False)
pfx_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
password_encrypted: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
subject_cn: Mapped[str] = mapped_column(String(255), nullable=False)
cnpj_certificado: Mapped[str] = mapped_column(String(14), nullable=False)
not_valid_before: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
not_valid_after: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class FiscalDocument(Base, UUIDPKMixin, TimestampMixin, SoftDeleteMixin):
"""Ported from the auto's `FiscalDocument` (`app/modules/fiscal/
models.py`). Per the porte table, `sale_id`/`service_order_id` are
DROPPED -- this service has no notion of a Sale/ServiceOrder (decision
#3 of the design spec: it receives a complete `DadosEmissao`, never
resolves or references the caller's own domain objects). A product's
own correlation to ITS sale/OS is the product's problem, carried via
the `Idempotency-Key` (Task 5) it sends, not a column here.
Invariants preserved from the auto (contract, not mere convention):
(a) a row is only inserted in the SAME commit that allocates `numero`
via `documents.service.allocate_fiscal_number` (which does not
commit by itself -- see its docstring) -- outbox transactional,
exactly as ported;
(b) documento é IMUTÁVEL após `ASSINADO` -- correção é cancelar (evento
futuro) e emitir outro, nunca um UPDATE no XML/chave já assinados;
(c) `chave_acesso` is UNIQUE GLOBALLY (not just per product) -- it
already embeds the emitente's CNPJ in its own digits, so two
DIFFERENT products (or tenants) can never legitimately produce the
same one; a collision here can only be a generation bug, never a
false positive between tenants OR between products.
`ix_fiscal_documents_product_tenant_branch_status` replaces the auto's
`ix_fiscal_documents_org_branch_status` (porte table: anti-oracle 404
is now by `(product_id, tenant_ref)`, and `GET /v1/documentos` filters
by `tenant_ref`/`branch_ref`/`status` per the design spec's API
table) -- the index covers exactly that filter shape."""
__tablename__ = "fiscal_documents"
__table_args__ = (
Index(
"ix_fiscal_documents_product_tenant_branch_status",
"product_id",
"tenant_ref",
"branch_ref",
"status",
),
UniqueConstraint("chave_acesso", name="uq_fiscal_documents_chave_acesso"),
)
product_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("products.id"), nullable=False)
tenant_ref: Mapped[str] = mapped_column(String(64), nullable=False)
branch_ref: Mapped[str] = mapped_column(String(64), nullable=False)
series_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("fiscal_series.id"), nullable=False)
document_model: Mapped[str] = mapped_column(String(2), nullable=False)
serie: Mapped[int] = mapped_column(Integer, nullable=False)
numero: Mapped[int] = mapped_column(Integer, nullable=False)
chave_acesso: Mapped[str] = mapped_column(String(44), nullable=False)
codigo_numerico: Mapped[str] = mapped_column(String(8), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
ambiente: Mapped[str] = mapped_column(String(12), nullable=False)
xml_assinado: Mapped[str] = mapped_column(Text, nullable=False)
rejeicao_codigo: Mapped[str | None] = mapped_column(String(10), nullable=True)
rejeicao_motivo: Mapped[str | None] = mapped_column(String(500), nullable=True)
protocolo: Mapped[str | None] = mapped_column(String(20), nullable=True)
autorizada_em: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+124
View File
@@ -0,0 +1,124 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fiscal_svc.documents.models import FiscalDocumentModel, FiscalSeries
class FiscalSeriesNotFoundError(Exception):
"""Ported from the auto's `tenants.service.FiscalSeriesNotFoundError`.
Raised when a `(product_id, tenant_ref, branch_ref, document_model,
serie)` tuple does not resolve to a live row -- deliberately raised
both when the series truly does not exist and when it belongs to
another product/tenant/branch, so a cross-tenant lookup can't be used
to distinguish "not found" from "not yours" (anti-oracle, porte table:
"por org" -> "por (product_id, tenant_ref)")."""
def __init__(
self,
product_id: uuid.UUID,
tenant_ref: str,
branch_ref: str,
document_model: FiscalDocumentModel | str,
serie: int,
):
self.product_id = product_id
self.tenant_ref = tenant_ref
self.branch_ref = branch_ref
self.document_model = document_model
self.serie = serie
super().__init__(
f"Série fiscal não encontrada para product_id={product_id}, "
f"tenant_ref={tenant_ref}, branch_ref={branch_ref}, "
f"modelo={document_model}, série={serie}"
)
async def allocate_fiscal_number(
session: AsyncSession,
product_id: uuid.UUID,
tenant_ref: str,
branch_ref: str,
document_model: FiscalDocumentModel | str,
serie: int,
) -> int:
"""Ported verbatim (mechanism + contract) from the auto's
`tenants.service.allocate_fiscal_number`, with the porte table's
tenancy adaptation applied to the lookup (`organization_id`/`branch_id`
-> `product_id`/`tenant_ref`/`branch_ref`). Every invariant from the
original is preserved:
`SELECT ... FOR UPDATE` locks the SERIES ROW: two concurrent emissions
for the same `(product_id, tenant_ref, branch_ref, document_model,
serie)` never receive the same number -- the second waits for the
first's lock and reads the already-incremented `next_number`.
`.execution_options(populate_existing=True)` is OBRIGATÓRIO: the lock
happens in Postgres, but if this `FiscalSeries` object is already in
THIS session's identity map (e.g. an earlier unlocked read of the same
row, same session), SQLAlchemy's default behavior hands back the
CACHED object without repopulating it from the freshly-(re)locked row
-- even though the `FOR UPDATE` really did lock the real row. Without
this, `next_number` can stay pinned to a stale value and two callers
can allocate the SAME number. `fiscal_svc.core.db`'s
`expire_on_commit=False` makes this worse (the object never expires on
its own after a commit either). See
`auto/backend/app/modules/tenants/service.py::allocate_fiscal_number`'s
docstring and `tests/modules/tenants/test_fiscal_series.py::
test_allocate_fiscal_number_repopulates_identity_mapped_object` for the
original reproduction this ports the fix (and the test) for.
CONTRATO -- ESTA FUNÇÃO NÃO COMMITA. Only `flush()`s: the number is
allocated in memory (and the row stays LOCKED by this transaction's
`FOR UPDATE`) but nothing is durable until the CALLER commits. This is
deliberate: the real emission flow (Task 5) is "alocar número -> montar
o XML -> assinar -> persistir o `FiscalDocument` (com esse número)",
and allocation + the document INSERT must commit TOGETHER, atomically,
in the SAME transaction -- otherwise a failure between allocating and
inserting the document would burn a number with no record of what it
was for (and, worse, no `FiscalDocument` row to even know it needs
inutilização at SEFAZ later). A caller that never commits (a test that
only calls this and rolls back, for instance) leaves the allocation
with zero durable effect -- expected, not a bug.
Not an endpoint -- an INTERNAL function the emission service (Task 5)
calls. Each caller must pass its OWN `AsyncSession` (one transaction per
call): it's one session's `FOR UPDATE` blocking another session's
`SELECT` that actually serializes two concurrent allocations; two calls
sharing one session/transaction would have no real DB concurrency to
serialize."""
result = await session.execute(
select(FiscalSeries)
.where(
FiscalSeries.product_id == product_id,
FiscalSeries.tenant_ref == tenant_ref,
FiscalSeries.branch_ref == branch_ref,
FiscalSeries.document_model == (
document_model.value
if isinstance(document_model, FiscalDocumentModel)
else document_model
),
FiscalSeries.serie == serie,
FiscalSeries.deleted_at.is_(None),
)
.with_for_update()
.execution_options(populate_existing=True)
)
series = result.scalar_one_or_none()
if series is None:
raise FiscalSeriesNotFoundError(
product_id=product_id,
tenant_ref=tenant_ref,
branch_ref=branch_ref,
document_model=document_model,
serie=serie,
)
allocated = series.next_number
series.next_number = allocated + 1
# NÃO commita -- ver a seção "CONTRATO" do docstring. O caller commita,
# idealmente junto com o INSERT do FiscalDocument que consome este
# número. `flush()` garante que o UPDATE já foi enviado ao Postgres (o
# lock do FOR UPDATE segue travado até o commit do caller).
await session.flush()
return allocated
View File
@@ -0,0 +1,274 @@
"""Task 3: `documents.service.allocate_fiscal_number` -- ported from the
auto's `tenants.service.allocate_fiscal_number`
(`tests/modules/tenants/test_fiscal_series.py`), same mechanism/contract,
tenancy adapted to `(product_id, tenant_ref, branch_ref)`. Covers: sequential
allocation, the no-commit contract (Fix 1's two directions), the genuine
N=10 concurrency guarantee, the identity-map staleness repro
`populate_existing` exists to fix, and not-found (missing/soft-deleted/
wrong product/wrong tenant/wrong branch -- every dimension of the new
tenancy, not just product)."""
import asyncio
import uuid
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker
from fiscal_svc.documents.models import FiscalDocumentModel, FiscalSeries
from fiscal_svc.documents.service import FiscalSeriesNotFoundError, allocate_fiscal_number
from fiscal_svc.tenancy.service import create_product
async def _product(db_session, name="auto"):
return await create_product(db_session, name=name, api_key=f"k-{uuid.uuid4().hex}")
async def _series(db_session, product, *, tenant_ref="tenant-1", branch_ref="branch-1", next_number=1014):
series = FiscalSeries(
product_id=product.id,
tenant_ref=tenant_ref,
branch_ref=branch_ref,
document_model=FiscalDocumentModel.NFE_55.value,
serie=1,
next_number=next_number,
)
db_session.add(series)
await db_session.commit()
await db_session.refresh(series)
return series
@pytest.mark.asyncio
async def test_allocate_fiscal_number_sequential_calls_increment(db_session):
product = await _product(db_session)
series = await _series(db_session, product)
first = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
second = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert first == 1014
assert second == 1015
await db_session.refresh(series)
assert series.next_number == 1016
@pytest.mark.asyncio
async def test_allocate_fiscal_number_accepts_the_raw_string_document_model(db_session):
"""`document_model` is a plain `String(2)` column (zero enum PG) -- the
function must accept either the `FiscalDocumentModel` enum member or
its raw `.value` string, since callers (Task 4/5) may hold either."""
product = await _product(db_session)
series = await _series(db_session, product)
allocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, "55", 1
)
await db_session.commit()
assert allocated == 1014
@pytest.mark.asyncio
async def test_allocate_fiscal_number_without_commit_does_not_persist(test_engine, db_session):
"""Fix 1's contract, safe direction: a caller that allocates and then
rolls back (or never commits) leaves NO durable trace -- the next
allocation must hand out the SAME number again."""
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
rollback_session = session_maker()
try:
allocated = await allocate_fiscal_number(
rollback_session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
assert allocated == 100
await rollback_session.rollback()
finally:
await rollback_session.close()
await db_session.refresh(series)
assert series.next_number == 100, (
f"expected next_number to revert to 100 after rollback, got "
f"{series.next_number} -- the un-committed allocation leaked a "
"durable side effect"
)
reallocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert reallocated == 100
@pytest.mark.asyncio
async def test_allocate_fiscal_number_missing_series_raises(db_session):
product = await _product(db_session)
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, "tenant-1", "branch-1", FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_soft_deleted_series_raises(db_session):
from datetime import datetime, timezone
product = await _product(db_session)
series = await _series(db_session, product)
series.deleted_at = datetime.now(timezone.utc)
await db_session.commit()
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_product_raises(db_session):
"""Tenancy-scoping guard (porte adaptation): the SAME
tenant_ref/branch_ref/document_model/serie under a DIFFERENT product_id
must never allocate from a series that belongs to a different product --
the anti-oracle boundary is (product_id, tenant_ref), so this exercises
the product_id half of it."""
product_a = await _product(db_session, name="auto")
series = await _series(db_session, product_a)
product_b = await _product(db_session, name="crm")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product_b.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_tenant_ref_raises(db_session):
"""Same tenancy boundary, tenant_ref half: two tenants of the SAME
product must not share a series just because branch_ref/model/serie
coincide."""
product = await _product(db_session)
series = await _series(db_session, product, tenant_ref="tenant-a")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, "tenant-b", series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_other_branch_ref_raises(db_session):
"""Same tenancy boundary, branch_ref half -- the auto's own guard test
(`test_allocate_fiscal_number_other_org_raises`) had no branch-level
equivalent since `branch_id` there is a real FK scoped by
`organization_id`; here `branch_ref` is its own opaque dimension."""
product = await _product(db_session)
series = await _series(db_session, product, branch_ref="branch-a")
with pytest.raises(FiscalSeriesNotFoundError):
await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, "branch-b", FiscalDocumentModel.NFE_55, 1
)
@pytest.mark.asyncio
async def test_allocate_fiscal_number_concurrent_calls_produce_distinct_sequential_numbers(
test_engine, db_session
):
"""The regression guard this whole feature exists for, ported verbatim
from the auto's own concurrency test: N=10 GENUINELY concurrent
`allocate_fiscal_number` calls against the SAME series must produce 10
DISTINCT, SEQUENTIAL numbers. Each call uses its OWN
`AsyncSession(test_engine)` (a real, separate DB connection) so the
event loop can genuinely interleave their network round-trips."""
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
async def _allocate_and_commit(session):
allocated = await allocate_fiscal_number(
session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
await session.commit()
return allocated
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
sessions = [session_maker() for _ in range(10)]
try:
results = await asyncio.gather(
*(_allocate_and_commit(session) for session in sessions),
return_exceptions=True,
)
finally:
for session in sessions:
await session.close()
errors = [r for r in results if isinstance(r, BaseException)]
assert not errors, f"expected all 10 concurrent allocations to succeed, got errors={errors!r}"
allocated = sorted(results)
assert allocated == list(range(100, 110)), (
f"expected 10 distinct sequential numbers 100..109, got {allocated!r} "
"(a repeat means two concurrent callers received the same NF-e "
"number; a gap means one was skipped)"
)
await db_session.refresh(series)
assert series.next_number == 110
@pytest.mark.asyncio
async def test_allocate_fiscal_number_repopulates_identity_mapped_object(test_engine, db_session):
"""`SELECT ... FOR UPDATE` locks the ROW in Postgres, but if the calling
session already has that row's Python object in its IDENTITY MAP, plain
SQLAlchemy does NOT repopulate that object's attributes from the newly
fetched row by default. Reproduced with ordinary sequential awaits
across two sessions (no `asyncio.gather` needed) -- ported from the
auto's own reproduction."""
from fiscal_svc.documents.models import FiscalSeries as _FiscalSeries
from sqlalchemy import select
product = await _product(db_session)
series = await _series(db_session, product, next_number=100)
# Step 1: put the series into S1 (db_session)'s identity map with
# next_number=100.
loaded = (
await db_session.execute(select(_FiscalSeries).where(_FiscalSeries.id == series.id))
).scalar_one()
assert loaded.next_number == 100
# Step 2: a genuinely separate session allocates 100 and commits -- the
# DB row now holds 101.
session_maker = async_sessionmaker(test_engine, expire_on_commit=False)
other_session = session_maker()
try:
other_allocated = await allocate_fiscal_number(
other_session, product.id, series.tenant_ref, series.branch_ref,
FiscalDocumentModel.NFE_55, 1,
)
await other_session.commit()
finally:
await other_session.close()
assert other_allocated == 100
# Step 3: S1 allocates next -- MUST be 101 (the real, post-S2 value),
# never 100 again (that would be a duplicate NF-e number).
allocated = await allocate_fiscal_number(
db_session, product.id, series.tenant_ref, series.branch_ref, FiscalDocumentModel.NFE_55, 1
)
await db_session.commit()
assert allocated == 101, (
f"expected 101 (the value S2 already committed), got {allocated} -- "
"S1's allocate_fiscal_number returned a STALE identity-map-cached "
"next_number instead of repopulating from the FOR UPDATE-locked row"
)
@@ -0,0 +1,306 @@
"""Migration test (Task 3): `fiscal_series` + `fiscal_certificates` +
`fiscal_documents` -- table/columns exist via `information_schema` over the
`create_all` schema (Teste A), and each round-trips (INSERT cru via the
real ORM) on a database built PURELY by `alembic upgrade head` (Teste B) --
same two-test precedent as `test_products_schema.py` (Task 2) and the
auto's own `tests/migrations/test_fiscal_*_schema.py`. Also proves the
constraints Global Constraints calls out by name: UNIQUE `chave_acesso`,
the partial-unique cert-vivo-per-`(product_id, branch_ref)` index, and the
UNIQUE `(product_id, tenant_ref, branch_ref, document_model, serie)` on
`fiscal_series`."""
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
# Import de registro: garante que os modelos relevantes estão registrados em
# Base.metadata antes do create_all da fixture test_engine, ao rodar este
# arquivo isolado.
from fiscal_svc.documents import models as _documents_models # noqa: F401
from fiscal_svc.tenancy import models as _tenancy_models # noqa: F401
from tests.migrations._helpers import run_alembic, run_psql
_MIGRATION_DB_NAME = "fiscal_svc_test_fiscal_documents_schema"
_MIGRATION_DB_URL = (
f"postgresql+asyncpg://postgres:postgres@localhost:5432/{_MIGRATION_DB_NAME}"
)
@pytest.mark.asyncio
async def test_fiscal_series_table_and_columns_exist(db_session):
rows = {
r[0]
for r in (
await db_session.execute(
text(
"select column_name from information_schema.columns "
"where table_name='fiscal_series'"
)
)
)
}
assert {
"id", "product_id", "tenant_ref", "branch_ref", "document_model",
"serie", "next_number", "created_at", "updated_at", "deleted_at",
} <= rows
@pytest.mark.asyncio
async def test_fiscal_certificates_table_and_columns_exist(db_session):
rows = {
r[0]: r[1]
for r in (
await db_session.execute(
text(
"select column_name, is_nullable from information_schema.columns "
"where table_name='fiscal_certificates'"
)
)
)
}
for required in (
"product_id", "tenant_ref", "branch_ref", "cnpj", "pfx_encrypted",
"password_encrypted", "subject_cn", "cnpj_certificado",
"not_valid_before", "not_valid_after", "deleted_at",
):
assert required in rows, f"coluna {required} ausente em fiscal_certificates"
assert rows["pfx_encrypted"] == "NO"
assert rows["deleted_at"] == "YES"
@pytest.mark.asyncio
async def test_fiscal_documents_table_and_columns_exist(db_session):
rows = {
r[0]: r[1]
for r in (
await db_session.execute(
text(
"select column_name, is_nullable from information_schema.columns "
"where table_name='fiscal_documents'"
)
)
)
}
for required in (
"product_id", "tenant_ref", "branch_ref", "series_id", "document_model",
"serie", "numero", "chave_acesso", "codigo_numerico", "status",
"ambiente", "xml_assinado", "rejeicao_codigo", "rejeicao_motivo",
"protocolo", "autorizada_em", "deleted_at",
):
assert required in rows, f"coluna {required} ausente em fiscal_documents"
assert rows["chave_acesso"] == "NO"
# sale_id/service_order_id are DELIBERATELY absent -- porte table.
assert "sale_id" not in rows
assert "service_order_id" not in rows
@pytest_asyncio.fixture
async def migration_database():
run_psql("-c", f"DROP DATABASE IF EXISTS {_MIGRATION_DB_NAME} WITH (FORCE);")
run_psql("-c", f"CREATE DATABASE {_MIGRATION_DB_NAME};")
yield
run_psql("-c", f"DROP DATABASE IF EXISTS {_MIGRATION_DB_NAME} WITH (FORCE);")
async def _make_product(session, name="auto"):
from fiscal_svc.tenancy.service import create_product
return await create_product(session, name=name, api_key=f"key-{name}-{id(session)}")
@pytest.mark.asyncio
async def test_fiscal_series_and_certificate_and_document_round_trip_on_a_real_migrated_database(
migration_database,
):
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.documents.models import FiscalCertificate, FiscalDocument, FiscalSeries
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
try:
async with session_maker() as session:
product = await _make_product(session)
series = FiscalSeries(
product_id=product.id,
tenant_ref="tenant-1",
branch_ref="branch-1",
document_model="55",
serie=1,
next_number=1014,
)
session.add(series)
await session.flush()
certificate = FiscalCertificate(
product_id=product.id,
tenant_ref="tenant-1",
branch_ref="branch-1",
cnpj="14200166000187",
pfx_encrypted=b"\x00\x01ciphertext-pfx",
password_encrypted=b"\x00\x02ciphertext-pw",
subject_cn="EMPRESA TESTE LTDA:14200166000187",
cnpj_certificado="14200166000187",
not_valid_before=datetime.now(timezone.utc) - timedelta(days=1),
not_valid_after=datetime.now(timezone.utc) + timedelta(days=365),
)
session.add(certificate)
document = FiscalDocument(
product_id=product.id,
tenant_ref="tenant-1",
branch_ref="branch-1",
series_id=series.id,
document_model="55",
serie=1,
numero=1014,
chave_acesso="4" * 44,
codigo_numerico="12345678",
status="ASSINADO",
ambiente="homologacao",
xml_assinado="<NFe/>",
)
session.add(document)
# Would crash here (UndefinedColumn/DataError) before the fix.
await session.commit()
series_id, certificate_id, document_id = series.id, certificate.id, document.id
async with session_maker() as session:
reloaded_series = await session.get(FiscalSeries, series_id)
assert reloaded_series.next_number == 1014
reloaded_cert = await session.get(FiscalCertificate, certificate_id)
assert reloaded_cert.cnpj_certificado == "14200166000187"
assert reloaded_cert.pfx_encrypted == b"\x00\x01ciphertext-pfx"
reloaded_doc = await session.get(FiscalDocument, document_id)
assert reloaded_doc.status == "ASSINADO"
assert reloaded_doc.chave_acesso == "4" * 44
result_q = await session.execute(
select(FiscalDocument).where(FiscalDocument.chave_acesso == "4" * 44)
)
assert result_q.scalar_one().id == document_id
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_chave_acesso_unique_constraint_holds_on_real_migration(migration_database):
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.documents.models import FiscalDocument, FiscalSeries
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
try:
async with session_maker() as session:
product = await _make_product(session, name="auto-chave")
series = FiscalSeries(
product_id=product.id, tenant_ref="t1", branch_ref="b1",
document_model="55", serie=1, next_number=1,
)
session.add(series)
await session.flush()
def _doc(numero):
return FiscalDocument(
product_id=product.id, tenant_ref="t1", branch_ref="b1",
series_id=series.id, document_model="55", serie=1, numero=numero,
chave_acesso="9" * 44, codigo_numerico="12345678",
status="ASSINADO", ambiente="homologacao", xml_assinado="<NFe/>",
)
session.add(_doc(1))
await session.commit()
session.add(_doc(2))
with pytest.raises(IntegrityError):
await session.commit()
await session.rollback()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_two_live_certificates_for_same_product_branch_violate_unique_index_on_real_migration(
migration_database,
):
"""Same fix/reasoning as the auto's `a1b2c3d4e5f6` migration: a PARTIAL
UNIQUE index (here on `(product_id, branch_ref) WHERE deleted_at IS
NULL`) makes two concurrently-uploaded LIVE certificates for the same
slot structurally impossible, not just avoided by the service layer."""
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.documents.models import FiscalCertificate
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
try:
async with session_maker() as session:
product = await _make_product(session, name="auto-cert-corrida")
def _cert():
return FiscalCertificate(
product_id=product.id,
tenant_ref="t1",
branch_ref="b1",
cnpj="14200166000280",
pfx_encrypted=b"\x00pfx",
password_encrypted=b"\x00pw",
subject_cn="EMPRESA TESTE LTDA:14200166000280",
cnpj_certificado="14200166000280",
not_valid_before=datetime.now(timezone.utc) - timedelta(days=1),
not_valid_after=datetime.now(timezone.utc) + timedelta(days=365),
)
session.add(_cert())
await session.commit()
session.add(_cert())
with pytest.raises(IntegrityError):
await session.commit()
await session.rollback()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_duplicate_fiscal_series_tuple_violates_unique_constraint_on_real_migration(
migration_database,
):
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
assert result.returncode == 0, result.stderr
from fiscal_svc.documents.models import FiscalSeries
engine = create_async_engine(_MIGRATION_DB_URL, echo=False)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
try:
async with session_maker() as session:
product = await _make_product(session, name="auto-serie-dup")
def _series():
return FiscalSeries(
product_id=product.id, tenant_ref="t1", branch_ref="b1",
document_model="55", serie=1, next_number=1,
)
session.add(_series())
await session.commit()
session.add(_series())
with pytest.raises(IntegrityError):
await session.commit()
await session.rollback()
finally:
await engine.dispose()
View File
@@ -0,0 +1,189 @@
"""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) == []