- 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).
236 lines
8.5 KiB
Python
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"
|