Files
Autoparts-DB/pos/tests/test_facturapi_service.py
consultoria-as d67887284d feat(pos/facturapi): finalize Horux-to-Facturapi migration
- Normalize Facturapi key/org_id resolution (supports both cfdi_ prefixed
  tenant_config keys and short names used by invoicing_bp).
- Add CSD upload end-to-end (backend + frontend).
- Add helper scripts: setup_facturapi_orgs.py and check_facturapi_tenants.py.
- Add 20 unit tests with mocks (pos/tests/test_facturapi_service.py).
- Add CI workflow for lint + console tests on Python 3.11/3.13.
- Add pyproject.toml and requirements-dev.txt with ruff/pytest config.
- Update FASES_IMPLEMENTADAS.md with FASE 8 documentation.

Tests: 81 passing (61 console + 20 Facturapi).
2026-06-15 04:58:42 +00:00

236 lines
8.5 KiB
Python

"""Unit tests for Facturapi service with mocked HTTP calls.
These tests do not require PostgreSQL or network access.
"""
import base64
from unittest import mock
import pytest
# Import must not trigger DB connections.
from pos.services import facturapi_service
@pytest.fixture
def user_key():
"""Patch USER_KEY for the duration of the test."""
with mock.patch.object(facturapi_service, "USER_KEY", "sk_user_abc"):
yield
class TestGetApiKey:
def test_prefers_secret_key(self):
config = {
"facturapi_secret_key": "sk_secret_123",
"facturapi_key": "sk_test_456",
}
assert facturapi_service.get_api_key(config) == "sk_secret_123"
def test_falls_back_to_facturapi_key(self):
config = {"facturapi_key": "sk_test_456"}
assert facturapi_service.get_api_key(config) == "sk_test_456"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_key": "sk_test_789"}
assert facturapi_service.get_api_key(config) == "sk_test_789"
def test_falls_back_to_user_key_env(self, user_key):
assert facturapi_service.get_api_key({}) == "sk_user_abc"
def test_raises_when_nothing_configured(self):
with (
mock.patch.object(facturapi_service, "USER_KEY", ""),
pytest.raises(facturapi_service.FacturapiError),
):
facturapi_service.get_api_key({})
class TestGetOrgId:
def test_prefers_short_key(self):
config = {"facturapi_org_id": "org_123", "cfdi_facturapi_org_id": "org_456"}
assert facturapi_service._get_org_id(config) == "org_123"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_org_id": "org_789"}
assert facturapi_service._get_org_id(config) == "org_789"
def test_returns_none_when_missing(self):
assert facturapi_service._get_org_id({}) is None
class TestRequest:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_successful_request_returns_json(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.content = b'{"id": "inv_1"}'
mock_response.json.return_value = {"id": "inv_1"}
mock_request.return_value = mock_response
result = facturapi_service._request("GET", "/invoices", "sk_test")
assert result == {"id": "inv_1"}
mock_request.assert_called_once()
_, kwargs = mock_request.call_args
assert kwargs["auth"] == ("sk_test", "")
@mock.patch("pos.services.facturapi_service.requests.request")
def test_failed_request_raises_facturapi_error(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad request"
mock_request.return_value = mock_response
with pytest.raises(facturapi_service.FacturapiError) as exc_info:
facturapi_service._request("POST", "/invoices", "sk_test", json_payload={})
assert exc_info.value.status_code == 400
class TestCreateOrganization:
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_creates_organization_and_generates_live_key(self, mock_request, mock_find, user_key):
mock_find.return_value = None
mock_request.side_effect = [
{"id": "org_123"}, # POST /organizations
{"key": "sk_live_abc"}, # PUT /organizations/org_123/apikeys/live
]
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result == {"org_id": "org_123", "api_key": "sk_live_abc"}
assert mock_request.call_count == 2
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_reuses_existing_organization_by_rfc(self, mock_request, mock_find, user_key):
mock_find.return_value = {"id": "org_existing"}
mock_request.return_value = {"key": "sk_live_existing"}
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result["org_id"] == "org_existing"
mock_request.assert_called_once()
class TestUploadCsd:
@mock.patch("pos.services.facturapi_service.requests.post")
def test_uploads_csd(self, mock_post):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.json.return_value = {"certificate": {"has_certificate": True}}
mock_post.return_value = mock_response
config = {"facturapi_key": "sk_test", "facturapi_org_id": "org_1"}
cer_b64 = base64.b64encode(b"fake-cer").decode("ascii")
key_b64 = base64.b64encode(b"fake-key").decode("ascii")
result = facturapi_service.upload_csd(config, cer_b64, key_b64, "password")
assert result["certificate"]["has_certificate"] is True
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs["auth"] == ("sk_test", "")
class TestCreateInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_invoice(self, mock_request):
mock_request.return_value = {"id": "inv_1", "uuid": "uuid-1"}
config = {"facturapi_key": "sk_test"}
payload = {"customer": {"tax_id": "XAXX010101000"}}
result = facturapi_service.create_invoice(config, payload)
assert result["uuid"] == "uuid-1"
mock_request.assert_called_once_with("POST", "/invoices", "sk_test", json_payload=payload, timeout=90)
class TestCancelInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_cancel_invoice_with_replacement(self, mock_request):
mock_request.return_value = {"status": "canceled"}
config = {"facturapi_key": "sk_test"}
result = facturapi_service.cancel_invoice(config, "inv_1", "01", replacement_uuid="uuid-2")
assert result["status"] == "canceled"
mock_request.assert_called_once_with(
"DELETE",
"/invoices/inv_1",
"sk_test",
params={"motive": "01", "replacement": "uuid-2"},
timeout=60,
)
class TestDownloadXml:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_downloads_xml(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.content = b"<xml/>"
mock_request.return_value = mock_response
config = {"facturapi_key": "sk_test"}
result = facturapi_service.download_xml(config, "inv_1")
assert result == b"<xml/>"
class TestGetOrgStatus:
@mock.patch("pos.services.facturapi_service.get_organization")
def test_returns_configured_with_csd(self, mock_get_org):
mock_get_org.return_value = {
"legal": {"name": "Test SA", "tax_id": "ABC010101AAA"},
"certificate": {"has_certificate": True},
"pending_steps": [],
}
config = {"facturapi_key": "sk_test", "cfdi_facturapi_org_id": "org_1"}
result = facturapi_service.get_org_status(config)
assert result["configured"] is True
assert result["has_csd"] is True
assert result["has_org_id"] is True
def test_returns_error_without_key(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({})
assert result["has_key"] is False
assert "not configured" in result["error"].lower()
def test_returns_error_without_org_id(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({"facturapi_key": "sk_test"})
assert result["has_org_id"] is False
assert "organization" in result["error"].lower()
class TestCreateOrUpdateCustomer:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_new_customer_when_not_found(self, mock_request):
mock_request.side_effect = [
{"data": []}, # search
{"id": "cus_1"}, # create
]
config = {"facturapi_key": "sk_test"}
customer_id = facturapi_service.create_or_update_customer(
config,
{
"legal_name": "Test",
"tax_id": "ABC010101AAA",
"tax_system": "601",
"email": "test@example.com",
"zip": "01000",
},
)
assert customer_id == "cus_1"