Spaces:
Sleeping
Sleeping
File size: 20,037 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 | """Contract transport queue (bàn giao 14, 04/08/2026) — fakeredis, KHÔNG cần
Docker/Redis thật (Redis thật chỉ cần cho gate G3 chạy tay).
Worker giả trong test chạy ĐÚNG code path worker thật — `jobqueue.serve_one`
+ `engine.handle_job` — trên một FakeServer chung, API và worker mỗi bên một
client riêng như hai process thật. Chỉ khác chỗ boot: env_stub thay
PositionPlayEnv (nếp conftest — test contract, không test vật lý).
Byte-equality hai mode đo ĐÚNG NGHĨA ĐEN bằng `r.content` — cùng kỹ thuật
với bất biến DB on/off của bàn giao 10 (test_db_qr), mở rộng chiều mode.
"""
from __future__ import annotations
import threading
import time
import fakeredis
import pytest
from fastapi.testclient import TestClient
from poolcoach_rl import recommend as rec_pkg
from app import engine, jobqueue
from app import main as app_main
from test_api_v2 import BALLS_FULL, make_shot, patch_v2, v2_result
@pytest.fixture(autouse=True)
def _reset_jobqueue():
"""Mọi test trong file này xong là transport về inprocess — test file
khác (và test sau trong file) không được thấy queue sót lại (nếp db_mem)."""
yield
jobqueue.teardown()
@pytest.fixture
def client(monkeypatch, env_stub):
monkeypatch.setattr(app_main.state, "env_h", env_stub)
monkeypatch.setattr(app_main.state, "jit_ready", True)
monkeypatch.setattr(app_main.state, "boot_error", None)
return TestClient(app_main.app)
class FakeQueue:
"""FakeServer chung + hai connection tách nhau (API / worker)."""
def __init__(self):
self.server = fakeredis.FakeServer()
self.api = fakeredis.FakeStrictRedis(server=self.server,
decode_responses=True)
self.worker = fakeredis.FakeStrictRedis(server=self.server,
decode_responses=True)
@pytest.fixture
def fq():
q = FakeQueue()
jobqueue.setup(client=q.api) # API vào mode queue
return q
def start_worker(fq: FakeQueue, env_h, n_jobs=1) -> threading.Thread:
"""Worker giả: phục vụ đúng `n_jobs` job rồi dừng — cùng code path worker
thật (serve_one + handle_job), chỉ khác vòng đời hữu hạn cho test.
Beat heartbeat TRƯỚC khi phục vụ (đồng bộ, trong thread test — không
race): worker thật cũng beat trong vòng serve, và từ bàn giao 20 route
recommend fast-fail ĐỌC heartbeat trước enqueue — worker giả không beat
thì mọi test đi qua queue rơi nhầm vào đường 503-ngay. Ngữ nghĩa từng
test không đổi: vẫn là "worker sống phục vụ job" (cùng lý do
start_cv_worker của test_scan, bàn giao 19)."""
jobqueue.beat(client=fq.worker)
def run():
done = 0
tries = 0
while done < n_jobs and tries < 50:
tries += 1
if jobqueue.serve_one(lambda p: engine.handle_job(p, env_h),
timeout_s=0.2, client=fq.worker):
done += 1
t = threading.Thread(target=run, daemon=True)
t.start()
return t
# ------------------------------------------------------------ mode theo env
def test_mode_khong_redis_url_la_inprocess(monkeypatch):
"""Không set REDIS_URL → mode inprocess, chạy Y HỆT hôm nay (cùng nếp
DATABASE_URL — Space không được vỡ)."""
monkeypatch.delenv("REDIS_URL", raising=False)
assert jobqueue.setup() == "inprocess"
assert jobqueue.mode() == "inprocess"
def test_mode_co_redis_url_la_queue(monkeypatch):
"""Set REDIS_URL → mode queue. Chưa có lệnh nào chạy nên URL không cần
trỏ tới Redis sống — connect lười của redis-py."""
monkeypatch.setenv("REDIS_URL", "redis://localhost:6399/9")
assert jobqueue.setup() == "queue"
assert jobqueue.mode() == "queue"
# ------------------------------------------------- transport: enqueue/reply
def test_submit_roundtrip_qua_worker_gia(fq, env_stub, monkeypatch):
patch_v2(monkeypatch, v2_result([make_shot(1)]))
start_worker(fq, env_stub)
reply = jobqueue.submit({"balls": BALLS_FULL, "alternatives": 3})
assert reply["ok"] is True
assert reply["result"]["shots"][0]["phi"] == 45.0
assert reply["result"]["search"]["engine"] == "zone"
def test_result_key_co_ttl_don_rac(fq, env_stub, monkeypatch):
"""Reply worker phải mang TTL — API timeout bỏ đi thì kết quả mồ côi tự
bốc hơi, không rác vĩnh viễn trong Redis."""
import json
patch_v2(monkeypatch, v2_result([make_shot(1)]))
fq.api.lpush(jobqueue.JOBS_KEY,
json.dumps({"job_id": "j-ttl",
"payload": {"balls": BALLS_FULL,
"alternatives": 3}}))
assert jobqueue.serve_one(lambda p: engine.handle_job(p, env_stub),
timeout_s=0.2, client=fq.worker) is True
key = jobqueue.RESULT_KEY.format(job_id="j-ttl")
ttl = fq.api.ttl(key)
assert 0 < ttl <= jobqueue.RESULT_TTL_S
assert json.loads(fq.api.lindex(key, 0))["ok"] is True
def test_submit_khong_worker_nem_queue_timeout(fq, monkeypatch):
monkeypatch.setenv("POOLCOACH_QUEUE_TIMEOUT_S", "0.2")
with pytest.raises(jobqueue.QueueTimeout):
jobqueue.submit({"balls": BALLS_FULL, "alternatives": 3})
def test_submit_redis_chet_nem_queue_down(fq):
fq.server.connected = False
with pytest.raises(jobqueue.QueueDown):
jobqueue.submit({"balls": BALLS_FULL, "alternatives": 3})
# --------------------------------------------------- API qua queue: happy path
def test_api_recommend_qua_queue_dung_shape(client, fq, env_stub, monkeypatch):
patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)]))
start_worker(fq, env_stub)
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r.status_code == 200
data = r.json()
assert len(data["shots"]) == 2
assert data["shots"][0]["describe"].startswith("ĐÁNH BI 1 VÀO LỖ")
assert data["search"] == {"engine": "zone", "n_sim": 99, "n_pot": 2,
"elapsed_s": 1.46}
def test_api_hai_mode_bang_nhau_tung_byte(client, env_stub, monkeypatch):
"""Bất biến trung tâm của bàn giao 14: cùng thế bàn + engine tất định →
body inprocess và body queue BẰNG NHAU TỪNG BYTE (mock giữ elapsed_s cố
định — ngoài đời elapsed_s là đồng hồ, gate G3 so phần còn lại)."""
patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)]))
body = {"balls": BALLS_FULL, "alternatives": 3}
jobqueue.teardown() # mode inprocess
inproc = client.post("/api/recommend", json=body)
assert inproc.status_code == 200
q = FakeQueue() # mode queue + worker giả
jobqueue.setup(client=q.api)
start_worker(q, env_stub)
queued = client.post("/api/recommend", json=body)
assert queued.status_code == 200
assert queued.content == inproc.content
def test_api_validation_422_cung_byte_hai_mode(client, env_stub, monkeypatch):
"""ValueError của validate_full đi qua worker phải ra ĐÚNG 422 + message
nguyên văn như in-process — cả body lỗi cũng trùng từng byte."""
def boom(*a, **k):
raise ValueError("cue và 1 chồng lên nhau (< 2R)")
monkeypatch.setattr(rec_pkg, "recommend_v2", boom)
body = {"balls": BALLS_FULL}
jobqueue.teardown()
inproc = client.post("/api/recommend", json=body)
assert inproc.status_code == 422
q = FakeQueue()
jobqueue.setup(client=q.api)
start_worker(q, env_stub)
queued = client.post("/api/recommend", json=body)
assert queued.status_code == 422
assert queued.content == inproc.content
assert queued.json()["detail"] == "cue và 1 chồng lên nhau (< 2R)"
def test_api_worker_loi_noi_bo_500_worker_song_tiep(client, fq, env_stub,
monkeypatch):
"""Job hỏng → 500 message rõ, nhưng worker KHÔNG chết — request kế được
phục vụ bình thường (handle_job nuốt exception thành reply lỗi)."""
calls = {"n": 0}
def flaky(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("no bung gia lap")
return v2_result([make_shot(1)])
monkeypatch.setattr(rec_pkg, "recommend_v2", flaky)
start_worker(fq, env_stub, n_jobs=2)
r1 = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r1.status_code == 500
assert "Engine gặp lỗi khi tính cú" in r1.json()["detail"]
assert "RuntimeError" in r1.json()["detail"]
r2 = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r2.status_code == 200
# ------------------------------------------- API qua queue: 503, không fallback
def test_api_khong_worker_503_va_khong_fallback(client, fq, monkeypatch):
"""Worker chết SAU enqueue (heartbeat còn sống trong cửa sổ TTL 15s) →
503 message tiếng Việt trong ~timeout. TUYỆT ĐỐI không âm thầm fallback
in-process (BRIEF 1.4) — engine local mà bị gọi là test này nổ ngay bằng
AssertionError. Từ bàn giao 20 phải đặt heartbeat để đi đúng đường
timeout-sau-enqueue nó vốn kiểm; vắng heartbeat là đường fast-fail
503-ngay — test riêng bên dưới."""
def phai_khong_duoc_goi(*a, **k):
raise AssertionError("mode queue mà engine in-process bị gọi — "
"fallback lén là bug")
monkeypatch.setattr(rec_pkg, "recommend_v2", phai_khong_duoc_goi)
jobqueue.beat(client=fq.worker)
monkeypatch.setenv("POOLCOACH_QUEUE_TIMEOUT_S", "0.2")
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r.status_code == 503
detail = r.json()["detail"]
assert "Engine không trả lời" in detail
assert "worker" in detail
def test_api_redis_chet_503_message(client, fq, monkeypatch):
"""Redis chết → 503 message "Redis". Từ bàn giao 20 đường đi là
`worker_alive_or_raise` ném QueueDown ngay ở check fast-fail (trước cả
submit) — vẫn CÙNG nhánh message "Redis", không đổ oan cho worker."""
patch_v2(monkeypatch, v2_result([make_shot(1)]))
fq.server.connected = False
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r.status_code == 503
assert "Redis" in r.json()["detail"]
def test_api_worker_song_lai_request_ke_ok(client, fq, env_stub, monkeypatch):
"""G3(c) bản test: worker chết → 503; worker sống lại → request kế 200
mà KHÔNG restart API. Job kẹt lại từ lúc chết được worker mới dọn (reply
mồ côi tự hết hạn TTL) rồi mới tới job mới. Từ bàn giao 20 phải đặt
heartbeat trước request đầu — không thì job #1 không bao giờ được enqueue
(fast-fail) và "job kẹt được dọn" thành kiểm chuyện không xảy ra."""
patch_v2(monkeypatch, v2_result([make_shot(1)]))
jobqueue.beat(client=fq.worker)
monkeypatch.setenv("POOLCOACH_QUEUE_TIMEOUT_S", "0.2")
r1 = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r1.status_code == 503 # job #1 kẹt trong list
monkeypatch.delenv("POOLCOACH_QUEUE_TIMEOUT_S")
start_worker(fq, env_stub, n_jobs=2) # dọn job kẹt + job mới
r2 = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r2.status_code == 200
# ------------------------------------ fast-fail heartbeat (bàn giao 20)
# Đối xứng /api/scan (test_scan, bàn giao 19): 3 đường sau fast-fail —
# (a) vắng heartbeat → 503-ngay không enqueue; (b) heartbeat sống nhưng
# worker kẹt → đường timeout cũ; (c) worker sống → byte-equal (đã khoá ở
# test_api_hai_mode_bang_nhau_tung_byte, start_worker beat sẵn heartbeat).
def test_recommend_vang_heartbeat_503_ngay_khong_doi_timeout(client, fq,
monkeypatch):
"""Vắng heartbeat engine worker → 503 NGAY message riêng, KHÔNG enqueue.
Timeout giữ MẶC ĐỊNH 30s (cố ý không rút ngắn): nếu route lỡ đi đường
submit thì test này mất trọn 30s — đồng hồ < 2s là bằng chứng đo được
của "503-ngay, không đợi timeout" (gate G2). Engine in-process cũng
không được gọi — fast-fail không phải cửa fallback lén."""
def phai_khong_duoc_goi(*a, **k):
raise AssertionError("fast-fail mà engine in-process bị gọi — "
"fallback lén là bug")
monkeypatch.setattr(rec_pkg, "recommend_v2", phai_khong_duoc_goi)
t0 = time.perf_counter()
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
elapsed = time.perf_counter() - t0
assert r.status_code == 503
assert "Engine worker không chạy" in r.json()["detail"]
assert "engine_worker" in r.json()["detail"]
assert elapsed < 2.0
assert fq.api.llen(jobqueue.JOBS_KEY) == 0 # không đẩy job
def test_recommend_co_heartbeat_worker_ket_van_duong_timeout(client, fq,
monkeypatch):
"""Heartbeat sống nhưng worker không phục vụ (= chết SAU enqueue, trong
cửa sổ TTL 15s) → vẫn đường timeout cũ: job ĐÃ vào queue, 503 message
"không trả lời" — fast-fail không nuốt mất ca này."""
jobqueue.beat(client=fq.worker)
monkeypatch.setenv("POOLCOACH_QUEUE_TIMEOUT_S", "0.2")
r = client.post("/api/recommend", json={"balls": BALLS_FULL})
assert r.status_code == 503
assert "không trả lời" in r.json()["detail"]
assert fq.api.llen(jobqueue.JOBS_KEY) == 1 # job đã enqueue
# ------------------------------------------------------- health 3 trạng thái
def test_health_queue_worker_song(client, fq):
jobqueue.beat(client=fq.worker)
data = client.get("/api/health").json()
assert data["mode"] == "queue"
assert data["redis_ok"] is True
assert data["worker_alive"] is True
assert data["status"] == "ok" # trường cũ không đổi
def test_health_queue_worker_chet(client, fq):
data = client.get("/api/health").json()
assert data["mode"] == "queue"
assert data["redis_ok"] is True
assert data["worker_alive"] is False
def test_health_queue_worker_thoat_sach_chet_ngay(client, fq):
"""Ctrl+C của worker xoá heartbeat — health thấy chết NGAY, không phải
đợi TTL 15s trôi."""
jobqueue.beat(client=fq.worker)
jobqueue.clear_heartbeat(client=fq.worker)
assert client.get("/api/health").json()["worker_alive"] is False
def test_health_queue_redis_chet_khong_500(client, fq):
"""Redis chết thì health TRẢ LỜI chuyện đó (redis_ok False), không 500."""
fq.server.connected = False
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["mode"] == "queue"
assert data["redis_ok"] is False
assert data["worker_alive"] is False
# ------------------------------------------- queue scan (bàn giao 18, 05/08)
# Queue thứ hai TRÊN CÙNG transport: các hàm nhận jobs_key/key tuỳ chọn,
# mặc định giữ queue recommend — test khoá cả hai vế: đi đúng list mới VÀ
# không lọt sang list cũ. Worker giả trả reply canned (CV thật cần torch —
# venv app không có, đúng thiết kế 2 venv; logic detect đo ở gate G3 tay).
SCAN_REPLY = {"ok": True, "result": {"balls": [
{"x": 0.45, "y": 0.30, "type": "cue", "conf": 0.98}]}}
def start_scan_worker(fq: FakeQueue, reply=None, n_jobs=1) -> threading.Thread:
"""Worker scan giả: cùng serve_one worker thật, chỉ khác handler canned."""
def run():
done = 0
tries = 0
while done < n_jobs and tries < 50:
tries += 1
if jobqueue.serve_one(lambda p: reply or SCAN_REPLY,
timeout_s=0.2, client=fq.worker,
jobs_key=jobqueue.SCAN_JOBS_KEY):
done += 1
t = threading.Thread(target=run, daemon=True)
t.start()
return t
def test_scan_queue_la_list_rieng(fq):
"""Job scan nằm ở pc:jobs:scan — worker recommend KHÔNG thấy nó, worker
scan thấy và reply về result key chung giao thức."""
import json
fq.api.lpush(jobqueue.SCAN_JOBS_KEY,
json.dumps({"job_id": "j-scan", "payload": {"n": 1}}))
assert jobqueue.serve_one(lambda p: {"ok": True}, timeout_s=0.1,
client=fq.worker) is False
assert jobqueue.serve_one(lambda p: SCAN_REPLY, timeout_s=0.1,
client=fq.worker,
jobs_key=jobqueue.SCAN_JOBS_KEY) is True
key = jobqueue.RESULT_KEY.format(job_id="j-scan")
reply = json.loads(fq.api.lindex(key, 0))
assert reply["ok"] is True
assert 0 < fq.api.ttl(key) <= jobqueue.RESULT_TTL_S
def test_submit_jobs_key_scan_roundtrip(fq):
"""submit(jobs_key=SCAN_JOBS_KEY) đẩy đúng queue scan và nhận reply."""
start_scan_worker(fq)
reply = jobqueue.submit({"image_b64": "x", "corners": []},
jobs_key=jobqueue.SCAN_JOBS_KEY)
assert reply == SCAN_REPLY
def test_heartbeat_scan_doc_lap_voi_engine(fq):
"""Heartbeat CV worker là khoá RIÊNG — beat/clear bên scan không đụng
worker_alive() của engine và ngược lại."""
jobqueue.beat(client=fq.worker, key=jobqueue.SCAN_HEARTBEAT_KEY)
assert jobqueue.worker_alive() is False
assert jobqueue.worker_alive(key=jobqueue.SCAN_HEARTBEAT_KEY) is True
jobqueue.beat(client=fq.worker)
jobqueue.clear_heartbeat(client=fq.worker,
key=jobqueue.SCAN_HEARTBEAT_KEY)
assert jobqueue.worker_alive(key=jobqueue.SCAN_HEARTBEAT_KEY) is False
assert jobqueue.worker_alive() is True
# ---------------------------------- health cv_worker_alive (bàn giao 19)
# CHỈ THÊM trường (nếp bàn giao 14) — shape đầy đủ khoá bằng so TẬP ở
# test_api_v2.test_g_health_shape_sau_don_dep; ở đây khoá GIÁ TRỊ theo
# trạng thái heartbeat scan, và tính độc lập với worker_alive (engine).
def test_health_cv_worker_alive_doc_lap_hai_heartbeat(client, fq):
"""cv_worker_alive đọc heartbeat SCAN — chỉ CV sống thì cv=True/engine
=False, và ngược lại (hai worker chết/sống độc lập, bàn giao 18)."""
jobqueue.beat(client=fq.worker, key=jobqueue.SCAN_HEARTBEAT_KEY)
data = client.get("/api/health").json()
assert data["cv_worker_alive"] is True
assert data["worker_alive"] is False
jobqueue.clear_heartbeat(client=fq.worker,
key=jobqueue.SCAN_HEARTBEAT_KEY)
jobqueue.beat(client=fq.worker)
data = client.get("/api/health").json()
assert data["cv_worker_alive"] is False
assert data["worker_alive"] is True
def test_health_inprocess_cv_worker_alive_null(client):
"""Mode inprocess → cv_worker_alive null "không áp dụng" (cùng quy ước
redis_ok/worker_alive bàn giao 14), KHÔNG phải False "chết"."""
jobqueue.teardown()
assert client.get("/api/health").json()["cv_worker_alive"] is None
def test_health_redis_chet_cv_worker_alive_false(client, fq):
"""Redis chết → cv_worker_alive False như worker_alive, health vẫn 200
(không 500 — redis_ok=False là câu trả lời)."""
fq.server.connected = False
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["redis_ok"] is False
assert data["cv_worker_alive"] is False
|