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