sophhub 0.4.71 → 0.4.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ """hotel_booking 纯函数单测(不触网、不需要 requests、不需要密钥)。
3
+
4
+ 覆盖三类容易出错的口径:
5
+ 1. **比价口径**:逐晚价条数必须等于入住晚数、逐晚价解析不出来就不能按剩下的凑;
6
+ 2. **房型 + 价格计划成对**:实测同一个 ratePlanId 会落在不同房型下且价格不同,
7
+ 只按 ratePlanId 索引会拿到别的房型的价;
8
+ 3. **退订响应形态**:整单退订 `data` 是对象、部分退订 `data` 是**数组**,
9
+ 两种都要能取到退单号;取不到必须返回 None(调用方据此报错,绝不能按整单取消处理)。
10
+
11
+ 用法::
12
+
13
+ python3 skills/hotel-booking/src/scripts/test_hotel_booking.py
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import io
19
+ import os
20
+ import sys
21
+ import tempfile
22
+ import types
23
+ from pathlib import Path
24
+
25
+ # 先隔离运行环境,再导入被测模块:模块导入时要求 SOPH_API_KEY 且会创建数据目录,
26
+ # 单测不应依赖密钥、也不该写真实用户目录。
27
+ _TMP_HOME = tempfile.mkdtemp(prefix="hotel-booking-test-")
28
+ os.environ["HOME"] = _TMP_HOME
29
+ os.environ["USERPROFILE"] = _TMP_HOME
30
+ os.environ.setdefault("SOPH_API_KEY", "unit-test-key-not-used")
31
+
32
+ # 业务脚本导入时即引入 requests;单测不触网,用桩模块顶上,
33
+ # 这样在没有安装 requests 的环境里也能跑这套测试。
34
+ if "requests" not in sys.modules:
35
+ _stub = types.ModuleType("requests")
36
+
37
+ def _no_network(*_args, **_kwargs):
38
+ raise AssertionError("单测不应发起网络请求")
39
+
40
+ _stub.post = _no_network
41
+ _stub.get = _no_network
42
+ sys.modules["requests"] = _stub
43
+
44
+ HERE = Path(__file__).resolve().parent
45
+ if str(HERE) not in sys.path:
46
+ sys.path.insert(0, str(HERE))
47
+
48
+ import hotel_booking as hb # noqa: E402
49
+
50
+
51
+ # ---------- 造数据(字段取自 dev8 实测响应) ----------
52
+
53
+ def _night_rates(prices: list[float], start: str = "2026-10-22") -> list[dict]:
54
+ from datetime import date, timedelta
55
+ y, m, d = (int(x) for x in start.split("-"))
56
+ first = date(y, m, d)
57
+ return [{
58
+ "checkInDate": (first + timedelta(days=i)).isoformat(),
59
+ "price": price,
60
+ "breakfastCount": 0,
61
+ "stock": None,
62
+ "breakFastDes": "无早",
63
+ } for i, price in enumerate(prices)]
64
+
65
+
66
+ def _plan(rate_plan_id: str, price: float, nights: int, shopping_code: str) -> dict:
67
+ return {
68
+ "bedInfo": "1张1.8米大床",
69
+ "bedType": "大床",
70
+ "currencyCode": "CNY",
71
+ "invoiceMode": "SUPPLIER_GENERAL_INVOICE",
72
+ "productType": "ORDINARY",
73
+ "nightRates": _night_rates([price] * nights),
74
+ "cancelRule": {"ruleType": "NeedOneTime", "showDescription": "10-21 18:00前|免费|10-21 18:00后|不可取消"},
75
+ "ratePlanId": rate_plan_id,
76
+ "price": price,
77
+ "stock": 10,
78
+ "shoppingCode": shopping_code,
79
+ "subProductCode": "5",
80
+ }
81
+
82
+
83
+ def _detail() -> dict:
84
+ return {
85
+ "originHotelId": "11357228",
86
+ "originType": "80",
87
+ "name": "上海浦东川沙诺富特酒店",
88
+ "lowestPrice": 458,
89
+ "rooms": [
90
+ {
91
+ "name": "高级大床房",
92
+ "roomId": "259735835",
93
+ "originRoomId": "259735835",
94
+ "area": "28",
95
+ "capcity": "2",
96
+ "ratePlans": [_plan("1655121775", 458, 1, "e16802672e290de2e54d2c5c106cf30d")],
97
+ },
98
+ {
99
+ "name": "行政大床房",
100
+ "roomId": "438117870",
101
+ "originRoomId": "438117870",
102
+ "area": "35",
103
+ "capcity": "2",
104
+ # 同一个 ratePlanId 出现在另一个房型下(实测形如 562680496 落在两个房型)
105
+ "ratePlans": [_plan("1655121775", 837, 1, "b0d2b1c0f0a94f1e9d7c3b2a5e6f7a8b")],
106
+ },
107
+ ],
108
+ }
109
+
110
+
111
+ def _guests() -> list[dict]:
112
+ return [{"name": "张三", "mobile": "13800138000", "credentialType": "IDENTITY",
113
+ "credentialNo": "310101199001011234", "roomNo": 1}]
114
+
115
+
116
+ # ---------- 比价口径 ----------
117
+
118
+ def test_nights_between() -> None:
119
+ assert hb.nights_between("2026-10-22", "2026-10-23") == 1
120
+ assert hb.nights_between("2026-10-22", "2026-10-25") == 3
121
+ assert hb.nights_between("2026-10-22", "2026-10-22") is None, "同日入住离店不合法"
122
+ assert hb.nights_between("2026-10-25", "2026-10-22") is None, "离店早于入住不合法"
123
+ assert hb.nights_between("2026/10/22", "2026-10-23") is None, "日期格式必须 yyyy-MM-dd"
124
+ assert hb.nights_between("", "2026-10-23") is None
125
+
126
+
127
+ def test_plan_total_multiplies_rooms() -> None:
128
+ rates = _night_rates([429, 429, 429])
129
+ assert hb.plan_total(rates, rooms=1, expected_nights=3) == 1287.0
130
+ assert hb.plan_total(rates, rooms=2, expected_nights=3) == 2574.0
131
+
132
+
133
+ def test_plan_total_rejects_incomplete_night_rates() -> None:
134
+ """缺一晚不能按剩下的凑:条数与晚数对不上必须拒绝。"""
135
+ assert hb.plan_total(_night_rates([429, 429]), rooms=1, expected_nights=3) is None
136
+ assert hb.plan_total([], rooms=1, expected_nights=1) is None
137
+ assert hb.plan_total(None, rooms=1, expected_nights=1) is None
138
+ broken = _night_rates([429])
139
+ broken[0]["price"] = None
140
+ assert hb.plan_total(broken, rooms=1, expected_nights=1) is None, "价格缺失不得当成 0"
141
+
142
+
143
+ def test_to_number_does_not_guess() -> None:
144
+ assert hb.to_number("458") == 458.0
145
+ assert hb.to_number(458) == 458.0
146
+ assert hb.to_number("458.50") == 458.5
147
+ assert hb.to_number("") is None
148
+ assert hb.to_number(None) is None
149
+ assert hb.to_number(True) is None, "布尔值不是价格"
150
+ assert hb.to_number("abc") is None
151
+ assert hb.to_number({"price": 1}) is None
152
+
153
+
154
+ # ---------- 房型 + 价格计划成对 ----------
155
+
156
+ def test_flatten_plans_pairs_room_with_plan() -> None:
157
+ rows = hb.flatten_plans(_detail(), expected_nights=1)
158
+ assert len(rows) == 2, "两个房型各有一个价格计划"
159
+
160
+ grand = next(r for r in rows if r["originRoomId"] == "259735835")
161
+ exec_room = next(r for r in rows if r["originRoomId"] == "438117870")
162
+ assert grand["price"] == 458.0 and exec_room["price"] == 837.0, "同名 ratePlanId 必须按房型分别取价"
163
+ assert grand["nightTotal"] == 458.0 and exec_room["nightTotal"] == 837.0
164
+ assert grand["cancelRule"] == "10-21 18:00前|免费|10-21 18:00后|不可取消"
165
+ assert grand["breakfast"] == "无早"
166
+ assert grand["productType"] == "ORDINARY"
167
+ assert grand["stock"] == 10.0
168
+
169
+
170
+ def test_flatten_plans_falls_back_to_room_id_and_marks_unknown_type() -> None:
171
+ detail = _detail()
172
+ detail["rooms"][0].pop("originRoomId") # 只给 roomId 的形态
173
+ detail["rooms"][0]["ratePlans"][0].pop("productType")
174
+ detail["rooms"][0]["ratePlans"][0]["stock"] = None
175
+ rows = hb.flatten_plans(detail, expected_nights=1)
176
+ first = next(r for r in rows if r["roomName"] == "高级大床房")
177
+ assert first["originRoomId"] == "259735835", "缺少 originRoomId 时回退 roomId"
178
+ assert first["productType"] == "UNKNOWN", "未识别取值标 UNKNOWN,不猜协议价"
179
+ assert first["stock"] is None
180
+ assert first["nightTotal"] == 458.0
181
+
182
+
183
+ def test_flatten_plans_marks_incomplete_night_total_as_none() -> None:
184
+ detail = _detail()
185
+ detail["rooms"][0]["ratePlans"][0]["nightRates"] = _night_rates([458, 458])
186
+ rows = hb.flatten_plans(detail, expected_nights=3)
187
+ first = next(r for r in rows if r["roomName"] == "高级大床房")
188
+ assert first["nightTotal"] is None, "晚数对不上时不下发可疑总价"
189
+
190
+
191
+ # ---------- 退订响应形态 ----------
192
+
193
+ def test_first_refund_order_no_accepts_object_and_array() -> None:
194
+ # 整单退订:data 是对象;未确认订单等价取消时 refundOrderNo 是字符串 "null"
195
+ assert hb.first_refund_order_no({"refundOrderNo": "1124465634433153"}) == "1124465634433153"
196
+ assert hb.first_refund_order_no({"refundOrderNo": "null"}) is None, '"null" 视为没有退单号'
197
+ assert hb.first_refund_order_no({"refundOrderNo": None}) is None
198
+ # 部分退订:data 是数组(dev8 实测形态)
199
+ assert hb.first_refund_order_no([
200
+ {"orderNo": "1124465480226944", "roomNos": [1],
201
+ "checkInDate": "2026-10-17", "checkOutDate": "2026-10-18",
202
+ "refundOrderNo": "1124465634433153"},
203
+ ]) == "1124465634433153"
204
+ assert hb.first_refund_order_no([{"refundOrderNo": None}, {"refundOrderNo": "R-2"}]) == "R-2"
205
+ assert hb.first_refund_order_no([]) is None
206
+ assert hb.first_refund_order_no("null") is None
207
+
208
+
209
+ # ---------- 请求报文组装 ----------
210
+
211
+ def test_build_create_payload_uses_inhouse_origin_type() -> None:
212
+ payload = hb.build_create_payload(
213
+ city_id="10801",
214
+ origin_hotel_id="11357228",
215
+ origin_room_id="259735835",
216
+ rate_plan_id="1655121775",
217
+ shopping_code="e16802672e290de2e54d2c5c106cf30d",
218
+ check_in="2026-10-22",
219
+ check_out="2026-10-23",
220
+ rooms=1,
221
+ total_amount=458.0,
222
+ earliest_arrival_time="2026-10-22 14:00:00",
223
+ latest_arrival_time="2026-10-22 23:59:00",
224
+ guests=_guests(),
225
+ contacter_name="张三",
226
+ contacter_mobile="13800138000",
227
+ )
228
+ assert payload["originType"] == 80, "本期统一在途渠道 80"
229
+ assert payload["totalAmount"] == 458.0
230
+ assert payload["checkInDate"] == "2026-10-22"
231
+ assert payload["customers"][0]["roomNo"] == 1
232
+ assert "cityId" in payload
233
+
234
+
235
+ def test_build_create_payload_omits_empty_fields() -> None:
236
+ """无值字段直接不下发,让网关点名字段报 40105,而不是下发空串。"""
237
+ payload = hb.build_create_payload(
238
+ city_id=None,
239
+ origin_hotel_id="11357228",
240
+ origin_room_id="259735835",
241
+ rate_plan_id="1655121775",
242
+ shopping_code="sc",
243
+ check_in="2026-10-22",
244
+ check_out="2026-10-23",
245
+ rooms=1,
246
+ total_amount=458.0,
247
+ earliest_arrival_time="2026-10-22 14:00:00",
248
+ latest_arrival_time="2026-10-22 23:59:00",
249
+ guests=_guests(),
250
+ contacter_name="张三",
251
+ contacter_mobile="13800138000",
252
+ )
253
+ assert "cityId" not in payload
254
+
255
+
256
+ def test_build_partial_refund_shape() -> None:
257
+ payload = hb.build_partial_refund(1, "2026-10-17", "2026-10-18", None)
258
+ assert payload["partialRefunds"][0]["roomNos"] == [1]
259
+ assert payload["partialRefunds"][0]["checkInDate"] == "2026-10-17"
260
+ assert "reason" not in payload, "没填原因就不要下发空原因"
261
+ with_reason = hb.build_partial_refund(2, "2026-10-17", "2026-10-18", "行程变更")
262
+ assert with_reason["reason"] == "行程变更"
263
+
264
+
265
+ def test_show_status_zh_and_expire_time() -> None:
266
+ assert hb.show_status_zh("CANCELED") == "已取消"
267
+ assert hb.show_status_zh("part_refund_change") == "部分退订", "大小写不敏感"
268
+ assert hb.show_status_zh("SOMETHING_NEW") == "SOMETHING_NEW", "未知状态原样展示,不吞掉"
269
+ assert hb.show_status_zh(None) == "-"
270
+ assert hb._expire_time_text(1790738675618).startswith("2026-")
271
+ assert hb._expire_time_text(None) == "None"
272
+
273
+
274
+ # ---------- cityId 透传(下单全程必须带,断了就查不到酒店/下不了单) ----------
275
+
276
+ def _run_hotel_detail(**kwargs):
277
+ """跑一次 cmd_hotel_detail,返回 (返回码, 实际下发的 detail 报文)。"""
278
+ seen: dict = {}
279
+
280
+ def fake_post(path, payload):
281
+ seen["path"] = path
282
+ seen["payload"] = payload
283
+ return {"code": "0", "msg": "成功", "data": _detail()}
284
+
285
+ original = hb._post
286
+ hb._post = fake_post
287
+ try:
288
+ args = types.SimpleNamespace(origin_hotel_id="11357228",
289
+ check_in="2026-10-22", check_out="2026-10-23", **kwargs)
290
+ with contextlib.redirect_stdout(io.StringIO()):
291
+ rc = hb.cmd_hotel_detail(args)
292
+ finally:
293
+ hb._post = original
294
+ return rc, seen
295
+
296
+
297
+ def test_hotel_detail_city_id_falls_back_to_search_cache() -> None:
298
+ """首次详情没有详情缓存时,cityId 必须从关键词检索缓存里取,否则创单会缺 cityId。"""
299
+ hb._write_json(hb.LAST_DETAIL_FILE, {})
300
+ hb._write_json(hb.LAST_SEARCH_FILE, {"keyword": "外滩", "cityId": "10801", "hotels": []})
301
+
302
+ rc, seen = _run_hotel_detail(city_id=None)
303
+
304
+ assert rc == 0
305
+ assert seen["path"] == "/search/detail"
306
+ assert seen["payload"].get("cityId") == "10801", "首次详情必须从检索缓存带上 cityId"
307
+ assert seen["payload"].get("originType") == 80
308
+ assert hb._read_json(hb.LAST_DETAIL_FILE, {}).get("cityId") == "10801", "详情缓存要写 cityId 供创单复用"
309
+
310
+
311
+ def test_hotel_detail_city_id_prefers_explicit_arg() -> None:
312
+ """显式 --city-id 优先,且两个缓存都为空时不能因缺 cityId 而崩。"""
313
+ hb._write_json(hb.LAST_DETAIL_FILE, {})
314
+ hb._write_json(hb.LAST_SEARCH_FILE, {"cityId": "10801", "createdAt": "2026-09-30 10:00:00"})
315
+
316
+ _, seen = _run_hotel_detail(city_id="2")
317
+ assert seen["payload"].get("cityId") == "2", "显式入参优先于缓存"
318
+
319
+ hb._write_json(hb.LAST_DETAIL_FILE, {})
320
+ hb._write_json(hb.LAST_SEARCH_FILE, {})
321
+ _, seen2 = _run_hotel_detail(city_id=None)
322
+ assert "cityId" not in seen2["payload"], "没有任何来源时不下发空 cityId"
323
+
324
+
325
+ def test_hotel_detail_city_id_uses_newer_cache() -> None:
326
+ """换城市后检索缓存比上次详情新,必须用新 cityId(沿用旧值会查不到酒店)。"""
327
+ hb._write_json(hb.LAST_DETAIL_FILE, {"cityId": "10801", "createdAt": "2026-09-30 09:00:00"})
328
+ hb._write_json(hb.LAST_SEARCH_FILE, {"cityId": "1", "createdAt": "2026-09-30 10:00:00"})
329
+ _, seen = _run_hotel_detail(city_id=None)
330
+ assert seen["payload"].get("cityId") == "1", "更新的检索缓存应覆盖旧详情缓存"
331
+
332
+ hb._write_json(hb.LAST_DETAIL_FILE, {"cityId": "10801", "createdAt": "2026-09-30 11:00:00"})
333
+ _, seen2 = _run_hotel_detail(city_id=None)
334
+ assert seen2["payload"].get("cityId") == "10801", "详情比检索新时用详情缓存"
335
+
336
+
337
+ if __name__ == "__main__":
338
+ tests = [(name, fn) for name, fn in sorted(globals().items())
339
+ if name.startswith("test_") and callable(fn)]
340
+ failed = 0
341
+ for name, fn in tests:
342
+ try:
343
+ fn()
344
+ print(f"PASS {name}")
345
+ except AssertionError as exc:
346
+ failed += 1
347
+ print(f"FAIL {name}: {exc}")
348
+ print(f"\n{len(tests) - failed}/{len(tests)} passed")
349
+ sys.exit(1 if failed else 0)