File size: 16,525 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
"""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"