diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7edc0850..24d77a37 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -36,7 +36,7 @@ jobs: set -e if [ -z "$TEST_DATABASE_URL" ]; then echo "::error::Define el secret TEST_DATABASE_URL en el repo (Gitea → Ajustes → Secretos)." - echo "Ejemplo: postgresql://usuario:clave@host:5432/nombre_bd" + echo "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd (host real, no el texto \"host\")" exit 1 fi python3 --version @@ -64,6 +64,8 @@ jobs: python -m pip install --upgrade pip pip install -r "$GITHUB_WORKSPACE/backend/requirements.txt" cd "$GITHUB_WORKSPACE/backend" + export TEST_DATABASE_URL="$TEST_DATABASE_URL" + alembic upgrade head pytest -q tests -v -ra -s build: diff --git a/backend/alembic/env.py b/backend/alembic/env.py index fbe67248..07823c7c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -10,6 +10,7 @@ from alembic.operations import ops from core.config import settings from core.database import Base from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine.url import make_url logger = logging.getLogger(__name__) @@ -18,9 +19,68 @@ logger = logging.getLogger(__name__) config = context.config +def _strip_env_url(raw: str) -> str: + """Quita espacios/comillas típicos de secretos CI (.env, Gitea).""" + url = raw.strip().strip('"').strip("'") + return url + + +def _normalize_alembic_sqlalchemy_url(url: str) -> str: + """Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests.""" + url = _strip_env_url(url) + if url.startswith("postgresql+asyncpg://"): + return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) + if url.startswith("postgresql+psycopg2://"): + return url + if url.startswith("postgresql://"): + return url.replace("postgresql://", "postgresql+psycopg2://", 1) + if url.startswith("postgres://"): + return url.replace("postgres://", "postgresql+psycopg2://", 1) + return url + + +def _validate_sqlalchemy_url(url: str, env_key: str) -> None: + """Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2).""" + try: + make_url(url) + except Exception as e: + raise RuntimeError( + f"{env_key} no es una URL de SQLAlchemy válida. " + "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd" + ) from e + + +def _reject_documentation_placeholder_host(url: str, source: str) -> None: + """ + Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal. + """ + try: + parsed = make_url(url) + except Exception: + return + h = (parsed.host or "").strip().lower() + if h == "host": + raise RuntimeError( + f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. " + "Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, " + "o host.docker.internal si act corre en contenedor y Postgres en tu máquina)." + ) + + def get_database_url(): """Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini.""" - # Intentar construir desde variables de entorno primero + # CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita. + # CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for + # itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida. + for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"): + raw = os.environ.get(env_key) + if raw and raw.strip(): + normalized = _normalize_alembic_sqlalchemy_url(raw) + _validate_sqlalchemy_url(normalized, env_key) + _reject_documentation_placeholder_host(normalized, env_key) + return normalized + + # Construcción desde settings (CORE_DB_* en .env / entorno) host = settings.CORE_DB_HOST db = settings.CORE_DB_NAME user = settings.CORE_DB_USER @@ -32,18 +92,22 @@ def get_database_url(): encoded_user = quote_plus(user) encoded_password = quote_plus(password) encoded_db = quote_plus(db) - return f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}" + built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}" + _reject_documentation_placeholder_host(built, "CORE_DB_HOST") + return built except Exception as e: logger.error(f"Error al construir URL: {e}") # Fallback al archivo de configuración url = config.get_main_option("sqlalchemy.url") - if not url: + if not url or "${" in url or "%(" in url: raise RuntimeError( "No se ha configurado la cadena de conexión a PostgreSQL. " - "Proporciona las variables de entorno POSTGRES_* o configura sqlalchemy.url en alembic.ini" + "Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; " + "sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado." ) + _reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url") return url diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index 70f7bb5f..3f7dc48b 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Dict, Optional, Union from core.exceptions import ErrorCollector from sqlalchemy import func from sqlalchemy.orm import Session @@ -17,8 +17,14 @@ from api.v1.modules.a76.manifests.manifest.models import Manifest from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection -from core.exceptions import ErrorCollector -from typing import Dict, Any + + +def _normalize_invoice_currency_value(value) -> str: + """Lowercase currency code (foreign/local/manual) for comparisons.""" + if value is None or value == "": + return "" + raw = getattr(value, "value", value) + return str(raw).lower() def invoice_exists( @@ -174,10 +180,11 @@ def validate_required_fields_by_operation( def validate_common( db: Session, - invoice: schemas.InvoiceHeaderUpdate, + invoice: Union[schemas.InvoiceHeaderCreate, schemas.InvoiceHeaderUpdate], tenant_id: int, company_id: int, errors: ErrorCollector, + existing_invoice: Optional[models.InvoiceHeader] = None, ): if invoice.compliance_mx.pedimento_id: pedimento = ( @@ -560,69 +567,97 @@ def validate_common( value=invoice.logistics.transport_num, ) - invoice.financials.currency = invoice.financials.currency or "foreign" - - if invoice.financials.currency not in [c.value for c in Currency]: - errors.add_error( - field="financials.currency", - message="La Moneda proporcionada no es válida.", - solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"], - code="INVALID_CURRENCY", - value=invoice.financials.currency, - ) - else: - # Only check for existing items during update operations (when invoice has an id) - if hasattr(invoice, "id"): - has_items = ( - db.query(LineItem) - .filter( - LineItem.invoice_id == invoice.id, - LineItem.tenant_id == tenant_id, - LineItem.company_id == company_id, - ) - .first() + if invoice.financials: + submitted = invoice.financials.currency + stored_str = "" + if existing_invoice and existing_invoice.financials is not None: + stored_str = _normalize_invoice_currency_value( + existing_invoice.financials.currency ) - if has_items: - errors.add_error( - field="items", - message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.", - solution=[ - "Verifica la moneda de los items asociados a la factura." - ], - code="CURRENCY_CANNOT_BE_CHANGED", - value=invoice.financials.currency, - ) - if invoice.financials.currency == "foreign": - invoice.financials.currency_type = "USD" - elif invoice.financials.currency == "local": - invoice.financials.currency_type = "MXN" - elif invoice.financials.currency == "manual": - if not invoice.financials.currency_type: + if submitted is None or submitted == "": + if stored_str: + invoice.financials.currency = stored_str + else: + invoice.financials.currency = "foreign" + else: + invoice.financials.currency = ( + _normalize_invoice_currency_value(submitted) or "foreign" + ) + + if invoice.financials.currency not in [c.value for c in Currency]: errors.add_error( - field="financials.currency_type", - message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.", - solution=["Proporciona un Tipo de Moneda válido"], - code="REQUIRED_FIELD", - value=invoice.financials.currency_type, + field="financials.currency", + message="La Moneda proporcionada no es válida.", + solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"], + code="INVALID_CURRENCY", + value=invoice.financials.currency, ) else: - currency_exists = ( - db.query(CurrencyType) - .filter(CurrencyType.code == invoice.financials.currency_type) - .first() - ) - if not currency_exists: + invoice_id = getattr(invoice, "id", None) + if ( + invoice_id is not None + and existing_invoice is not None + and existing_invoice.financials is not None + ): + old_c = _normalize_invoice_currency_value( + existing_invoice.financials.currency + ) + new_c = invoice.financials.currency + if old_c != new_c: + has_items = ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + if has_items: + errors.add_error( + field="items", + message=( + "La opción tipo de moneda no puede ser modificada " + "ya que la factura tiene partidas asociadas." + ), + solution=[ + "Verifica la moneda de las partidas asociadas a la factura." + ], + code="CURRENCY_CANNOT_BE_CHANGED", + value=invoice.financials.currency, + ) + + if invoice.financials.currency == "foreign": + invoice.financials.currency_type = "USD" + elif invoice.financials.currency == "local": + invoice.financials.currency_type = "MXN" + elif invoice.financials.currency == "manual": + if not invoice.financials.currency_type: errors.add_error( field="financials.currency_type", - message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.", - solution=[ - "Verifica el código del Tipo de Moneda", - "Revisa el catálogo", - ], - code="NOT_FOUND", + message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.", + solution=["Proporciona un Tipo de Moneda válido"], + code="REQUIRED_FIELD", value=invoice.financials.currency_type, ) + else: + currency_exists = ( + db.query(CurrencyType) + .filter(CurrencyType.code == invoice.financials.currency_type) + .first() + ) + if not currency_exists: + errors.add_error( + field="financials.currency_type", + message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.", + solution=[ + "Verifica el código del Tipo de Moneda", + "Revisa el catálogo", + ], + code="NOT_FOUND", + value=invoice.financials.currency_type, + ) if invoice.logistics.incoterm: incoterm_exists = ( diff --git a/backend/api/v1/modules/a76/invoices/exports/validators/update.py b/backend/api/v1/modules/a76/invoices/exports/validators/update.py index 464c04dc..7c4b5cd4 100644 --- a/backend/api/v1/modules/a76/invoices/exports/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/exports/validators/update.py @@ -55,7 +55,9 @@ def validate_update( ) # Primero ejecutar validaciones comunes - validate_common(db, invoice, tenant_id, company_id, errors) + validate_common( + db, invoice, tenant_id, company_id, errors, existing_invoice=existing_invoice + ) # Mapeo de columnas CSV a campos de la factura # Siguiendo la lógica del código Clarion original diff --git a/backend/api/v1/modules/a76/invoices/imports/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/validators/update.py index 606df18a..565d3722 100644 --- a/backend/api/v1/modules/a76/invoices/imports/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/validators/update.py @@ -55,7 +55,9 @@ def validate_update( ) # Primero ejecutar validaciones comunes - validate_common(db, invoice, tenant_id, company_id, errors) + validate_common( + db, invoice, tenant_id, company_id, errors, existing_invoice=existing_invoice + ) # Mapeo de columnas CSV a campos de la factura # Siguiendo la lógica del código Clarion original diff --git a/backend/core/config.py b/backend/core/config.py index 495600f8..eaabdb82 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -27,6 +27,8 @@ class Settings(BaseSettings): CORE_DB_USER: str = "postgres" CORE_DB_PASSWORD: str = "postgres" + TEST_DATABASE_URL: str = "postgresql://postgres:postgres@localhost:5432/anexo76_core" + # Keycloak KEYCLOAK_SERVER_URL: str = "http://localhost:8080/kcauth" KEYCLOAK_REALM: str = "master" diff --git a/backend/tests/README.md b/backend/tests/README.md index ef790e4b..ad75670f 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -44,14 +44,14 @@ flowchart TB - **BD**: `TEST_DATABASE_URL` o `CORE_DATABASE_URL` o `settings.core_database_url`. Cada test corre dentro de una transacción; al terminar **no persiste** nada (rollback). - **Celery**: sin Redis; `task_always_eager` para que el endpoint dispare lógica síncrona. -- **Seguridad en tests**: `validate_access_to_resource` se reemplaza por un stub que devuelve `tenant_id` del usuario fake. +- **Seguridad en tests**: `validate_access_to_resource` se reemplaza por un stub que devuelve `tenant_id` del usuario fake (alineado con el fixture `test_tenant`). ### Builders: qué se construye antes de llamar a la API Orden típico en casi todos los tests que tocan proceso: 1. `ensure_reference_data`: tipos de factura (`TEM`, `DEF`, `MEX`, `DONAC`) y régimen `A1` si no existen. -2. `ensure_tenant_company(tenant_id=1, company_id=1)`. +2. Fixture `test_tenant`: asigna IDs efímeros altos y crea tenant + company; el `app` usa ese `tenant_id` en el usuario fake. 3. `create_business_catalogs`: proveedor/cliente/destinatario (con dirección idempotente vía `_ensure_client_provider_address`), agente aduanal, UoM, part number FA, tipo cambio, etc. 4. `create_import_invoice_with_line` / `create_export_invoice_with_line` según el caso. @@ -134,7 +134,7 @@ Si `TEST_DATABASE_URL` apunta a una base con datos previos, los builders reutili - PostgreSQL test database available. - Environment variable: - - `TEST_DATABASE_URL=postgresql://user:pass@host:5432/db_name` + - `TEST_DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/db_name` (sustituye por el host real alcanzable desde el runner; no uses el literal `host` de los ejemplos genéricos) ## Run commands diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1be795d3..3f601f19 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,6 +17,11 @@ from core.celery_app import celery_app from core.config import settings from core.database import get_core_db from core.security import get_current_user +from tests.fixtures.builders import ( + EphemeralOrgIds, + allocate_ephemeral_tenant_company_ids, + ensure_tenant_company, +) TEST_DB_URL = ( @@ -49,6 +54,14 @@ def db_session() -> Generator[Session, None, None]: connection.close() +@pytest.fixture +def test_tenant(db_session: Session) -> EphemeralOrgIds: + """Tenant y company dedicados por test (IDs efímeros en rango alto).""" + tid, cid = allocate_ephemeral_tenant_company_ids(db_session) + ensure_tenant_company(db_session, tenant_id=tid, company_id=cid) + return EphemeralOrgIds(tenant_id=tid, company_id=cid) + + @pytest.fixture(autouse=True) def celery_eager() -> Generator[None, None, None]: prev_broker = celery_app.conf.broker_url @@ -77,7 +90,11 @@ def celery_eager() -> Generator[None, None, None]: @pytest.fixture -def app(db_session: Session, monkeypatch: pytest.MonkeyPatch) -> FastAPI: +def app( + db_session: Session, + test_tenant: EphemeralOrgIds, + monkeypatch: pytest.MonkeyPatch, +) -> FastAPI: test_app = FastAPI() test_app.include_router(process_routes.router, prefix="/api/v1/a76") @@ -85,7 +102,7 @@ def app(db_session: Session, monkeypatch: pytest.MonkeyPatch) -> FastAPI: yield db_session async def _override_current_user(): - return {"sub": "test-user", "tenant_id": 1} + return {"sub": "test-user", "tenant_id": test_tenant.tenant_id} # validate_access_to_resource is imported directly in the routes module. monkeypatch.setattr( diff --git a/backend/tests/e2e/test_inventory_flow_anexo24.py b/backend/tests/e2e/test_inventory_flow_anexo24.py index 19be14b6..0daab7a8 100644 --- a/backend/tests/e2e/test_inventory_flow_anexo24.py +++ b/backend/tests/e2e/test_inventory_flow_anexo24.py @@ -4,13 +4,11 @@ from api.v1.modules.a24.balance_movements.models import BalanceMovement, Movemen from api.v1.modules.a24.discharges.models import DischargeDetail, DischargeHeader from api.v1.modules.a76.invoices.imports.process import main_process as import_main from api.v1.modules.a76.invoices.exports.process import main_process as export_main -from api.v1.modules.a76.invoices.exports.process import task as export_task from tests.fixtures.builders import ( create_business_catalogs, create_export_invoice_with_line, create_import_invoice_with_line, ensure_reference_data, - ensure_tenant_company, ) @@ -34,7 +32,7 @@ def _patch_export_pipeline(monkeypatch, export_line): monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None) -def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch): +def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch, test_tenant): """ E2E principal: - Alta catálogos/base @@ -42,16 +40,16 @@ def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch): - Procesa factura EXP -> genera consumo/descarga - Valida decremento de saldo sin negativos """ + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) import_invoice, import_line = create_import_invoice_with_line( - db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-E2E-01", qty=Decimal("10") + db_session, tid, cid, catalogs, invoice_type="TEM", invoice_number="IMP-E2E-01", qty=Decimal("10") ) _patch_import_pipeline(monkeypatch, import_line) - response = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id=1") + response = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id={cid}") assert response.status_code == 200 entry_movements = ( @@ -67,11 +65,11 @@ def test_e2e_inventory_flow_import_then_export(client, db_session, monkeypatch): assert sum(Decimal(str(m.quantity or 0)) for m in entry_movements) > 0 export_invoice, export_line = create_export_invoice_with_line( - db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("4") + db_session, tid, cid, catalogs, import_invoice, import_line, qty=Decimal("4") ) _patch_export_pipeline(monkeypatch, export_line) - response = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + response = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}") assert response.status_code == 200 consumptions = ( diff --git a/backend/tests/fixtures/builders.py b/backend/tests/fixtures/builders.py index d1f0671d..6486d36b 100644 --- a/backend/tests/fixtures/builders.py +++ b/backend/tests/fixtures/builders.py @@ -1,7 +1,9 @@ from __future__ import annotations +import uuid from datetime import date, datetime from decimal import Decimal +from typing import NamedTuple from sqlalchemy.orm import Session @@ -34,6 +36,31 @@ from api.v1.modules.public.reference_data.invoice_types.models import InvoiceTyp from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +class EphemeralOrgIds(NamedTuple): + """Tenant + company creados por test (IDs altos, baja colisión con datos reales / CI).""" + + tenant_id: int + company_id: int + + +def allocate_ephemeral_tenant_company_ids(db: Session) -> tuple[int, int]: + """ + Genera par (tenant_id, company_id) no usados en esta sesión. + Mismo entero para ambas PKs es válido (tablas distintas). + """ + for _ in range(64): + n = uuid.uuid4().int + tid = 1_500_000_000 + (n % 99_000_000) + if db.get(Tenant, tid) is not None: + continue + if db.get(Company, tid) is not None: + continue + return tid, tid + raise RuntimeError( + "No se pudo asignar tenant/company efímero para tests (reintentar o revisar datos en BD)." + ) + + def ensure_reference_data(db: Session) -> None: for key, desc in [ ("TEM", "IMPORTACION TEMPORAL"), @@ -63,18 +90,21 @@ def ensure_reference_data(db: Session) -> None: db.flush() -def ensure_tenant_company(db: Session, tenant_id: int = 1, company_id: int = 1) -> Company: +def ensure_tenant_company(db: Session, tenant_id: int, company_id: int) -> Company: tenant = db.get(Tenant, tenant_id) if tenant is None: tenant = Tenant( id=tenant_id, name=f"Tenant {tenant_id}", - slug=f"tenant-{tenant_id}", + slug=f"pytest-{tenant_id}-{uuid.uuid4().hex[:10]}", type=TenantType.SHARED, keycloak_realm="test", is_active=True, ) db.add(tenant) + # El FK de a76.company → core.tenants exige que el tenant exista en esta transacción + # antes del INSERT de company; un solo flush al final puede ordenar mal (Tenant híbrido Column+Mapped). + db.flush() company = db.get(Company, company_id) if company is None: diff --git a/backend/tests/integration/test_export_process_api.py b/backend/tests/integration/test_export_process_api.py index 411a894c..ed51d68b 100644 --- a/backend/tests/integration/test_export_process_api.py +++ b/backend/tests/integration/test_export_process_api.py @@ -7,11 +7,11 @@ from api.v1.modules.a76.invoices.exports.process import main_process as export_m from api.v1.modules.a76.invoices.imports.process import main_process as import_main from core.exceptions import ValidationException from tests.fixtures.builders import ( + EphemeralOrgIds, create_business_catalogs, create_export_invoice_with_line, create_import_invoice_with_line, ensure_reference_data, - ensure_tenant_company, ) @@ -44,27 +44,30 @@ def _patch_export_pipeline(monkeypatch, export_line): monkeypatch.setattr(export_main, "review_qty_series", lambda *args, **kwargs: None) -def _prepare_inventory(client, db_session, monkeypatch): +def _prepare_inventory(client, db_session, monkeypatch, test_tenant: EphemeralOrgIds): + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) import_invoice, import_line = create_import_invoice_with_line( - db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-EXP-01", qty=Decimal("10") + db_session, tid, cid, catalogs, invoice_type="TEM", invoice_number="IMP-EXP-01", qty=Decimal("10") ) _patch_import_pipeline(monkeypatch, import_line) - resp = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id=1") + resp = client.post(f"/api/v1/a76/invoices/{import_invoice.id}/process?company_id={cid}") assert resp.status_code == 200 return catalogs, import_invoice, import_line -def test_process_export_endpoint_consumes_existing_balances(client, db_session, monkeypatch): - catalogs, import_invoice, import_line = _prepare_inventory(client, db_session, monkeypatch) +def test_process_export_endpoint_consumes_existing_balances(client, db_session, monkeypatch, test_tenant): + catalogs, import_invoice, import_line = _prepare_inventory( + client, db_session, monkeypatch, test_tenant + ) + tid, cid = test_tenant.tenant_id, test_tenant.company_id export_invoice, export_line = create_export_invoice_with_line( - db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("3") + db_session, tid, cid, catalogs, import_invoice, import_line, qty=Decimal("3") ) _patch_export_pipeline(monkeypatch, export_line) - resp = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + resp = client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}") assert resp.status_code == 200 consumptions = ( @@ -78,15 +81,18 @@ def test_process_export_endpoint_consumes_existing_balances(client, db_session, assert consumptions -def test_process_export_prevents_negative_balance(client, db_session, monkeypatch): - catalogs, import_invoice, import_line = _prepare_inventory(client, db_session, monkeypatch) +def test_process_export_prevents_negative_balance(client, db_session, monkeypatch, test_tenant): + catalogs, import_invoice, import_line = _prepare_inventory( + client, db_session, monkeypatch, test_tenant + ) + tid, cid = test_tenant.tenant_id, test_tenant.company_id export_invoice, export_line = create_export_invoice_with_line( - db_session, 1, 1, catalogs, import_invoice, import_line, qty=Decimal("99") + db_session, tid, cid, catalogs, import_invoice, import_line, qty=Decimal("99") ) _patch_export_pipeline(monkeypatch, export_line) with pytest.raises(ValidationException): - client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id=1") + client.post(f"/api/v1/a76/invoices/{export_invoice.id}/process?company_id={cid}") # Debe no crear consumos al existir insuficiencia de saldo. consumptions = ( @@ -100,19 +106,19 @@ def test_process_export_prevents_negative_balance(client, db_session, monkeypatc assert consumptions == [] -def test_process_endpoint_prevents_double_processing_import(client, db_session, monkeypatch): +def test_process_endpoint_prevents_double_processing_import(client, db_session, monkeypatch, test_tenant): + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) invoice, line = create_import_invoice_with_line( - db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-DOUBLE-01", qty=Decimal("5") + db_session, tid, cid, catalogs, invoice_type="TEM", invoice_number="IMP-DOUBLE-01", qty=Decimal("5") ) _patch_import_pipeline_without_prevalidators(monkeypatch) - first = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + first = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}") assert first.status_code == 200 with pytest.raises(ValidationException): - client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}") # No debe duplicar entradas para la misma factura/línea procesada. entries = ( diff --git a/backend/tests/integration/test_import_process_api.py b/backend/tests/integration/test_import_process_api.py index fd0aadaa..956f759c 100644 --- a/backend/tests/integration/test_import_process_api.py +++ b/backend/tests/integration/test_import_process_api.py @@ -6,7 +6,6 @@ from tests.fixtures.builders import ( create_business_catalogs, create_import_invoice_with_line, ensure_reference_data, - ensure_tenant_company, ) @@ -20,16 +19,16 @@ def _patch_import_pipeline(monkeypatch, import_line): monkeypatch.setattr(import_main, "_validate_lines", lambda *args, **kwargs: ([], {})) -def test_process_import_endpoint_creates_balance_entries(client, db_session, monkeypatch): +def test_process_import_endpoint_creates_balance_entries(client, db_session, monkeypatch, test_tenant): + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) invoice, line = create_import_invoice_with_line( - db_session, 1, 1, catalogs, invoice_type="TEM", invoice_number="IMP-INT-01", qty=Decimal("7") + db_session, tid, cid, catalogs, invoice_type="TEM", invoice_number="IMP-INT-01", qty=Decimal("7") ) _patch_import_pipeline(monkeypatch, line) - resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}") assert resp.status_code == 200 entries = ( @@ -44,16 +43,16 @@ def test_process_import_endpoint_creates_balance_entries(client, db_session, mon assert len(entries) >= 1 -def test_process_import_def_does_not_create_balance_entries(client, db_session, monkeypatch): +def test_process_import_def_does_not_create_balance_entries(client, db_session, monkeypatch, test_tenant): + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) invoice, line = create_import_invoice_with_line( - db_session, 1, 1, catalogs, invoice_type="DEF", invoice_number="IMP-DEF-01", qty=Decimal("7") + db_session, tid, cid, catalogs, invoice_type="DEF", invoice_number="IMP-DEF-01", qty=Decimal("7") ) _patch_import_pipeline(monkeypatch, line) - resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id=1") + resp = client.post(f"/api/v1/a76/invoices/{invoice.id}/process?company_id={cid}") assert resp.status_code == 200 entries = ( diff --git a/backend/tests/unit/invoices/test_balance_algorithm.py b/backend/tests/unit/invoices/test_balance_algorithm.py index a158ed06..70b6c1df 100644 --- a/backend/tests/unit/invoices/test_balance_algorithm.py +++ b/backend/tests/unit/invoices/test_balance_algorithm.py @@ -15,22 +15,21 @@ from tests.fixtures.builders import ( create_business_catalogs, create_import_invoice_with_line, ensure_reference_data, - ensure_tenant_company, ) -def test_net_balance_accounts_for_returns_and_entry_void(db_session): +def test_net_balance_accounts_for_returns_and_entry_void(db_session, test_tenant): """ Balance neto esperado: entry 10 - consumption 4 + return 1 - entry_void 2 = 5 """ + tid, cid = test_tenant.tenant_id, test_tenant.company_id ensure_reference_data(db_session) - ensure_tenant_company(db_session, tenant_id=1, company_id=1) - catalogs = create_business_catalogs(db_session, tenant_id=1, company_id=1) + catalogs = create_business_catalogs(db_session, tenant_id=tid, company_id=cid) import_invoice, import_line = create_import_invoice_with_line( db_session, - 1, - 1, + tid, + cid, catalogs, invoice_type="TEM", invoice_number="IMP-UNIT-01",