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)
|