"""test_worker_service.py — §6.1 HTTP worker 核心逻辑单测(M3a U1)。 守的不变量: · job-in 解析:合法 JSON → dict;坏 JSON → None(不抛)。 · HMAC:compute_signature == hmac-sha256(secret, raw bytes) hex 小写(与 Java CallbackSignatureVerifier 对账)。 · 有界队列 + 去重(KTD4):有容量 → 202 受理入队;队满 → 503(非 2xx,执行器据此 LLM_ERROR,诚实残留); 同 job_id 重投 → 202 幂等、不重复入队(worker 侧防同 job 重跑)。 · process_job:跑 run_fn → 组 result-out → 向 callback.target 发(注入 stub run_fn/send_fn,零真实网络/LLM)。 跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_worker_service.py """ import hashlib import hmac import json import sys import tempfile import urllib.error import urllib.request from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ import worker_service as W # noqa: E402 import dedup # noqa: E402 # M3b U3:process_job 现会调 D9 dedup.check_similarity(写 FS 登记表)。把登记表重定向到临时路径, # 防本测污染真 cheap-worker/results/_dedup_registry.jsonl(与 wg1_groupb_test 隔离范式一致)。 dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="ws-dedup-")) / "_dedup_registry.jsonl" _GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();" def _tmp() -> Path: return Path(tempfile.mkdtemp(prefix="ws-test-")) def _make_game_dir(tmp: Path) -> Path: gd = tmp / "amgen-w1" (gd / "src").mkdir(parents=True) (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") (gd / "src" / "game-logic.js").write_text("// l\n", encoding="utf-8") return gd # ---------- parse_job ---------- def test_parse_job_valid(): raw = json.dumps({"job_id": "j1", "brief": "b", "gameId": "g1", "callback": {"target": "http://x"}}).encode("utf-8") job = W.parse_job(raw) assert job["job_id"] == "j1" and job["gameId"] == "g1" def test_parse_job_bad_json_returns_none(): assert W.parse_job(b"{not json") is None def test_default_run_fn_forwards_explicit_interaction_profile(monkeypatch, tmp_path): """进程内兼容入口必须原样透传可信 job 的 profile;字段缺失时仍传 None,不按 brief 猜测。""" import cheap_run import cheap_studio received = [] async def _run_studio(game_id, brief, **kwargs): received.append((game_id, brief, kwargs)) return {"ok": False} monkeypatch.setattr(cheap_studio, "run_studio", _run_studio) monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / str(gid)) W._default_run_fn({ "gameId": 42, "traceId": "trace-42", "brief": "标准三消解谜", "interactionProfileId": "match3.orthogonal-swap-v1", }) W._default_run_fn({"gameId": 43, "traceId": "trace-43", "brief": "标准三消解谜"}) assert received[0][2]["interaction_profile_id"] == "match3.orthogonal-swap-v1" assert received[1][2]["interaction_profile_id"] is None # ---------- HMAC ---------- def test_compute_signature_matches_hmac_sha256(): sig = W.compute_signature("secret", b"data") assert sig == hmac.new(b"secret", b"data", hashlib.sha256).hexdigest() def test_compute_signature_empty_secret_none(): """空 secret → 不签(返 None),与 service.py 一致(密钥空=验签关闭)。""" assert W.compute_signature("", b"data") is None # ---------- 有界队列 + 去重 ---------- def test_enqueue_accepts_until_full_then_503(): state = W.WorkerState(queue_maxsize=2, run_fn=lambda job: (None, None), send_fn=lambda *a: (200, "")) assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202) assert W.try_enqueue(state, {"job_id": "b"}) == (True, 202) # 队满:503(非 2xx;诚实残留:执行器据此置 LLM_ERROR,见 KTD4/m1) assert W.try_enqueue(state, {"job_id": "c"}) == (False, 503) def test_enqueue_dedup_same_job_id(): state = W.WorkerState(queue_maxsize=8, run_fn=lambda job: (None, None), send_fn=lambda *a: (200, "")) assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202) # 同 job_id 重投:幂等 202、不重复入队 assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202) assert state.queue.qsize() == 1 # ---------- process_job(注入 stub,零网络/LLM)---------- def test_process_job_success_posts_result_out_to_callback(): gd = _make_game_dir(_tmp()) captured = {} def send_fn(url, payload, secret): captured["url"] = url captured["payload"] = payload captured["secret"] = secret return 200, "ok" state = W.WorkerState( callback_secret="s3cr3t", run_fn=lambda job: ({"verdict": {"pass": True}, "costRmb": 0.02}, gd), send_fn=send_fn, profile_fn=lambda job, game_dir: None, # best-effort:本测不产 sourceProject ) job = {"job_id": "j", "traceId": "j", "templateId": "generic", "brief": "点点乐", "callback": {"target": "http://cb/internal"}} W.process_job(state, job) assert captured["url"] == "http://cb/internal" assert captured["payload"]["status"] == "succeeded" assert "__GameBundle" in captured["payload"]["engineBundle"] assert captured["payload"]["gameConfig"]["engineDriven"] is True assert captured["secret"] == "s3cr3t" def test_process_job_failed_maps_failure_reason(): gd = _make_game_dir(_tmp()) captured = {} state = W.WorkerState( callback_secret="", run_fn=lambda job: ({"verdict": {"pass": False, "failedGates": ["E_live"]}}, gd), send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""), profile_fn=lambda job, game_dir: None, ) job = {"job_id": "j2", "templateId": "generic", "brief": "x", "callback": {"target": "http://cb"}} W.process_job(state, job) assert captured["payload"]["status"] == "failed" assert captured["payload"]["failureReason"] in { "no_template_match", "config_invalid", "content_violation", "budget_exceeded", "timeout", "llm_error", "generation_failed", } assert captured["payload"].get("engineBundle") is None def test_process_job_with_profile_includes_source_project(): gd = _make_game_dir(_tmp()) captured = {} profile = {"tickModel": "frame", "inputModel": "tap-targets", "progressModel": "score"} state = W.WorkerState( callback_secret="", run_fn=lambda job: ({"verdict": {"pass": True}}, gd), send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""), profile_fn=lambda job, game_dir: profile, ) job = {"job_id": "j3", "templateId": "generic", "brief": "x", "callback": {"target": "http://cb"}} W.process_job(state, job) sp = json.loads(captured["payload"]["sourceProject"]) assert sp["schemaVersion"] == "2.0" and sp["profile"] == profile def test_create_scaffold_lineage_only_comes_from_publishable_v3(monkeypatch): """worker 只消费权威发布谓词;冻结 v2 和未验证对象都不得产血缘。""" sample = (Path(__file__).resolve().parents[2] / "contracts/play-loop/samples/playtest-evidence/valid" / "01-accepted-narrative.json") frozen_v2 = json.loads(sample.read_text(encoding="utf-8")) assert W._accepted_scaffold_template({"acceptanceV3": frozen_v2}) is None assert W._accepted_scaffold_template({ "acceptanceV3": {"templateRoute": "_template-story", "decision": {"accepted": True}} }) is None # 完整 playtest/3 契约与发布谓词由 test_acceptance_v3 覆盖;这里只验证 worker 的路由消费边界。 publishable_v3 = {"schemaVersion": "playtest/3", "templateRoute": "_template-story"} monkeypatch.setattr(W.cheap_verify, "is_v3_publishable", lambda value: value is publishable_v3) assert W._accepted_scaffold_template({"acceptanceV3": publishable_v3}) == "_template-story" # ---------- HTTP 集成(起真服务器 + worker 线程,注入 stub run_fn/send_fn)---------- def test_http_e2e_accept_and_process(monkeypatch): """POST /generate → 202 投递握手 → worker 串行处理 → 向 callback 发 succeeded result-out。""" monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v2"}) gd = _make_game_dir(_tmp()) captured = {} state = W.WorkerState( run_fn=lambda job: ({"verdict": {"pass": True}}, gd), send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""), profile_fn=lambda j, g: None, ) W.start_worker(state) server, port = W.start_server(state, port=0) try: body = json.dumps({"job_id": "h1", "traceId": "h1", "templateId": "generic", "brief": "x", "gameId": "w1", "callback": {"target": "http://cb"}}).encode("utf-8") req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=5) as resp: assert resp.status == 202 ack = json.loads(resp.read()) assert ack["accepted"] is True and ack["traceId"] == "h1" state.queue.join() # 等 worker 处理完 assert captured["payload"]["status"] == "succeeded" assert "__GameBundle" in captured["payload"]["engineBundle"] finally: server.shutdown() def test_http_freezes_expected_v3_and_blocks_fully_stripped_summary(monkeypatch): """HTTP 入队冻结 v3;run_fn 即使只返 legacy pass=true,也必须 downgrade failed。""" monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"}) gd = _make_game_dir(_tmp()) captured = {} state = W.WorkerState( run_fn=lambda job: (captured.update(job=job) or {"verdict": {"pass": True}, "accepted": True}, gd), send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""), profile_fn=lambda j, g: None, ) W.start_worker(state) server, port = W.start_server(state, port=0) try: body = json.dumps({"job_id": "h-v3", "traceId": "h-v3", "templateId": "generic", "brief": "x", "gameId": "w-v3", "callback": {"target": "http://cb"}}).encode("utf-8") req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=5) as resp: assert resp.status == 202 state.queue.join() assert captured["job"]["_expectedAcceptanceMode"] == "v3" assert captured["payload"]["status"] == "failed" assert captured["payload"]["trace"]["acceptanceState"] == "downgrade_detected" finally: server.shutdown() def test_http_bad_json_returns_400(): state = W.WorkerState(run_fn=lambda j: (None, None), send_fn=lambda *a: (200, "")) server, port = W.start_server(state, port=0) try: req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=b"{bad", method="POST", headers={"Content-Type": "application/json"}) try: urllib.request.urlopen(req, timeout=5) raise AssertionError("应回 400") except urllib.error.HTTPError as e: assert e.code == 400 finally: server.shutdown() def test_http_health_200(): state = W.WorkerState(run_fn=lambda j: (None, None), send_fn=lambda *a: (200, "")) server, port = W.start_server(state, port=0) try: with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as resp: assert resp.status == 200 assert json.loads(resp.read())["ok"] is True finally: server.shutdown() def test_post_callback_builds_signed_request(): """post_callback 构建 POST 请求:body=签名字节、X-Callback-Signature=HMAC(secret,body)(与 Java 对账)。""" captured = {} class FakeResp: status = 200 def read(self): return b"ok" def __enter__(self): return self def __exit__(self, *a): return False def fake_opener(req, timeout=None): captured["url"] = req.full_url captured["method"] = req.get_method() captured["body"] = req.data captured["headers"] = {k.lower(): v for k, v in req.headers.items()} return FakeResp() status, body = W.post_callback("http://cb/internal", {"traceId": "t", "status": "succeeded"}, "sek", opener=fake_opener) assert status == 200 and body == "ok" assert captured["method"] == "POST" assert captured["url"] == "http://cb/internal" assert captured["headers"]["content-type"].startswith("application/json") expected_sig = hmac.new(b"sek", captured["body"], hashlib.sha256).hexdigest() assert captured["headers"]["x-callback-signature"] == expected_sig assert json.loads(captured["body"])["traceId"] == "t" def test_post_callback_no_signature_when_secret_empty(): """空 secret → 不带签名头(与 service.py 一致)。""" captured = {} class FakeResp: status = 200 def read(self): return b"" def __enter__(self): return self def __exit__(self, *a): return False def fake_opener(req, timeout=None): captured["headers"] = {k.lower(): v for k, v in req.headers.items()} return FakeResp() W.post_callback("http://cb", {"x": 1}, "", opener=fake_opener) assert "x-callback-signature" not in captured["headers"] def test_worker_loop_run_failure_sends_fallback_failed(): """run_fn 抛异常 → worker 发兜底 failed 回调(任务不挂 RUNNING)。""" captured = {} def boom(job): raise RuntimeError("gen exploded") state = W.WorkerState( run_fn=boom, send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""), ) W.start_worker(state) W.try_enqueue(state, {"job_id": "boom1", "traceId": "boom1", "templateId": "generic", "callback": {"target": "http://cb"}}) state.queue.join() assert captured["payload"]["status"] == "failed" # 七值枚举 catch-all:worker 异常兜底 → failureReason=llm_error(M3a 七值收敛后的真值; # 原断言 generation_failed 是 M3a 遗留陈旧值 —— 非枚举值会被后端 isValidFailureReason 拒)。 assert captured["payload"]["failureReason"] == "llm_error" # ---------- 面四断点①(capture):do_POST 抓入站 traceparent 进 job ---------- def test_do_post_captures_inbound_traceparent_into_job(): """POST /generate 带 traceparent header → worker 线程拿到的 job 带 _otelCarrier(供建 worker span)。""" gd = _make_game_dir(_tmp()) captured = {} state = W.WorkerState( run_fn=lambda job: captured.update(job=job) or ({"verdict": {"pass": True}}, gd), send_fn=lambda url, payload, secret: (200, ""), profile_fn=lambda j, g: None, ) W.start_worker(state) server, port = W.start_server(state, port=0) try: tp = "00-11112222333344445555666677778888-1111222233334444-01" body = json.dumps({"job_id": "tp1", "traceId": "tp1", "templateId": "generic", "brief": "x", "gameId": "w1", "callback": {"target": "http://cb"}}).encode("utf-8") req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST", headers={"Content-Type": "application/json", "traceparent": tp}) with urllib.request.urlopen(req, timeout=5) as resp: assert resp.status == 202 state.queue.join() # 入站 traceparent 被抓进 job(do_POST 只这里能读 header),供 process_job 在 worker 线程建 worker span。 assert captured["job"]["_otelCarrier"]["traceparent"] == tp finally: server.shutdown() def test_do_post_without_traceparent_adds_no_carrier(): """无 traceparent header → job 不带 _otelCarrier(纯增量、不无中生有;默认关行为不变)。""" gd = _make_game_dir(_tmp()) captured = {} state = W.WorkerState( run_fn=lambda job: captured.update(job=job) or ({"verdict": {"pass": True}}, gd), send_fn=lambda url, payload, secret: (200, ""), profile_fn=lambda j, g: None, ) W.start_worker(state) server, port = W.start_server(state, port=0) try: body = json.dumps({"job_id": "n1", "traceId": "n1", "brief": "x", "gameId": "w1", "callback": {"target": "http://cb"}}).encode("utf-8") req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=5) as resp: assert resp.status == 202 state.queue.join() assert "_otelCarrier" not in captured["job"] finally: server.shutdown() if __name__ == "__main__": _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] _failed = 0 for _fn in _fns: try: _fn() print(f" PASS {_fn.__name__}") except Exception as e: # noqa: BLE001 _failed += 1 print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") print(f"\n{len(_fns) - _failed}/{len(_fns)} passed") sys.exit(1 if _failed else 0)