Product is the only tenant table this service owns (porte table: organization_id -> product_id, everything below it -- tenant_ref/ branch_ref -- stays an opaque string owned by the consuming product, never a row here). Bcrypt-hashed API keys (passlib, same CryptContext shape as the auto's auth.service), generated once by scripts/create_product.py and never persisted in the clear. require_product (X-Api-Key -> Product, 401 on missing/wrong/soft-deleted) is the porte adaptation of the auto's require_permission — every /v1/* route will depend on it instead of a JWT bearer token. Also ports shared/base_model.py and shared/errors.py verbatim (Global Constraints: soft delete mixins, structured 409 bodies). products migration + real "alembic upgrade head" round-trip test (tests/migrations/, new shared _helpers.py instead of the auto's ad hoc cross-file _run_psql reuse). 13 tests green via `make k8s-test`.
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""Migration test (Task 2): `products` -- confirms the table/columns exist
|
|
via `information_schema` over the `create_all` schema (Teste A, same
|
|
precedent as the auto's `test_fiscal_cadastro_schema.py`), and that a
|
|
`Product` round-trips (INSERT cru via the real ORM/service) on a database
|
|
built PURELY by `alembic upgrade head` (Teste B) -- never
|
|
`Base.metadata.create_all`, which would never catch the migration itself
|
|
drifting from the model."""
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
# Import de registro: garante que Product está registrado em Base.metadata
|
|
# antes do create_all da fixture test_engine, ao rodar este arquivo isolado.
|
|
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_products_schema"
|
|
_MIGRATION_DB_URL = (
|
|
f"postgresql+asyncpg://postgres:postgres@localhost:5432/{_MIGRATION_DB_NAME}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_products_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='products'"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
for required in ("id", "name", "api_key_hash", "webhook_secret", "created_at", "deleted_at"):
|
|
assert required in rows, f"coluna {required} ausente em products"
|
|
assert rows["name"] == "NO"
|
|
assert rows["api_key_hash"] == "NO"
|
|
assert rows["webhook_secret"] == "YES"
|
|
assert rows["deleted_at"] == "YES"
|
|
|
|
|
|
@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);")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_product_round_trips_on_a_real_migrated_database(migration_database):
|
|
result = run_alembic(_MIGRATION_DB_URL, "upgrade", "head")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
from fiscal_svc.tenancy.service import create_product
|
|
|
|
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 create_product(session, name="auto", api_key="plain-key-123")
|
|
product_id = product.id
|
|
# The plaintext key is NEVER what lands in the column.
|
|
assert product.api_key_hash != "plain-key-123"
|
|
|
|
async with session_maker() as session:
|
|
from fiscal_svc.tenancy.models import Product
|
|
|
|
reloaded = await session.get(Product, product_id)
|
|
assert reloaded.name == "auto"
|
|
assert reloaded.deleted_at is None
|
|
|
|
result_q = await session.execute(select(Product).where(Product.name == "auto"))
|
|
assert result_q.scalar_one().id == product_id
|
|
finally:
|
|
await engine.dispose()
|