|
| 1 | +from datetime import datetime, timezone |
| 2 | + |
| 3 | +import pytest |
| 4 | + |
| 5 | +import shade |
| 6 | +from shade import InvalidRequestError, ShadeObject, WebhookEvent, WebhookEventType |
| 7 | + |
| 8 | +PAYMENT_DATA = { |
| 9 | + "id": "pay_123", |
| 10 | + "amount": "150.25", |
| 11 | + "asset": "USDC", |
| 12 | + "status": "completed", |
| 13 | +} |
| 14 | + |
| 15 | + |
| 16 | +def _api_response(**overrides): |
| 17 | + """A representative camelCase backend payload.""" |
| 18 | + data = { |
| 19 | + "id": "evt_123", |
| 20 | + "type": "payment.completed", |
| 21 | + "data": dict(PAYMENT_DATA), |
| 22 | + "createdAt": "2026-07-20T12:00:00Z", |
| 23 | + "livemode": True, |
| 24 | + } |
| 25 | + data.update(overrides) |
| 26 | + return data |
| 27 | + |
| 28 | + |
| 29 | +def test_from_dict_populates_all_fields(): |
| 30 | + event = WebhookEvent.from_dict(_api_response()) |
| 31 | + |
| 32 | + assert event.id == "evt_123" |
| 33 | + assert event.type == "payment.completed" |
| 34 | + assert event.data == PAYMENT_DATA |
| 35 | + assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) |
| 36 | + assert event.livemode is True |
| 37 | + |
| 38 | + |
| 39 | +def test_data_stays_a_raw_dict(): |
| 40 | + event = WebhookEvent.from_dict(_api_response()) |
| 41 | + |
| 42 | + assert isinstance(event.data, dict) |
| 43 | + assert not isinstance(event.data, ShadeObject) |
| 44 | + assert event.data["status"] == "completed" |
| 45 | + |
| 46 | + |
| 47 | +def test_non_dict_data_raises(): |
| 48 | + with pytest.raises(InvalidRequestError): |
| 49 | + WebhookEvent.from_dict(_api_response(data=["a", "b"])) |
| 50 | + |
| 51 | + |
| 52 | +def test_payload_is_not_mutated(): |
| 53 | + payload = _api_response() |
| 54 | + snapshot = { |
| 55 | + **payload, |
| 56 | + "data": dict(payload["data"]), |
| 57 | + } |
| 58 | + |
| 59 | + WebhookEvent.from_dict(payload) |
| 60 | + |
| 61 | + assert payload == snapshot |
| 62 | + |
| 63 | + |
| 64 | +def test_created_at_parsed_from_iso_string(): |
| 65 | + event = WebhookEvent.from_dict(_api_response(createdAt="2026-01-02T03:04:05Z")) |
| 66 | + assert event.created_at == datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) |
| 67 | + |
| 68 | + |
| 69 | +def test_created_at_accepts_snake_case_key(): |
| 70 | + payload = _api_response() |
| 71 | + del payload["createdAt"] |
| 72 | + payload["created_at"] = "2026-07-20T12:00:00Z" |
| 73 | + |
| 74 | + event = WebhookEvent.from_dict(payload) |
| 75 | + assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) |
| 76 | + |
| 77 | + |
| 78 | +def test_invalid_created_at_raises(): |
| 79 | + with pytest.raises(InvalidRequestError) as exc_info: |
| 80 | + WebhookEvent.from_dict(_api_response(createdAt="not-a-timestamp")) |
| 81 | + |
| 82 | + assert "createdAt" in exc_info.value.field_errors |
| 83 | + |
| 84 | + |
| 85 | +@pytest.mark.parametrize("livemode,expected", [(True, True), (False, False)]) |
| 86 | +def test_livemode_reflects_payload(livemode, expected): |
| 87 | + event = WebhookEvent.from_dict(_api_response(livemode=livemode)) |
| 88 | + assert event.livemode is expected |
| 89 | + |
| 90 | + |
| 91 | +def test_livemode_string_value_raises(): |
| 92 | + with pytest.raises(InvalidRequestError): |
| 93 | + WebhookEvent.from_dict(_api_response(livemode="false")) |
| 94 | + |
| 95 | + |
| 96 | +@pytest.mark.parametrize("field", ["id", "type", "data", "createdAt", "livemode"]) |
| 97 | +def test_missing_required_field_raises(field): |
| 98 | + payload = _api_response() |
| 99 | + del payload[field] |
| 100 | + |
| 101 | + with pytest.raises(InvalidRequestError): |
| 102 | + WebhookEvent.from_dict(payload) |
| 103 | + |
| 104 | + |
| 105 | +def test_non_dict_payload_raises(): |
| 106 | + with pytest.raises(InvalidRequestError): |
| 107 | + WebhookEvent.from_dict("not-a-payload") |
| 108 | + |
| 109 | + |
| 110 | +def test_unknown_fields_are_preserved(): |
| 111 | + event = WebhookEvent.from_dict(_api_response(apiVersion="2026-07-01")) |
| 112 | + assert event.apiVersion == "2026-07-01" |
| 113 | + |
| 114 | + |
| 115 | +def test_to_dict_round_trips_by_alias(): |
| 116 | + event = WebhookEvent.from_dict(_api_response()) |
| 117 | + dumped = event.to_dict() |
| 118 | + |
| 119 | + assert dumped["id"] == "evt_123" |
| 120 | + assert dumped["createdAt"] == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) |
| 121 | + assert dumped["data"] == PAYMENT_DATA |
| 122 | + assert dumped["livemode"] is True |
| 123 | + |
| 124 | + |
| 125 | +def test_repr_shows_event_id(): |
| 126 | + event = WebhookEvent.from_dict(_api_response()) |
| 127 | + assert repr(event) == "<WebhookEvent id='evt_123'>" |
| 128 | + |
| 129 | + |
| 130 | +def test_event_type_constants_compare_to_wire_strings(): |
| 131 | + assert WebhookEventType.PAYMENT_COMPLETED == "payment.completed" |
| 132 | + assert WebhookEventType.INVOICE_PAID == "invoice.paid" |
| 133 | + assert WebhookEventType.SWAP_SLIPPAGE_EXCEEDED == "swap.slippage_exceeded" |
| 134 | + |
| 135 | + |
| 136 | +def test_event_type_usable_in_conditionals(): |
| 137 | + event = WebhookEvent.from_dict(_api_response()) |
| 138 | + assert event.type == WebhookEventType.PAYMENT_COMPLETED |
| 139 | + assert event.type != WebhookEventType.PAYMENT_EXPIRED |
| 140 | + |
| 141 | + |
| 142 | +def test_unknown_event_type_still_parses(): |
| 143 | + event = WebhookEvent.from_dict(_api_response(type="payment.refunded")) |
| 144 | + assert event.type == "payment.refunded" |
| 145 | + |
| 146 | + |
| 147 | +def test_exported_from_package_root(): |
| 148 | + assert shade.WebhookEvent is WebhookEvent |
| 149 | + assert shade.WebhookEventType is WebhookEventType |
0 commit comments