"""Contract TV tại bàn (bàn giao 15, 05/08/2026) — WS /display/{id}, pair, push sau recommend. Điểm KHÁC các file test cũ: client ở đây dùng ``with TestClient(...)`` — context manager cho mọi request HTTP lẫn WS session dùng CHUNG một portal (một event loop). Không có nó thì mỗi request một loop riêng, và push từ route ``/api/recommend`` (loop A) sang WS session (loop B) là undefined behaviour chỉ-có-trong-test. Đổi lại phải vô hiệu ``_boot`` trước khi enter (context manager chạy lifespan → thread warmup sẽ đụng pooltool stub và set ``boot_error`` giữa chừng — race với monkeypatch). Engine vẫn mock như mọi test app: kiểm CONTRACT (hello/paired/payload/ status), không kiểm vật lý. Tái dùng make_shot/v2_result/BALLS_FULL của test_api_v2 — một nguồn duy nhất cho shape ShotFull. """ from __future__ import annotations import re import pytest from fastapi.testclient import TestClient from fastapi.websockets import WebSocketDisconnect from app import displays, jobqueue from app import main as app_main from test_api_v2 import BALLS_FULL, BALLS_LAST, make_shot, patch_v2, v2_result from test_queue import FakeQueue, start_worker @pytest.fixture(autouse=True) def _reset(): """Hub + transport về trạng thái sạch sau MỖI test — registry là state module-level, test sau không được thấy display/pair sót lại (nếp _reset_jobqueue của test_queue).""" yield displays.hub.reset() jobqueue.teardown() @pytest.fixture def client(monkeypatch, env_stub): # DATABASE_URL/REDIS_URL của máy dev không được lọt vào lifespan — # test displays chạy degraded (không DB, transport inprocess) như Space. monkeypatch.delenv("DATABASE_URL", raising=False) monkeypatch.delenv("REDIS_URL", raising=False) monkeypatch.setattr(app_main, "_boot", lambda: None) with TestClient(app_main.app) as c: 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) yield c # ------------------------------------------------------- WS hello + registry def test_ws_hello_mang_ma_pair_6_so(client): with client.websocket_connect("/display/tv-a") as ws: hello = ws.receive_json() assert hello["type"] == "hello" assert re.fullmatch(r"\d{6}", hello["code"]) assert hello["paired"] is False def test_ws_reconnect_cung_display_id_giu_nguyen_ma(client): """TV reload giữ display_id trong sessionStorage → reconnect phải thấy ĐÚNG mã cũ, người đang gõ dở mã trên điện thoại không bị đổi bài.""" with client.websocket_connect("/display/tv-a") as ws: code1 = ws.receive_json()["code"] with client.websocket_connect("/display/tv-a") as ws: code2 = ws.receive_json()["code"] assert code1 == code2 def test_ws_hai_display_hai_ma_khac_nhau(client): with client.websocket_connect("/display/tv-a") as wa: with client.websocket_connect("/display/tv-b") as wb: ca = wa.receive_json()["code"] cb = wb.receive_json()["code"] assert ca != cb def test_ws_rot_giu_entry_va_ma(client): """Disconnect chỉ buông ws — entry (mã + pairing) sống tới khi server restart, đúng quyết định MVP 'pair mất khi restart là chấp nhận được'.""" with client.websocket_connect("/display/tv-a") as ws: code = ws.receive_json()["code"] d = displays.hub._displays["tv-a"] assert d.ws is None assert d.code == code # ------------------------------------------------- POST /api/displays/pair def test_pair_dung_ma_tv_nhan_paired(client): with client.websocket_connect("/display/tv-a") as ws: code = ws.receive_json()["code"] r = client.post("/api/displays/pair", json={"code": code, "session_id": "phien-1"}) assert r.status_code == 200 assert r.json() == {"paired": True} assert ws.receive_json() == {"type": "paired"} assert displays.hub._displays["tv-a"].session_id == "phien-1" def test_pair_sai_ma_404_message_tieng_viet(client): with client.websocket_connect("/display/tv-a") as ws: ws.receive_json() r = client.post("/api/displays/pair", json={"code": "000000", "session_id": "phien-1"}) assert r.status_code == 404 detail = r.json()["detail"] assert isinstance(detail, str) # FE toast chỉ đọc được string assert "Mã ghép không đúng" in detail assert "TV" in detail def test_pair_thieu_session_id_422(client): r = client.post("/api/displays/pair", json={"code": "123456"}) assert r.status_code == 422 def test_pair_khi_tv_dang_rot_van_duoc_reconnect_thay_paired(client): """TV rớt đúng lúc người chơi gõ mã: entry còn → pair vẫn 200, và hello lúc TV reconnect phải mang paired=True (không cần gửi bù message nào).""" with client.websocket_connect("/display/tv-a") as ws: code = ws.receive_json()["code"] r = client.post("/api/displays/pair", json={"code": code, "session_id": "phien-1"}) assert r.status_code == 200 with client.websocket_connect("/display/tv-a") as ws: hello = ws.receive_json() assert hello["code"] == code assert hello["paired"] is True def test_pair_lai_phien_khac_de_len(client): """Bàn đổi khách: pair lần nữa với phiên mới — phiên mới thắng.""" with client.websocket_connect("/display/tv-a") as ws: code = ws.receive_json()["code"] client.post("/api/displays/pair", json={"code": code, "session_id": "phien-1"}) ws.receive_json() # paired lần 1 client.post("/api/displays/pair", json={"code": code, "session_id": "phien-2"}) ws.receive_json() # paired lần 2 assert displays.hub._displays["tv-a"].session_id == "phien-2" # ------------------------------------------- push sau recommend (Bước 3, G3) def _pair(client, ws, session_id): """Đọc hello + pair + đọc báo paired — WS sạch message, sẵn chờ push.""" code = ws.receive_json()["code"] r = client.post("/api/displays/pair", json={"code": code, "session_id": session_id}) assert r.status_code == 200 assert ws.receive_json() == {"type": "paired"} def test_push_inprocess_tv_nhan_dung_payload(client, monkeypatch): patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)])) with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-1") r = client.post("/api/recommend", json={"balls": BALLS_FULL, "session_id": "phien-1"}) assert r.status_code == 200 msg = ws.receive_json() assert msg["type"] == "recommend" assert msg["balls"] == BALLS_FULL assert msg["result"] == r.json() # TV thấy ĐÚNG cái điện thoại thấy assert msg["ts"] # timestamp có mặt (format tự do) def test_push_qua_queue_transport(client, env_stub, monkeypatch): """G3 — push phải chạy Y HỆT khi recommend đi qua worker Redis: push nằm ở API layer SAU khi có kết quả, transport nào cũng đi qua nó.""" patch_v2(monkeypatch, v2_result([make_shot(1)])) q = FakeQueue() jobqueue.setup(client=q.api) start_worker(q, env_stub) with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-1") r = client.post("/api/recommend", json={"balls": BALLS_FULL, "session_id": "phien-1"}) assert r.status_code == 200 msg = ws.receive_json() assert msg["type"] == "recommend" assert msg["result"] == r.json() def test_g2_response_byte_equal_co_khong_tv(client, monkeypatch): """G2 — bất biến recommend: body CÓ TV ghép bằng TỪNG BYTE body KHÔNG TV (mock giữ elapsed_s cố định nên so thẳng content, cùng kỹ thuật với bất biến hai mode của bàn giao 14).""" patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)])) body = {"balls": BALLS_FULL, "alternatives": 3, "session_id": "phien-1"} khong_tv = client.post("/api/recommend", json=body) assert khong_tv.status_code == 200 # session có, TV chưa ghép with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-1") co_tv = client.post("/api/recommend", json=body) assert co_tv.status_code == 200 ws.receive_json() # push ĐÃ tới TV assert co_tv.content == khong_tv.content def test_tv_rot_recommend_van_200(client, monkeypatch): """G3 — TV rớt giữa chừng (đã ghép, socket đóng): recommend vẫn 200, không exception, không chậm — best-effort bỏ qua lặng lẽ.""" patch_v2(monkeypatch, v2_result([make_shot(1)])) with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-1") r = client.post("/api/recommend", json={"balls": BALLS_FULL, "session_id": "phien-1"}) assert r.status_code == 200 assert len(r.json()["shots"]) == 1 def test_push_chi_toi_phien_da_ghep(client, monkeypatch): """Recommend của phiên KHÁC không được lọt sang TV. Chứng minh không cần chờ timeout: bắn phiên lạ trước, phiên mình sau — message ĐẦU TIÊN TV nhận phải là của phiên mình (thế bàn khác nhau làm chứng).""" patch_v2(monkeypatch, v2_result([make_shot(1)])) with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-A") client.post("/api/recommend", json={"balls": BALLS_FULL, "session_id": "phien-B"}) client.post("/api/recommend", json={"balls": BALLS_LAST, "session_id": "phien-A"}) msg = ws.receive_json() assert msg["balls"] == BALLS_LAST # ------------------------------------------- cap + TTL registry (bàn giao 20) def _clock(monkeypatch): """Đồng hồ giả tiêm vào ``displays._now`` — thời gian đi bằng monkeypatch, không sleep (BRIEF 3.4). Registry stamp ``last_seen_at`` qua ``_now()`` lúc gọi nên mọi mốc trong test đều đọc từ đây.""" state = {"t": 1_000_000.0} monkeypatch.setattr(displays, "_now", lambda: state["t"]) return state def test_qua_ttl_mat_ma_va_ghep_lai_duoc(client, monkeypatch): """TV rớt quá TTL → purge lazy ở register kế tiếp: reconnect được cấp entry MỚI (pairing cũ mất — "TV rớt quá 1h mất mã" là hành vi chấp nhận) và ghép lại được NGAY bằng mã mới.""" clock = _clock(monkeypatch) with client.websocket_connect("/display/tv-a") as ws: code1 = ws.receive_json()["code"] r = client.post("/api/displays/pair", json={"code": code1, "session_id": "phien-1"}) assert r.status_code == 200 ws.receive_json() # báo paired clock["t"] += displays.DISPLAY_TTL_S + 1 # rớt quá 60 phút with client.websocket_connect("/display/tv-a") as ws: hello = ws.receive_json() assert hello["paired"] is False # entry cũ đã bị purge r = client.post("/api/displays/pair", json={"code": hello["code"], "session_id": "phien-2"}) assert r.status_code == 200 assert ws.receive_json() == {"type": "paired"} assert displays.hub._displays["tv-a"].session_id == "phien-2" def test_duoi_ttl_entry_rot_khong_bi_purge(client, monkeypatch): """Rớt CHƯA quá TTL → entry (mã + pairing) còn nguyên qua lần register kế — hành vi giữ-mã của bàn giao 15 không đổi trong hạn.""" clock = _clock(monkeypatch) with client.websocket_connect("/display/tv-a") as ws: code1 = ws.receive_json()["code"] clock["t"] += displays.DISPLAY_TTL_S - 60 # 59 phút — còn hạn with client.websocket_connect("/display/tv-b") as ws: # trigger purge ws.receive_json() d = displays.hub._displays.get("tv-a") assert d is not None assert d.code == code1 def test_entry_dang_ghep_dang_ket_noi_khong_bao_gio_purge(client, monkeypatch): """Entry đang ghép + đang KẾT NỐI không bao giờ bị purge dù bao lâu trôi qua — TTL chỉ áp cho entry rớt (ws=None); TV nối liền nhiều giờ vẫn nhận push bình thường.""" clock = _clock(monkeypatch) patch_v2(monkeypatch, v2_result([make_shot(1)])) with client.websocket_connect("/display/tv-a") as ws: _pair(client, ws, "phien-1") clock["t"] += displays.DISPLAY_TTL_S * 10 # 10 tiếng trôi qua with client.websocket_connect("/display/tv-b") as wb: # trigger purge wb.receive_json() d = displays.hub._displays.get("tv-a") assert d is not None assert d.session_id == "phien-1" r = client.post("/api/recommend", json={"balls": BALLS_FULL, "session_id": "phien-1"}) assert r.status_code == 200 assert ws.receive_json()["type"] == "recommend" def test_day_cap_toan_song_tu_choi_sach(client, monkeypatch): """Đầy MAX_DISPLAYS toàn kết nối sống → TV mới bị accept-rồi-đóng (1013 Try Again Later), KHÔNG cấp entry; TV đang nối không suy suyển. Cap thu nhỏ bằng monkeypatch cho test nhẹ — register đọc hằng số module lúc gọi nên setattr là đủ.""" monkeypatch.setattr(displays, "MAX_DISPLAYS", 2) with client.websocket_connect("/display/tv-a") as wa: wa.receive_json() with client.websocket_connect("/display/tv-b") as wb: wb.receive_json() with client.websocket_connect("/display/tv-c") as wc: with pytest.raises(WebSocketDisconnect) as exc: wc.receive_json() # không hello — đóng ngay assert exc.value.code == 1013 assert set(displays.hub._displays) == {"tv-a", "tv-b"} def test_day_cap_xoa_entry_rot_cu_nhat(client, monkeypatch): """Đầy cap nhưng còn entry rớt → TV mới được chỗ bằng cách xoá entry ws=None có last_seen_at CŨ NHẤT — đúng con cũ nhất, không phải con rớt gần đây.""" clock = _clock(monkeypatch) monkeypatch.setattr(displays, "MAX_DISPLAYS", 3) with client.websocket_connect("/display/tv-old") as ws: ws.receive_json() clock["t"] += 100 with client.websocket_connect("/display/tv-mid") as ws: ws.receive_json() clock["t"] += 100 with client.websocket_connect("/display/tv-live") as wl: wl.receive_json() with client.websocket_connect("/display/tv-new") as wn: assert wn.receive_json()["type"] == "hello" # được entry thường assert "tv-old" not in displays.hub._displays assert "tv-mid" in displays.hub._displays assert "tv-live" in displays.hub._displays def test_reconnect_khi_day_cap_van_giu_ma(client, monkeypatch): """TV ĐÃ CÓ entry mở connection mới lúc registry đầy toàn sống → vẫn vào bình thường và giữ nguyên mã (chỉ TV MỚI mới cần xin chỗ).""" monkeypatch.setattr(displays, "MAX_DISPLAYS", 2) with client.websocket_connect("/display/tv-a") as wa: code_a = wa.receive_json()["code"] with client.websocket_connect("/display/tv-b") as wb: wb.receive_json() with client.websocket_connect("/display/tv-a") as wa2: assert wa2.receive_json()["code"] == code_a # --------------------------------------------------- trang /tv (Bước 4) def test_trang_tv_duoc_serve_va_khong_cache(client): """`/tv` đi qua mount static html=True (redirect sang `/tv/` rồi trả `tv/index.html`) và PHẢI mang no-cache như mọi html/js của app — bug cache 30/07 không được tái diễn trên trang mới.""" r = client.get("/tv") # TestClient tự follow redirect assert r.status_code == 200 assert "PoolCoach TV" in r.text assert r.headers["cache-control"] == "no-cache"