diff --git a/.gitignore b/.gitignore index 3910ba5..f140124 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # specific on the project *credential.py +/roulier/tests/credentials.py *label*.pdf # files downloaded when executed some tests # Byte-compiled / optimized / DLL files diff --git a/roulier/tests/__init__.py b/roulier/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roulier/tests/conftest.py b/roulier/tests/conftest.py new file mode 100644 index 0000000..dfe72c0 --- /dev/null +++ b/roulier/tests/conftest.py @@ -0,0 +1,58 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import date +import pytest + +try: + from .credentials import CREDENTIALS +except ImportError: + from .credentials_demo import CREDENTIALS + + +@pytest.fixture(scope="session") +def credentials(): + return { + **CREDENTIALS, + "isTest": True, + } + + +@pytest.fixture +def base_get_label_data(): + return { + "service": { + "shippingDate": date(2024, 1, 1), + "labelFormat": "PDF", + }, + "parcels": [{"weight": 1.2, "comment": "Fake comment"}], + "from_address": { + "name": "Akrétion", + "street1": "27 rue Henri Rolland", + "street2": "Batiment B", + "city": "Villeurbanne", + "zip": "69100", + "country": "FR", + "phone": "+33482538457", + }, + "to_address": { + "name": "Hügǒ", + "firstName": "Victor", + "street1": "6 Place des Vôsges", + "city": "Paris", + "zip": "75004", + "country": "FR", + "email": "hugo.victor@example.com", + "phone": "+33600000000", + }, + } + + +@pytest.fixture +def base_find_pickup_site_data(): + return { + "search": { + "country": "FR", + "zip": "69100", + }, + } diff --git a/roulier/tests/credentials_demo.py b/roulier/tests/credentials_demo.py new file mode 100644 index 0000000..4f5dabd --- /dev/null +++ b/roulier/tests/credentials_demo.py @@ -0,0 +1,12 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +"""Copy this file to credentials.py and fill in your test credentials""" + +CREDENTIALS = { + "mondialrelay_fr": { # These are public test credentials + "login": "BDTEST13", + "password": "PrivateK", + }, +} diff --git a/roulier/tests/test_api.py b/roulier/tests/test_api.py new file mode 100644 index 0000000..df2b89f --- /dev/null +++ b/roulier/tests/test_api.py @@ -0,0 +1,272 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from roulier import roulier +from roulier.carrier import Carrier, action +from roulier.exception import CarrierError, InvalidApiInput +from pydantic import BaseModel +import pytest + + +@pytest.fixture(autouse=True) +def reset_roulier(): + roulier.factory._carrier_action = {} + + +def test_carrier_api(): + assert "dummy" not in roulier.get_carriers_action_available() + + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(outname=data.name, outid=data.id) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + assert input.name == "test" + assert input.id == 1 + + return DummyOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get"] + + rv = roulier.get("dummy", "get", {"name": "test", "id": 1}) + assert rv == {"outname": "test", "outid": 1} + + +def test_carrier_api_unexposed(): + class DummySubIn(BaseModel): + key: str + + class DummyIn(BaseModel): + name: str | None = None + subs: list[DummySubIn] + + class DummyOut(BaseModel): + len: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(len=len(data.subs)) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + def unexposed(self, input: DummyIn) -> DummyOut: + """This should not be exposed.""" + + @action + def acquire(self, input: DummyIn) -> DummyOut: + assert input.name == None + assert len(input.subs) == 2 + return DummyOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["acquire"] + + rv = roulier.get("dummy", "acquire", {"subs": [{"key": "test"}, {"key": "test2"}]}) + assert rv == {"len": 2} + + +def test_carrier_api_bad_input_signature(): + + class DummyOut(BaseModel): + pass + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input) -> DummyOut: + pass + + with pytest.raises(ValueError) as excinfo: + roulier.get("dummy", "get", {}) + assert "Missing input argument or type hint" in str(excinfo.value) + + +def test_carrier_api_bad_output_signature(): + class DummyIn(BaseModel): + pass + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn): + pass + + with pytest.raises(ValueError) as excinfo: + roulier.get("dummy", "get", {}) + assert "Missing return type hint" in str(excinfo.value) + + +def test_carrier_api_invalid_input(): + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(outname=data.name, outid=data.id) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + return DummyOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get"] + + with pytest.raises(InvalidApiInput) as excinfo: + roulier.get("dummy", "get", {"description": "test", "id": 1}) + + assert "Invalid input data" in str(excinfo.value) + assert "name\n Field required" in str(excinfo.value) + + +def test_carrier_api_invalid_output(): + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(out=data.name) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + return DummyOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get"] + + with pytest.raises(CarrierError) as excinfo: + roulier.get("dummy", "get", {"name": "test", "id": 1}) + + assert "Action failed" in str(excinfo.value) + assert "outname\n Field required" in str(excinfo.value) + + +def test_carrier_api_carrier_error(): + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(outname=data.name, outid=data.id) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + raise CarrierError({"url": "http://fail"}, "This failed miserably") + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get"] + + with pytest.raises(CarrierError) as excinfo: + roulier.get("dummy", "get", {"name": "test", "id": 1}) + + assert "This failed miserably" in str(excinfo.value) + assert excinfo.value.response == {"url": "http://fail"} + + +def test_carrier_api_extra_input(): + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(outname=data.name, outid=data.id) + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + return DummyOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get"] + + rv = roulier.get("dummy", "get", {"name": "test", "description": "test", "id": 1}) + assert rv == {"outname": "test", "outid": 1} + + +def test_carrier_api_multi_actions(): + class DummyIn(BaseModel): + name: str + id: int + + class DummyOut(BaseModel): + outname: str + outid: int + + @classmethod + def from_in(cls, data: DummyIn): + return cls(outname=data.name, outid=data.id) + + class DummyAcquireOut(BaseModel): + out: str + + @classmethod + def from_in(cls, data: DummyIn): + return cls(out=f"[{data.id}] {data.name}") + + class DummyCarrier(Carrier): + __key__ = "dummy" + + @action + def get(self, input: DummyIn) -> DummyOut: + return DummyOut.from_in(input) + + @action + def acquire(self, input: DummyIn) -> DummyAcquireOut: + return DummyAcquireOut.from_in(input) + + assert "dummy" in roulier.get_carriers_action_available() + assert roulier.get_carriers_action_available()["dummy"] == ["get", "acquire"] + + rv = roulier.get("dummy", "get", {"name": "test", "description": "test", "id": 1}) + assert rv == {"outname": "test", "outid": 1} + + rv = roulier.get( + "dummy", "acquire", {"name": "test", "description": "test", "id": 1} + ) + assert rv == {"out": "[1] test"} diff --git a/roulier/tests/test_helpers.py b/roulier/tests/test_helpers.py new file mode 100644 index 0000000..b2eff52 --- /dev/null +++ b/roulier/tests/test_helpers.py @@ -0,0 +1,129 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from ..helpers import ( + filter_empty, + merge, + none_as_empty, + prefix, + suffix, + unaccent, + walk_data, +) + + +def test_prefix(): + assert prefix({"a": 1, "b": 2}, "p_") == {"p_a": 1, "p_b": 2} + + +def test_suffix(): + assert suffix({"a": 1, "b": 2}, "_s") == {"a_s": 1, "b_s": 2} + + +def test_walk_data(): + assert walk_data( + { + "a": 1, + "b": { + "c": 3, + "d": {"a": 5, "f": 6}, + }, + } + ) == { + "a": 1, + "b": { + "c": 3, + "d": {"a": 5, "f": 6}, + }, + } + assert walk_data( + { + "a": 1, + "b": { + "c": 3, + "d": {"a": 5, "f": 6}, + }, + }, + filter=lambda x: x not in [1, 5], + ) == {"b": {"c": 3, "d": {"f": 6}}} + assert walk_data( + { + "a": 1, + "b": { + "c": 3, + "d": {"a": 5, "f": 6}, + }, + }, + transform=lambda x: x + 1, + ) == { + "a": 2, + "b": { + "c": 4, + "d": {"a": 6, "f": 7}, + }, + } + + +def test_filter_empty(): + assert filter_empty( + { + "a": 1, + "b": None, + "c": "", + } + ) == {"a": 1} + assert filter_empty( + { + "a": 1, + "b": {"c": None, "d": {"a": 5, "f": ""}}, + } + ) == { + "a": 1, + "b": {"d": {"a": 5}}, + } + + +def test_none_as_empty(): + assert none_as_empty({"a": 1, "b": None}) == {"a": 1, "b": ""} + + +def test_unaccent(): + assert unaccent("éà") == "ea" + assert unaccent({"a": "éà", "b": {"c": 12, "d": ["çô", "ë"]}}) == { + "a": "ea", + "b": {"c": 12, "d": ["co", "e"]}, + } + + +def test_merge(): + assert merge({"a": 1}, {"b": 2}) == {"a": 1, "b": 2} + assert merge({"a": 1}, {"a": None}) == {"a": 1} + assert merge({"a": 1}, {"a": ""}) == {"a": 1} + assert merge({"a": None}, {"a": 1}) == {"a": 1} + assert merge({"a": ""}, {"a": 1}) == {"a": 1} + assert merge({"a": 1}, {"a": 2}, {"a": 3}) == {"a": 3} + assert merge( + { + "a": {"b": 1}, + }, + { + "a": {"c": 2}, + }, + ) == { + "a": {"b": 1, "c": 2}, + } + assert merge({"a": {"b": 1}}, {"a": {"b": 2}}) == {"a": {"b": 2}} + assert merge({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 3}}) == {"a": {"b": 3}} + assert merge({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": None}}) == { + "a": {"b": 2} + } + assert merge({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": ""}}) == {"a": {"b": 2}} + assert merge({"a": {"b": 1}}, {"a": {"b": None}}, {"a": {"b": 3}}) == { + "a": {"b": 3} + } + assert merge({"a": {"b": 1}}, {"a": {"b": ""}}, {"a": {"b": 3}}) == {"a": {"b": 3}} + assert merge( + {"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 3}}, {"a": {"b": None}} + ) == {"a": {"b": 3}} + assert merge({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 3}}, {"a": {"b": ""}}) diff --git a/setup.py b/setup.py index 9083957..1160ad3 100755 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ "zeep", ], extras_requires={ - "dev": ["ptpython", "pytest"], + "dev": ["pytest", "pytest-recording"], }, author="Hparfr ", author_email="roulier@hpar.fr",