|
| 1 | +import pytest |
| 2 | +from stellar_sdk import Keypair |
| 3 | + |
| 4 | +import shade |
| 5 | +from shade import InvalidRequestError, Merchant, ShadeObject |
| 6 | + |
| 7 | +VALID_ADDRESS = Keypair.random().public_key |
| 8 | + |
| 9 | + |
| 10 | +def _api_response(**overrides): |
| 11 | + """A representative camelCase backend payload.""" |
| 12 | + data = { |
| 13 | + "id": "clx123", |
| 14 | + "merchantId": 42, |
| 15 | + "address": VALID_ADDRESS, |
| 16 | + "account": "GACCOUNT", |
| 17 | + "email": "owner@acme.test", |
| 18 | + "firstName": "Ada", |
| 19 | + "lastName": "Lovelace", |
| 20 | + "businessName": "Acme Payments", |
| 21 | + "category": "software", |
| 22 | + "description": "We take money.", |
| 23 | + "logo": "https://cdn.test/logo.png", |
| 24 | + "webhook": "https://acme.test/hooks", |
| 25 | + "active": True, |
| 26 | + "verified": True, |
| 27 | + } |
| 28 | + data.update(overrides) |
| 29 | + return data |
| 30 | + |
| 31 | + |
| 32 | +def test_from_dict_maps_camelcase_to_snake_case(): |
| 33 | + merchant = Merchant.from_dict(_api_response()) |
| 34 | + |
| 35 | + assert merchant.id == "clx123" |
| 36 | + assert merchant.merchant_id == 42 |
| 37 | + assert merchant.address == VALID_ADDRESS |
| 38 | + assert merchant.first_name == "Ada" |
| 39 | + assert merchant.last_name == "Lovelace" |
| 40 | + assert merchant.business_name == "Acme Payments" |
| 41 | + assert merchant.active is True |
| 42 | + assert merchant.verified is True |
| 43 | + |
| 44 | + |
| 45 | +def test_merchant_id_is_int(): |
| 46 | + merchant = Merchant.from_dict(_api_response(merchantId=7)) |
| 47 | + assert isinstance(merchant.merchant_id, int) |
| 48 | + assert merchant.merchant_id == 7 |
| 49 | + |
| 50 | + |
| 51 | +def test_merchant_is_exported_from_package(): |
| 52 | + assert shade.Merchant is Merchant |
| 53 | + assert issubclass(Merchant, ShadeObject) |
| 54 | + |
| 55 | + |
| 56 | +def test_from_dict_preserves_unknown_keys(): |
| 57 | + # The ShadeObject base allows extra fields so a server-side addition never |
| 58 | + # breaks an older SDK; the known fields still map correctly. |
| 59 | + merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01")) |
| 60 | + assert merchant.merchant_id == 42 |
| 61 | + assert merchant.to_dict()["createdAt"] == "2026-01-01" |
| 62 | + |
| 63 | + |
| 64 | +def test_from_dict_requires_a_mapping(): |
| 65 | + with pytest.raises(InvalidRequestError): |
| 66 | + Merchant.from_dict([("id", "x")]) # type: ignore[arg-type] |
| 67 | + |
| 68 | + |
| 69 | +def test_invalid_address_raises_on_construction(): |
| 70 | + with pytest.raises(InvalidRequestError) as exc_info: |
| 71 | + Merchant.from_dict(_api_response(address="not-a-stellar-key")) |
| 72 | + assert exc_info.value.param == "address" |
| 73 | + |
| 74 | + |
| 75 | +def test_address_wrong_length_is_rejected(): |
| 76 | + with pytest.raises(InvalidRequestError): |
| 77 | + Merchant( |
| 78 | + id="x", |
| 79 | + merchant_id=1, |
| 80 | + address="G" + "A" * 55, # starts with G but too short / bad checksum |
| 81 | + active=True, |
| 82 | + verified=False, |
| 83 | + ) |
| 84 | + |
| 85 | + |
| 86 | +def test_non_integer_merchant_id_raises(): |
| 87 | + with pytest.raises(InvalidRequestError) as exc_info: |
| 88 | + Merchant.from_dict(_api_response(merchantId="abc")) |
| 89 | + assert exc_info.value.param == "merchantId" |
| 90 | + |
| 91 | + |
| 92 | +def test_boolean_merchant_id_is_rejected(): |
| 93 | + # A bool would otherwise be coerced to 1/0; it is never a valid merchant id. |
| 94 | + with pytest.raises(InvalidRequestError) as exc_info: |
| 95 | + Merchant.from_dict(_api_response(merchantId=True)) |
| 96 | + assert exc_info.value.param == "merchantId" |
| 97 | + |
| 98 | + |
| 99 | +@pytest.mark.parametrize("field", ["active", "verified"]) |
| 100 | +@pytest.mark.parametrize("value", ["false", "true", "", 0, 1, None]) |
| 101 | +def test_non_boolean_flags_are_rejected(field, value): |
| 102 | + """Strings like "false" must not be silently coerced to True.""" |
| 103 | + with pytest.raises(InvalidRequestError) as exc_info: |
| 104 | + Merchant.from_dict(_api_response(**{field: value})) |
| 105 | + assert exc_info.value.param == field |
| 106 | + |
| 107 | + |
| 108 | +def test_boolean_flags_are_preserved(): |
| 109 | + merchant = Merchant.from_dict(_api_response(active=False, verified=True)) |
| 110 | + assert merchant.active is False |
| 111 | + assert merchant.verified is True |
| 112 | + |
| 113 | + |
| 114 | +def test_display_name_prefers_business_name(): |
| 115 | + merchant = Merchant.from_dict(_api_response()) |
| 116 | + assert merchant.display_name == "Acme Payments" |
| 117 | + |
| 118 | + |
| 119 | +def test_display_name_falls_back_to_full_name(): |
| 120 | + merchant = Merchant.from_dict(_api_response(businessName=None)) |
| 121 | + assert merchant.display_name == "Ada Lovelace" |
| 122 | + |
| 123 | + |
| 124 | +def test_display_name_trims_missing_last_name(): |
| 125 | + merchant = Merchant.from_dict(_api_response(businessName=None, lastName=None)) |
| 126 | + assert merchant.display_name == "Ada" |
| 127 | + |
| 128 | + |
| 129 | +def test_display_name_falls_back_to_email(): |
| 130 | + merchant = Merchant.from_dict( |
| 131 | + _api_response(businessName=None, firstName=None, lastName=None) |
| 132 | + ) |
| 133 | + assert merchant.display_name == "owner@acme.test" |
| 134 | + |
| 135 | + |
| 136 | +def test_display_name_ignores_whitespace_only_business_name(): |
| 137 | + """A blank business_name must fall through, not be returned verbatim.""" |
| 138 | + merchant = Merchant.from_dict(_api_response(businessName=" ")) |
| 139 | + assert merchant.display_name == "Ada Lovelace" |
| 140 | + |
| 141 | + |
| 142 | +def test_display_name_ignores_whitespace_only_names(): |
| 143 | + merchant = Merchant.from_dict( |
| 144 | + _api_response(businessName="", firstName=" ", lastName="\t") |
| 145 | + ) |
| 146 | + assert merchant.display_name == "owner@acme.test" |
| 147 | + |
| 148 | + |
| 149 | +def test_display_name_is_none_when_every_candidate_is_blank(): |
| 150 | + merchant = Merchant.from_dict( |
| 151 | + _api_response(businessName=" ", firstName="", lastName=None, email=" ") |
| 152 | + ) |
| 153 | + assert merchant.display_name is None |
| 154 | + |
| 155 | + |
| 156 | +def test_display_name_trims_the_returned_value(): |
| 157 | + merchant = Merchant.from_dict(_api_response(businessName=" Acme Payments ")) |
| 158 | + assert merchant.display_name == "Acme Payments" |
| 159 | + |
| 160 | + |
| 161 | +def test_display_name_normalizes_padded_name_components(): |
| 162 | + # Each component is stripped before joining, so padding does not leak into |
| 163 | + # the middle of the full name as a double space. |
| 164 | + merchant = Merchant.from_dict( |
| 165 | + _api_response(businessName=None, firstName=" Ada ", lastName=" Lovelace ") |
| 166 | + ) |
| 167 | + assert merchant.display_name == "Ada Lovelace" |
| 168 | + |
| 169 | + |
| 170 | +def test_optional_fields_default_to_none(): |
| 171 | + merchant = Merchant( |
| 172 | + id="x", |
| 173 | + merchant_id=1, |
| 174 | + address=VALID_ADDRESS, |
| 175 | + active=False, |
| 176 | + verified=False, |
| 177 | + ) |
| 178 | + assert merchant.account is None |
| 179 | + assert merchant.email is None |
| 180 | + assert merchant.display_name is None |
| 181 | + |
| 182 | + |
| 183 | +def test_to_dict_round_trips_to_camelcase(): |
| 184 | + payload = _api_response() |
| 185 | + merchant = Merchant.from_dict(payload) |
| 186 | + assert merchant.to_dict() == payload |
| 187 | + assert Merchant.from_dict(merchant.to_dict()) == merchant |
0 commit comments