"""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" )