139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import unittest
|
||
|
||
from cmbuyer_client.core.errors import ValidationError
|
||
from cmbuyer_client.core.models import ClaimedTask, SecretToken
|
||
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
|
||
|
||
|
||
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
||
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
|
||
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
|
||
TOKEN = "0123456789abcdef" * 4
|
||
|
||
|
||
def claim_wire() -> dict[str, object]:
|
||
return {
|
||
"task": {
|
||
"id": TASK_ID,
|
||
"version": 3,
|
||
"title": "纯棉短袖",
|
||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||
"goods_id": "937122477375",
|
||
"sku_color": "黑色CHA(纯棉)",
|
||
"sku_size": "M(建议100-115)",
|
||
"quantity": 2,
|
||
"max_total_price": "30.00",
|
||
},
|
||
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
|
||
"attempt": {
|
||
"id": ATTEMPT_ID,
|
||
"claim_token": TOKEN,
|
||
"claim_generation": 1,
|
||
"lease_expires_at": "2026-08-04T09:05:00Z",
|
||
},
|
||
}
|
||
|
||
|
||
class CoreModelsTests(unittest.TestCase):
|
||
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
|
||
claimed = ClaimedTask.from_wire(claim_wire())
|
||
self.assertEqual(claimed.task.quantity, 2)
|
||
self.assertNotIn(TOKEN, repr(claimed))
|
||
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
|
||
|
||
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
|
||
mutations = []
|
||
for mutate in (
|
||
lambda value: value["task"].__setitem__("quantity", True),
|
||
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
|
||
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
|
||
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
|
||
lambda value: value["task"].__setitem__("version", 2),
|
||
):
|
||
value = claim_wire()
|
||
mutate(value)
|
||
mutations.append(value)
|
||
for value in mutations:
|
||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||
ClaimedTask.from_wire(value)
|
||
|
||
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
|
||
bad_values = (
|
||
b'{"task":{"id":1,"id":2}}',
|
||
b'{"value":1.0}',
|
||
b'{"value":NaN}',
|
||
b'\xef\xbb\xbf{}',
|
||
b'\xff',
|
||
('{"value":' + "9" * 5000 + '}').encode(),
|
||
)
|
||
for raw in bad_values:
|
||
with self.subTest(raw=raw), self.assertRaises(ValidationError):
|
||
strict_json_loads(raw, maximum=1024)
|
||
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
|
||
|
||
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
|
||
equal = (
|
||
"2026-08-04T09:01:00.1Z",
|
||
"2026-08-04T09:01:00.100000Z",
|
||
"2026-08-04T09:01:00.100000000Z",
|
||
)
|
||
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
|
||
ordered = (
|
||
"2026-08-04T09:01:00Z",
|
||
"2026-08-04T09:01:00.000001Z",
|
||
"2026-08-04T09:01:00.0000011Z",
|
||
"2026-08-04T09:01:00.000001101Z",
|
||
"2026-08-04T09:01:01Z",
|
||
)
|
||
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
|
||
|
||
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
|
||
value = claim_wire()
|
||
value["task"]["max_total_price"] = "0.01"
|
||
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
|
||
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
|
||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||
changed = claim_wire()
|
||
changed["task"]["max_total_price"] = invalid
|
||
ClaimedTask.from_wire(changed)
|
||
|
||
wide = claim_wire()
|
||
wide_goods = "1" * 33
|
||
wide["task"].update(
|
||
goods_id=wide_goods,
|
||
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods,
|
||
max_total_price="1" * 31 + ".00",
|
||
quantity=2_147_483_648,
|
||
)
|
||
self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648)
|
||
for invalid_goods in ("123", "1٢3"):
|
||
changed = claim_wire()
|
||
changed["task"]["goods_id"] = invalid_goods
|
||
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
|
||
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
|
||
ClaimedTask.from_wire(changed)
|
||
too_large = claim_wire()
|
||
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
|
||
with self.assertRaises(ValidationError):
|
||
ClaimedTask.from_wire(too_large)
|
||
|
||
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
|
||
for escaped in (r'"\ud800"', r'"\udc00"'):
|
||
value = claim_wire()
|
||
value["task"]["title"] = json.loads(escaped)
|
||
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
|
||
ClaimedTask.from_wire(value)
|
||
value = claim_wire()
|
||
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
|
||
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
|
||
|
||
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
|
||
for title in ("", " \t\r\n", "\u3000", " \u3000\t"):
|
||
value = claim_wire()
|
||
value["task"]["title"] = title
|
||
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
|
||
ClaimedTask.from_wire(value)
|