"""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""
mock_request.return_value = mock_response
config = {"facturapi_key": "sk_test"}
result = facturapi_service.download_xml(config, "inv_1")
assert result == b""
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"