wechatbridge-cli 1.7.0__tar.gz → 1.7.2__tar.gz
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.
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/PKG-INFO +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_hardening.py +256 -0
- wechatbridge_cli-1.7.2/tests/test_price.py +186 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/agy.py +115 -27
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/codex.py +28 -4
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/dsh.py +13 -70
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/grok.py +28 -3
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/ilink.py +40 -6
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/main.py +58 -18
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/price.py +137 -31
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/runner_common.py +258 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/LICENSE +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/README.md +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/pyproject.toml +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/setup.cfg +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_dsh.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.7.0 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -2983,5 +2983,261 @@ class TestAgyStreamRetry(unittest.IsolatedAsyncioTestCase):
|
|
|
2983
2983
|
mock_sleep.assert_not_awaited()
|
|
2984
2984
|
|
|
2985
2985
|
|
|
2986
|
+
class TestAuditHardeningFixes(unittest.IsolatedAsyncioTestCase):
|
|
2987
|
+
"""Regression tests for deep-audit security and correctness fixes."""
|
|
2988
|
+
|
|
2989
|
+
def setUp(self):
|
|
2990
|
+
from wechatbridge.config import config as cfg
|
|
2991
|
+
self._cfg = cfg
|
|
2992
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
2993
|
+
self.session_base = self._tmp.name
|
|
2994
|
+
self._orig_base = cfg.session_base_dir
|
|
2995
|
+
self._orig_backend = cfg.backend
|
|
2996
|
+
cfg.session_base_dir = self.session_base
|
|
2997
|
+
|
|
2998
|
+
def tearDown(self):
|
|
2999
|
+
self._cfg.session_base_dir = self._orig_base
|
|
3000
|
+
self._cfg.backend = self._orig_backend
|
|
3001
|
+
self._tmp.cleanup()
|
|
3002
|
+
|
|
3003
|
+
async def test_agy_send_artifacts_allows_session_dir_and_blocks_sensitive_or_hardlink(self):
|
|
3004
|
+
from wechatbridge import main as main_mod
|
|
3005
|
+
from wechatbridge.runner_common import ensure_session_dir
|
|
3006
|
+
|
|
3007
|
+
self._cfg.backend = "agy"
|
|
3008
|
+
uid = "u-agy-sec"
|
|
3009
|
+
sdir = ensure_session_dir(uid)
|
|
3010
|
+
|
|
3011
|
+
# Normal artifact directly in session_dir (should be allowed)
|
|
3012
|
+
good_file = os.path.join(sdir, "report.pdf")
|
|
3013
|
+
with open(good_file, "wb") as f:
|
|
3014
|
+
f.write(b"%PDF-1.4 ok")
|
|
3015
|
+
|
|
3016
|
+
# Normal artifact in .gemini/antigravity-cli/scratch (should be allowed)
|
|
3017
|
+
scratch_dir = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch")
|
|
3018
|
+
os.makedirs(scratch_dir, exist_ok=True)
|
|
3019
|
+
scratch_file = os.path.join(scratch_dir, "chart.png")
|
|
3020
|
+
with open(scratch_file, "wb") as f:
|
|
3021
|
+
f.write(b"PNGDATA")
|
|
3022
|
+
|
|
3023
|
+
# Sensitive files inside session_dir (must be blocked)
|
|
3024
|
+
prefs_file = os.path.join(sdir, "prefs.json")
|
|
3025
|
+
with open(prefs_file, "w") as f:
|
|
3026
|
+
f.write('{"model": "gemini-4-argon"}')
|
|
3027
|
+
gemini_dir = os.path.join(sdir, ".gemini")
|
|
3028
|
+
os.makedirs(gemini_dir, exist_ok=True)
|
|
3029
|
+
token_file = os.path.join(gemini_dir, "antigravity-oauth-token")
|
|
3030
|
+
with open(token_file, "w") as f:
|
|
3031
|
+
f.write("secret-oauth-token")
|
|
3032
|
+
|
|
3033
|
+
# Hardlink inside session_dir pointing to external file (must be blocked)
|
|
3034
|
+
ext_file = os.path.join(self.session_base, "external_secret.txt")
|
|
3035
|
+
with open(ext_file, "w") as f:
|
|
3036
|
+
f.write("top-secret")
|
|
3037
|
+
hardlink_file = os.path.join(sdir, "linked.txt")
|
|
3038
|
+
os.link(ext_file, hardlink_file)
|
|
3039
|
+
|
|
3040
|
+
client = mock.MagicMock()
|
|
3041
|
+
client.state.baseurl = "https://ilink"
|
|
3042
|
+
client.state.bot_token = "tok"
|
|
3043
|
+
client.send_media = AsyncMock(return_value=True)
|
|
3044
|
+
client.send_message = AsyncMock(return_value=True)
|
|
3045
|
+
|
|
3046
|
+
await main_mod.send_artifacts_back(
|
|
3047
|
+
artifacts=[
|
|
3048
|
+
("report.pdf", good_file),
|
|
3049
|
+
("chart.png", scratch_file),
|
|
3050
|
+
("prefs.json", prefs_file),
|
|
3051
|
+
("antigravity-oauth-token", token_file),
|
|
3052
|
+
("linked.txt", hardlink_file),
|
|
3053
|
+
],
|
|
3054
|
+
client=client,
|
|
3055
|
+
from_user=uid,
|
|
3056
|
+
context_token="ctx",
|
|
3057
|
+
)
|
|
3058
|
+
|
|
3059
|
+
sent_paths = [c.kwargs["path"] for c in client.send_media.await_args_list]
|
|
3060
|
+
self.assertEqual(
|
|
3061
|
+
sent_paths,
|
|
3062
|
+
[os.path.realpath(good_file), os.path.realpath(scratch_file)],
|
|
3063
|
+
)
|
|
3064
|
+
|
|
3065
|
+
def test_agy_extract_artifacts_balanced_parens_and_model_tsv_parsing(self):
|
|
3066
|
+
from wechatbridge import agy as agy_mod
|
|
3067
|
+
|
|
3068
|
+
text = (
|
|
3069
|
+
"已生成 [report (1).pdf](file:///tmp/sess/report%20(1).pdf) "
|
|
3070
|
+
"和 [v2 [final].docx](file:///tmp/sess/v2%20[final].docx)。"
|
|
3071
|
+
)
|
|
3072
|
+
arts = agy_mod.extract_artifacts(text)
|
|
3073
|
+
self.assertEqual(
|
|
3074
|
+
arts,
|
|
3075
|
+
[
|
|
3076
|
+
("report (1).pdf", "/tmp/sess/report (1).pdf"),
|
|
3077
|
+
("v2 [final].docx", "/tmp/sess/v2 [final].docx"),
|
|
3078
|
+
],
|
|
3079
|
+
)
|
|
3080
|
+
|
|
3081
|
+
tsv_models = (
|
|
3082
|
+
"gemini-4-argon\tGemini 3.1 Pro (High)\n"
|
|
3083
|
+
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
|
3084
|
+
)
|
|
3085
|
+
self.assertEqual(
|
|
3086
|
+
agy_mod._parse_agy_model_ids(tsv_models),
|
|
3087
|
+
["gemini-4-argon", "claude-opus-4-6-thinking"],
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
async def test_planning_and_fast_toggle_and_clear_resets_mode(self):
|
|
3091
|
+
from wechatbridge import agy as agy_mod
|
|
3092
|
+
from wechatbridge.runner_common import load_prefs
|
|
3093
|
+
|
|
3094
|
+
uid = "u-toggle"
|
|
3095
|
+
r1 = await agy_mod.handle_slash_command("/planning", uid)
|
|
3096
|
+
self.assertIn("已开启规划模式", r1)
|
|
3097
|
+
self.assertEqual(load_prefs(uid).get("mode"), "plan")
|
|
3098
|
+
|
|
3099
|
+
# Calling /planning again toggles it off
|
|
3100
|
+
r2 = await agy_mod.handle_slash_command("/planning", uid)
|
|
3101
|
+
self.assertIn("已关闭规划模式", r2)
|
|
3102
|
+
self.assertEqual(load_prefs(uid).get("mode"), "")
|
|
3103
|
+
|
|
3104
|
+
# Enable again and verify /clear resets sticky mode
|
|
3105
|
+
await agy_mod.handle_slash_command("/planning", uid)
|
|
3106
|
+
self.assertEqual(load_prefs(uid).get("mode"), "plan")
|
|
3107
|
+
await agy_mod.handle_slash_command("/clear", uid)
|
|
3108
|
+
self.assertEqual(load_prefs(uid).get("mode"), "")
|
|
3109
|
+
|
|
3110
|
+
# /fast off
|
|
3111
|
+
await agy_mod.handle_slash_command("/fast", uid)
|
|
3112
|
+
self.assertEqual(load_prefs(uid).get("effort"), "low")
|
|
3113
|
+
r_fast_off = await agy_mod.handle_slash_command("/fast off", uid)
|
|
3114
|
+
self.assertIn("已关闭快速模式", r_fast_off)
|
|
3115
|
+
self.assertEqual(load_prefs(uid).get("effort"), "")
|
|
3116
|
+
|
|
3117
|
+
def test_sanitize_prompt_at_paths_allows_session_and_add_dirs_blocks_external(self):
|
|
3118
|
+
from wechatbridge.runner_common import _sanitize_prompt_at_paths, ensure_session_dir
|
|
3119
|
+
|
|
3120
|
+
sdir = ensure_session_dir("u-at")
|
|
3121
|
+
extra_dir = os.path.join(self.session_base, "allowed_extra")
|
|
3122
|
+
os.makedirs(extra_dir, exist_ok=True)
|
|
3123
|
+
prompt = (
|
|
3124
|
+
f"请看 @{sdir}/img.png 和 @{extra_dir}/doc.md "
|
|
3125
|
+
"不要读 @/etc/passwd 或 @../../etc/shadow"
|
|
3126
|
+
)
|
|
3127
|
+
sanitized = _sanitize_prompt_at_paths(prompt, sdir, extra_roots=[extra_dir])
|
|
3128
|
+
self.assertIn(f"@{sdir}/img.png", sanitized)
|
|
3129
|
+
self.assertIn(f"@{extra_dir}/doc.md", sanitized)
|
|
3130
|
+
self.assertNotIn("/etc/passwd", sanitized)
|
|
3131
|
+
self.assertNotIn("/etc/shadow", sanitized)
|
|
3132
|
+
self.assertEqual(sanitized.count("[blocked-path]"), 2)
|
|
3133
|
+
|
|
3134
|
+
async def test_ilink_get_updates_raises_on_5xx_and_encrypt_roundtrip(self):
|
|
3135
|
+
import httpx
|
|
3136
|
+
from wechatbridge.ilink import ILinkClient, _encrypt_aes_ecb, _decrypt_aes_ecb
|
|
3137
|
+
|
|
3138
|
+
key = os.urandom(16)
|
|
3139
|
+
for length in (0, 1, 15, 16, 17, 32, 100):
|
|
3140
|
+
raw = os.urandom(length)
|
|
3141
|
+
enc = _encrypt_aes_ecb(raw, key)
|
|
3142
|
+
self.assertEqual(_decrypt_aes_ecb(enc, key), raw)
|
|
3143
|
+
|
|
3144
|
+
client = ILinkClient()
|
|
3145
|
+
try:
|
|
3146
|
+
req = httpx.Request("POST", "https://ilink/ilink/bot/getupdates")
|
|
3147
|
+
resp_502 = httpx.Response(502, request=req)
|
|
3148
|
+
client.http_client.post = AsyncMock(
|
|
3149
|
+
side_effect=httpx.HTTPStatusError("502 Bad Gateway", request=req, response=resp_502)
|
|
3150
|
+
)
|
|
3151
|
+
client.state.clear = mock.MagicMock()
|
|
3152
|
+
with self.assertRaises(httpx.HTTPStatusError):
|
|
3153
|
+
await client.get_updates("buf1", "https://ilink", "tok")
|
|
3154
|
+
client.state.clear.assert_not_called()
|
|
3155
|
+
finally:
|
|
3156
|
+
await client.close()
|
|
3157
|
+
|
|
3158
|
+
async def test_price_http_error_does_not_leak_url_or_appkey(self):
|
|
3159
|
+
import httpx
|
|
3160
|
+
from wechatbridge import price as price_mod
|
|
3161
|
+
|
|
3162
|
+
req = httpx.Request(
|
|
3163
|
+
"GET",
|
|
3164
|
+
"https://openapi.dataoke.com/api/goods/get-goods-details?appKey=SECRET_DT_KEY_123&id=1",
|
|
3165
|
+
)
|
|
3166
|
+
err = httpx.ConnectError(
|
|
3167
|
+
"Connection failed for https://openapi.dataoke.com/api?appKey=SECRET_DT_KEY_123",
|
|
3168
|
+
request=req,
|
|
3169
|
+
)
|
|
3170
|
+
with mock.patch.object(price_mod, "dt_configured", return_value=True), \
|
|
3171
|
+
mock.patch.object(price_mod, "query_tb_item", side_effect=err):
|
|
3172
|
+
reply = await price_mod.handle_price_query({"kind": "tb_item", "item_id": "1"})
|
|
3173
|
+
self.assertNotIn("SECRET_DT_KEY_123", reply)
|
|
3174
|
+
self.assertIn("比价接口请求失败", reply)
|
|
3175
|
+
|
|
3176
|
+
with self.assertRaises(ValueError):
|
|
3177
|
+
price_mod._resolve_short_link("http://127.0.0.1:8080/admin")
|
|
3178
|
+
self.assertFalse(price_mod._is_allowed_short_url("https://evil.u.jd.com/abc"))
|
|
3179
|
+
self.assertFalse(price_mod._is_allowed_short_url("https://a.3.cn/xyz"))
|
|
3180
|
+
self.assertTrue(price_mod._is_allowed_short_url("https://u.jd.com/abc"))
|
|
3181
|
+
|
|
3182
|
+
def test_ensure_user_gemini_refreshes_stale_oauth_token_and_blocks_copied_secrets(self):
|
|
3183
|
+
from wechatbridge import agy as agy_mod
|
|
3184
|
+
from wechatbridge.runner_common import is_sensitive_artifact_path, _sanitize_prompt_at_paths
|
|
3185
|
+
|
|
3186
|
+
fake_host_home = os.path.join(self.session_base, "host_home")
|
|
3187
|
+
host_agy = os.path.join(fake_host_home, ".gemini", "antigravity-cli")
|
|
3188
|
+
os.makedirs(host_agy, exist_ok=True)
|
|
3189
|
+
src_token = os.path.join(host_agy, "antigravity-oauth-token")
|
|
3190
|
+
with open(src_token, "w") as f:
|
|
3191
|
+
f.write("old-token-value-111111")
|
|
3192
|
+
|
|
3193
|
+
def _fake_expanduser(p: str) -> str:
|
|
3194
|
+
if p.startswith("~/"):
|
|
3195
|
+
return os.path.join(fake_host_home, p[2:])
|
|
3196
|
+
return p
|
|
3197
|
+
|
|
3198
|
+
with mock.patch("os.path.expanduser", side_effect=_fake_expanduser):
|
|
3199
|
+
sdir = agy_mod.ensure_user_gemini("u-tok-refresh")
|
|
3200
|
+
dst_token = os.path.join(sdir, ".gemini", "antigravity-cli", "antigravity-oauth-token")
|
|
3201
|
+
with open(dst_token) as f:
|
|
3202
|
+
self.assertEqual(f.read(), "old-token-value-111111")
|
|
3203
|
+
|
|
3204
|
+
# Make dst older and update src token
|
|
3205
|
+
os.utime(dst_token, (1000.0, 1000.0))
|
|
3206
|
+
with open(src_token, "w") as f:
|
|
3207
|
+
f.write("refreshed-token-value-222222")
|
|
3208
|
+
os.utime(src_token, (2000.0, 2000.0))
|
|
3209
|
+
|
|
3210
|
+
agy_mod.ensure_user_gemini("u-tok-refresh")
|
|
3211
|
+
with open(dst_token) as f:
|
|
3212
|
+
self.assertEqual(f.read(), "refreshed-token-value-222222")
|
|
3213
|
+
|
|
3214
|
+
# Renamed / copied credentials must still be blocked by is_sensitive_artifact_path
|
|
3215
|
+
copied_secret = os.path.join(sdir, "token.copy")
|
|
3216
|
+
with open(copied_secret, "w") as f:
|
|
3217
|
+
f.write("refreshed-token-value-222222")
|
|
3218
|
+
self.assertTrue(is_sensitive_artifact_path(copied_secret, sdir))
|
|
3219
|
+
|
|
3220
|
+
auth_bak = os.path.join(sdir, "auth.json.bak")
|
|
3221
|
+
with open(auth_bak, "w") as f:
|
|
3222
|
+
f.write("{}")
|
|
3223
|
+
self.assertTrue(is_sensitive_artifact_path(auth_bak, sdir))
|
|
3224
|
+
|
|
3225
|
+
scratch_settings = os.path.join(sdir, ".gemini", "antigravity-cli", "scratch", "settings.json")
|
|
3226
|
+
os.makedirs(os.path.dirname(scratch_settings), exist_ok=True)
|
|
3227
|
+
with open(scratch_settings, "w") as f:
|
|
3228
|
+
f.write("{}")
|
|
3229
|
+
self.assertTrue(is_sensitive_artifact_path(scratch_settings, sdir))
|
|
3230
|
+
|
|
3231
|
+
# @<path> sanitization for bare sensitive names, URL-encoded traversal, and URLs
|
|
3232
|
+
p_out = _sanitize_prompt_at_paths(
|
|
3233
|
+
"read @prefs.json @auth.json.bak @..%2f..%2fetc%2fpasswd @https://evil.com/a",
|
|
3234
|
+
sdir,
|
|
3235
|
+
)
|
|
3236
|
+
self.assertEqual(
|
|
3237
|
+
p_out,
|
|
3238
|
+
"read [blocked-path] [blocked-path] [blocked-path] [blocked-path]",
|
|
3239
|
+
)
|
|
3240
|
+
|
|
3241
|
+
|
|
2986
3242
|
if __name__ == "__main__":
|
|
2987
3243
|
unittest.main()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Price module contract tests (stdlib unittest only).
|
|
2
|
+
|
|
3
|
+
These pin the *external* contracts that cannot be exercised without real
|
|
4
|
+
API keys: the JD gateway signature rule, the Dataoke signature rule, the
|
|
5
|
+
correct Dataoke endpoints/params for taokouling parsing and goods detail,
|
|
6
|
+
and response parsing against sample payloads shaped like the real APIs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
os.environ.setdefault("JDUNION_APP_KEY", "test_jd_key")
|
|
17
|
+
os.environ.setdefault("JDUNION_APP_SECRET", "test_jd_secret")
|
|
18
|
+
os.environ.setdefault("DATAOKE_APP_KEY", "test_dt_key")
|
|
19
|
+
os.environ.setdefault("DATAOKE_APP_SECRET", "test_dt_secret")
|
|
20
|
+
|
|
21
|
+
from wechatbridge import price as price_mod
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestJdSign(unittest.TestCase):
|
|
25
|
+
def test_secret_wraps_both_ends(self):
|
|
26
|
+
params = {
|
|
27
|
+
"method": "jd.union.open.goods.query",
|
|
28
|
+
"app_key": "test_jd_key",
|
|
29
|
+
"access_token": "",
|
|
30
|
+
"timestamp": "2026-11-01 19:00:00",
|
|
31
|
+
"format": "json",
|
|
32
|
+
"v": "1.0",
|
|
33
|
+
"sign_method": "md5",
|
|
34
|
+
"360buy_param_json": '{"skuIds":["123"]}',
|
|
35
|
+
}
|
|
36
|
+
plain = "".join(f"{k}{v}" for k, v in sorted(params.items()))
|
|
37
|
+
expected = hashlib.md5(
|
|
38
|
+
("test_jd_secret" + plain + "test_jd_secret").encode("utf-8")
|
|
39
|
+
).hexdigest().upper()
|
|
40
|
+
self.assertEqual(price_mod._jd_sign(dict(params)), expected)
|
|
41
|
+
|
|
42
|
+
def test_jd_query_parses_sample_payload(self):
|
|
43
|
+
sample = {
|
|
44
|
+
"jd_union_open_goods_query_reslt": {
|
|
45
|
+
"code": "0",
|
|
46
|
+
"data": [
|
|
47
|
+
{
|
|
48
|
+
"skuName": "Sample Widget 2000",
|
|
49
|
+
"priceInfo": {"price": 129.0, "lowestPrice": 99.0},
|
|
50
|
+
"couponInfo": {
|
|
51
|
+
"couponList": [
|
|
52
|
+
{"discount": 20.0, "quota": 100.0, "endTime": "2026-11-11 23:59:59"}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"imageInfo": {"imageList": ["https://img.example/x.jpg"]},
|
|
56
|
+
"shopInfo": {"shopName": "Sample Shop"},
|
|
57
|
+
"materialUrl": "https://item.jd.com/123.html",
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
with mock.patch.object(price_mod, "_jd_call", return_value=sample):
|
|
63
|
+
res = price_mod.query_jd_sku("123")
|
|
64
|
+
self.assertEqual(res.title, "Sample Widget 2000")
|
|
65
|
+
self.assertEqual(res.price, 129.0)
|
|
66
|
+
self.assertEqual(res.lowest_price, 99.0)
|
|
67
|
+
self.assertEqual(res.net_price(), 109.0)
|
|
68
|
+
card = res.card()
|
|
69
|
+
self.assertIn("到手价: ¥109", card)
|
|
70
|
+
self.assertIn("历史最低: ¥99", card)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestDataokeSignAndCalls(unittest.TestCase):
|
|
74
|
+
def test_dt_sign_matches_sdk_algorithm(self):
|
|
75
|
+
params = {"goodsId": "789", "appKey": "test_dt_key", "version": "v2.0.0"}
|
|
76
|
+
plain = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
77
|
+
expected = hashlib.md5(
|
|
78
|
+
(plain + "&key=test_dt_secret").encode("utf-8")
|
|
79
|
+
).hexdigest().upper()
|
|
80
|
+
self.assertEqual(price_mod._dt_sign(dict(params)), expected)
|
|
81
|
+
|
|
82
|
+
def test_dt_call_attaches_appkey_version_and_sign(self):
|
|
83
|
+
captured = {}
|
|
84
|
+
|
|
85
|
+
class FakeResp:
|
|
86
|
+
def raise_for_status(self):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def json(self):
|
|
90
|
+
return {"code": 0, "data": {"ok": True}}
|
|
91
|
+
|
|
92
|
+
def fake_get(url, params=None, timeout=None):
|
|
93
|
+
captured["url"] = url
|
|
94
|
+
captured["params"] = dict(params or {})
|
|
95
|
+
return FakeResp()
|
|
96
|
+
|
|
97
|
+
with mock.patch.object(price_mod.httpx, "get", side_effect=fake_get):
|
|
98
|
+
out = price_mod._dt_call("/goods/get-goods-details", {"goodsId": "789"})
|
|
99
|
+
self.assertEqual(out, {"ok": True})
|
|
100
|
+
self.assertEqual(captured["params"]["appKey"], "test_dt_key")
|
|
101
|
+
self.assertEqual(captured["params"]["version"], "v2.0.0")
|
|
102
|
+
unsigned = {k: v for k, v in captured["params"].items() if k != "sign"}
|
|
103
|
+
self.assertEqual(captured["params"]["sign"], price_mod._dt_sign(unsigned))
|
|
104
|
+
|
|
105
|
+
def test_dt_call_raises_on_nonzero_code(self):
|
|
106
|
+
class FakeResp:
|
|
107
|
+
def raise_for_status(self):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def json(self):
|
|
111
|
+
return {"code": 1, "msg": "sign error"}
|
|
112
|
+
|
|
113
|
+
with mock.patch.object(price_mod.httpx, "get", return_value=FakeResp()):
|
|
114
|
+
with self.assertRaises(ValueError):
|
|
115
|
+
price_mod._dt_call("/goods/get-goods-details", {"goodsId": "1"})
|
|
116
|
+
|
|
117
|
+
def test_resolve_tkl_uses_parse_content_endpoint(self):
|
|
118
|
+
captured = {}
|
|
119
|
+
|
|
120
|
+
def fake_call(path, params):
|
|
121
|
+
captured["path"] = path
|
|
122
|
+
captured["params"] = dict(params)
|
|
123
|
+
return {"goodsId": "789456"}
|
|
124
|
+
|
|
125
|
+
with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
|
|
126
|
+
out = price_mod.resolve_tkl("CZ0001 aBcd12")
|
|
127
|
+
self.assertEqual(captured["path"], "/tb-service/parse-content")
|
|
128
|
+
self.assertEqual(captured["params"]["content"], "CZ0001 aBcd12")
|
|
129
|
+
self.assertEqual(out["goodsId"], "789456")
|
|
130
|
+
|
|
131
|
+
def test_query_tb_item_sends_goods_id_and_parses(self):
|
|
132
|
+
captured = {}
|
|
133
|
+
sample_data = {
|
|
134
|
+
"goods": {
|
|
135
|
+
"goodsName": "TB Sample",
|
|
136
|
+
"actualPrice": 59.9,
|
|
137
|
+
"originalPrice": 79.9,
|
|
138
|
+
"quanPrice": 10.0,
|
|
139
|
+
"quanReceiveCondition": 50.0,
|
|
140
|
+
"quanEndTime": "2026-11-11 00:00:00",
|
|
141
|
+
"shopName": "TB Shop",
|
|
142
|
+
"goodsImage": "https://img.example/y.jpg",
|
|
143
|
+
"goodsUrl": "https://detail.tmall.com/item.htm?id=789",
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def fake_call(path, params):
|
|
148
|
+
captured["path"] = path
|
|
149
|
+
captured["params"] = dict(params)
|
|
150
|
+
return sample_data
|
|
151
|
+
|
|
152
|
+
with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
|
|
153
|
+
res = price_mod.query_tb_item("789")
|
|
154
|
+
self.assertEqual(captured["params"], {"goodsId": "789"})
|
|
155
|
+
self.assertEqual(res.title, "TB Sample")
|
|
156
|
+
self.assertEqual(res.price, 59.9)
|
|
157
|
+
self.assertEqual(res.net_price(), 49.9)
|
|
158
|
+
|
|
159
|
+
def test_dt_configured_requires_secret(self):
|
|
160
|
+
with mock.patch.object(price_mod, "_DT_APP_SECRET", ""):
|
|
161
|
+
self.assertFalse(price_mod.dt_configured())
|
|
162
|
+
with mock.patch.object(price_mod, "_DT_APP_SECRET", "x"):
|
|
163
|
+
self.assertTrue(price_mod.dt_configured())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestDetectTrigger(unittest.TestCase):
|
|
167
|
+
def test_samples(self):
|
|
168
|
+
cases = [
|
|
169
|
+
("看看 https://u.jd.com/AbCdEf 不错", "jd_short"),
|
|
170
|
+
("8.8¥CZ0001 aBcd12¥ 打开淘宝", "tkl"),
|
|
171
|
+
("https://item.jd.com/100012043978.html", "jd_item"),
|
|
172
|
+
("https://detail.tmall.com/item.htm?id=789456123", "tb_item"),
|
|
173
|
+
("https://m.tb.cn/h.abc123", "tb_short"),
|
|
174
|
+
("今天天气不错", None),
|
|
175
|
+
]
|
|
176
|
+
for text, kind in cases:
|
|
177
|
+
got = price_mod.detect_price_trigger(text)
|
|
178
|
+
if kind is None:
|
|
179
|
+
self.assertIsNone(got, msg=text)
|
|
180
|
+
else:
|
|
181
|
+
self.assertIsNotNone(got, msg=text)
|
|
182
|
+
self.assertEqual(got["kind"], kind, msg=text)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
unittest.main()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.7.
|
|
2
|
+
__version__ = "1.7.2"
|