Spaces:
Sleeping
Sleeping
| """Contract DB + QR + logging recommend (BRIEF 04/08/2026, Bước 4). | |
| Toàn bộ chạy SQLite in-memory — pytest KHÔNG cần Docker/Postgres (Postgres | |
| chỉ cần cho dev thật, gate G3 chạy tay). Engine vẫn mock như mọi test app: | |
| kiểm CONTRACT (status/shape/message/row DB), 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, hai file không được trôi khác nhau. | |
| """ | |
| from __future__ import annotations | |
| from datetime import datetime, timedelta, timezone | |
| import pytest | |
| import sqlalchemy as sa | |
| from fastapi.testclient import TestClient | |
| from poolcoach_rl import recommend as rec_pkg | |
| from app import db, qr | |
| from app import main as app_main | |
| from test_api_v2 import BALLS_FULL, make_shot, v2_result | |
| 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) | |
| def db_mem(): | |
| """DB SQLite in-memory sạch cho MỖI test; teardown trả module `db` về | |
| trạng thái tắt để các test degraded/cũ không thấy DB sót lại.""" | |
| db.setup("sqlite:///:memory:") | |
| db.Base.metadata.create_all(db.get_engine()) | |
| yield db | |
| db.teardown() | |
| def no_db(): | |
| """Degraded mode tường minh — như Space HF không có DATABASE_URL.""" | |
| db.teardown() | |
| yield | |
| def _seed_table(name="Bàn 1"): | |
| with db.session() as s: | |
| tenant = db.Tenant(name="dev") | |
| s.add(tenant) | |
| s.flush() | |
| tbl = db.Table(tenant_id=tenant.id, name=name) | |
| s.add(tbl) | |
| s.flush() | |
| return tenant.id, tbl.id | |
| def patch_v2(monkeypatch, result): | |
| monkeypatch.setattr(rec_pkg, "recommend_v2", lambda *a, **k: result) | |
| # ------------------------------------------------------------ /api/table-qr | |
| def test_qr_hop_le_tra_session_va_ban(client, db_mem): | |
| tenant_id, table_id = _seed_table() | |
| token = qr.make_table_token(tenant_id, table_id) | |
| r = client.get(f"/api/table-qr/{token}") | |
| assert r.status_code == 200 | |
| data = r.json() | |
| assert set(data) == {"session_id", "table", "expires_at"} | |
| assert data["table"] == {"id": table_id, "name": "Bàn 1"} | |
| # session phải nằm trong DB, không phải chỉ là chuỗi bịa trong response | |
| with db.session() as s: | |
| gs = s.get(db.GuestSession, data["session_id"]) | |
| assert gs is not None | |
| assert (gs.tenant_id, gs.table_id) == (tenant_id, table_id) | |
| def test_qr_ttl_24h(client, db_mem): | |
| tenant_id, table_id = _seed_table() | |
| r = client.get(f"/api/table-qr/{qr.make_table_token(tenant_id, table_id)}") | |
| with db.session() as s: | |
| gs = s.get(db.GuestSession, r.json()["session_id"]) | |
| ttl_h = (gs.expires_at - gs.created_at).total_seconds() / 3600 | |
| assert ttl_h == pytest.approx(24, abs=0.01) | |
| # expires_at trong response là ISO AWARE UTC — FE không phải đoán múi giờ | |
| exp = datetime.fromisoformat(r.json()["expires_at"]) | |
| assert exp.tzinfo is not None | |
| assert exp.utcoffset().total_seconds() == 0 | |
| now = datetime.now(timezone.utc) | |
| assert (exp - now).total_seconds() == pytest.approx(24 * 3600, abs=60) | |
| def test_qr_moi_lan_quet_mot_session_moi(client, db_mem): | |
| tenant_id, table_id = _seed_table() | |
| token = qr.make_table_token(tenant_id, table_id) | |
| s1 = client.get(f"/api/table-qr/{token}").json()["session_id"] | |
| s2 = client.get(f"/api/table-qr/{token}").json()["session_id"] | |
| assert s1 != s2 | |
| with db.session() as s: | |
| n = s.scalar(sa.select(sa.func.count()).select_from(db.GuestSession)) | |
| assert n == 2 | |
| def test_qr_token_rac_400_message_viet(client, db_mem): | |
| r = client.get("/api/table-qr/khong-phai-token") | |
| assert r.status_code == 400 | |
| detail = r.json()["detail"] | |
| assert isinstance(detail, str) | |
| assert "Mã QR không hợp lệ" in detail | |
| def test_qr_token_ky_secret_khac_400(client, db_mem, monkeypatch): | |
| """Đổi POOLCOACH_SECRET là mọi token cũ chết ngay (thu hồi QR) — secret | |
| phải được đọc MỖI request, không cache lúc import.""" | |
| tenant_id, table_id = _seed_table() | |
| token = qr.make_table_token(tenant_id, table_id) # ký secret dev | |
| monkeypatch.setenv("POOLCOACH_SECRET", "secret-moi-sau-khi-thu-hoi") | |
| assert client.get(f"/api/table-qr/{token}").status_code == 400 | |
| def test_qr_ban_khong_ton_tai_404(client, db_mem): | |
| tenant_id, _ = _seed_table() | |
| r = client.get(f"/api/table-qr/{qr.make_table_token(tenant_id, 999)}") | |
| assert r.status_code == 404 | |
| assert "không còn tồn tại" in r.json()["detail"] | |
| def test_qr_ban_cua_tenant_khac_404(client, db_mem): | |
| """Token ghép tenant A với bàn của tenant B phải 404 — không lộ bàn | |
| xuyên tenant dù chữ ký hợp lệ.""" | |
| _seed_table() # tenant 1, bàn 1 | |
| with db.session() as s: | |
| t2 = db.Tenant(name="tenant-khac") | |
| s.add(t2) | |
| s.flush() | |
| t2_id = t2.id | |
| r = client.get(f"/api/table-qr/{qr.make_table_token(t2_id, 1)}") | |
| assert r.status_code == 404 | |
| def test_qr_khong_db_503(client, no_db): | |
| tenant_id, table_id = 1, 1 | |
| r = client.get(f"/api/table-qr/{qr.make_table_token(tenant_id, table_id)}") | |
| assert r.status_code == 503 | |
| assert "chưa cấu hình cơ sở dữ liệu" in r.json()["detail"].lower() | |
| # ------------------------------------------- logging /api/recommend (Bước 3) | |
| def _rows(): | |
| with db.session() as s: | |
| return s.scalars(sa.select(db.Recommendation) | |
| .order_by(db.Recommendation.id)).all() | |
| def test_recommend_co_scan_id_edited_ghi_dung_row(client, db_mem, monkeypatch): | |
| patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)])) | |
| r = client.post("/api/recommend", json={ | |
| "balls": BALLS_FULL, "scan_id": "scan-123", "edited": True}) | |
| assert r.status_code == 200 | |
| rows = _rows() | |
| assert len(rows) == 1 | |
| row = rows[0] | |
| assert (row.scan_id, row.edited) == ("scan-123", True) | |
| assert row.engine_ver == "zoneplanner-v2" | |
| assert row.latency_ms >= 1 | |
| assert row.balls["cue"] == BALLS_FULL["cue"] | |
| assert row.shot["rank"] == 1 and row.shot["phi"] == 45.0 | |
| assert len(row.alternatives) == 1 and row.alternatives[0]["rank"] == 2 | |
| # quỹ đạo là dữ liệu vẽ, không vào log (sim tất định dựng lại được) | |
| assert "trajectories" not in row.shot | |
| def test_recommend_khong_scan_id_van_ghi_row_null(client, db_mem, monkeypatch): | |
| """Client cũ (không biết scan_id/edited tồn tại) vẫn 200 và vẫn được log | |
| — hai cột để NULL, không bịa default.""" | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", json={"balls": BALLS_FULL}) | |
| assert r.status_code == 200 | |
| rows = _rows() | |
| assert len(rows) == 1 | |
| assert (rows[0].scan_id, rows[0].edited) == (None, None) | |
| def test_recommend_het_duong_log_shot_null(client, db_mem, monkeypatch): | |
| patch_v2(monkeypatch, v2_result([], n_legal_pot=0)) | |
| r = client.post("/api/recommend", json={"balls": BALLS_FULL}) | |
| assert r.status_code == 200 | |
| rows = _rows() | |
| assert rows[0].shot is None and rows[0].alternatives == [] | |
| def test_recommend_response_khong_doi_mot_byte_khi_co_db(client, db_mem, | |
| monkeypatch): | |
| """Cùng request, DB bật hay tắt — body trả về phải BẰNG NHAU TỪNG BYTE. | |
| Đây là câu 'Response KHÔNG đổi một byte' của BRIEF, đo đúng nghĩa đen.""" | |
| patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)])) | |
| body = {"balls": BALLS_FULL, "scan_id": "scan-123", "edited": False} | |
| with_db = client.post("/api/recommend", json=body).content | |
| db.teardown() | |
| without_db = client.post("/api/recommend", json=body).content | |
| assert with_db == without_db | |
| def test_recommend_khong_db_van_200_khong_log(client, no_db, monkeypatch): | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", json={ | |
| "balls": BALLS_FULL, "scan_id": "scan-123", "edited": True}) | |
| assert r.status_code == 200 | |
| assert r.json()["shots"][0]["phi"] == 45.0 | |
| def test_recommend_db_hong_van_200(client, db_mem, monkeypatch, capfd): | |
| """Best-effort đúng nghĩa: DB nổ giữa chừng cũng chỉ được phép thành | |
| warning, response vẫn 200 nguyên vẹn.""" | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| def boom(): | |
| raise RuntimeError("DB chet giua chung") | |
| monkeypatch.setattr(db, "session", boom) | |
| r = client.post("/api/recommend", json={"balls": BALLS_FULL}) | |
| assert r.status_code == 200 | |
| assert "khong ghi duoc log recommend" in capfd.readouterr().out | |
| # ------------------------------- session_id trong recommend (bàn giao 12) | |
| # `session_id` là mở rộng thuần phần GHI LOG như scan_id/edited: session | |
| # sống → row recommendations mang tenant_id của quán; session rác/hết hạn | |
| # → bỏ qua + warning; response KHÔNG đổi một byte trong mọi nhánh. | |
| def _seed_session(hours=1.0): | |
| """Tenant + bàn + guest session còn `hours` giờ hạn (âm = đã hết hạn).""" | |
| tenant_id, table_id = _seed_table() | |
| with db.session() as s: | |
| gs = db.GuestSession(tenant_id=tenant_id, table_id=table_id, | |
| expires_at=db.utcnow() + timedelta(hours=hours)) | |
| s.add(gs) | |
| s.flush() | |
| return tenant_id, gs.id | |
| def test_recommend_session_that_dien_tenant_vao_row(client, db_mem, | |
| monkeypatch): | |
| tenant_id, session_id = _seed_session() | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", | |
| json={"balls": BALLS_FULL, "session_id": session_id}) | |
| assert r.status_code == 200 | |
| rows = _rows() | |
| assert len(rows) == 1 | |
| assert rows[0].tenant_id == tenant_id | |
| def test_recommend_khong_session_row_tenant_null(client, db_mem, monkeypatch): | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| assert client.post("/api/recommend", | |
| json={"balls": BALLS_FULL}).status_code == 200 | |
| assert _rows()[0].tenant_id is None | |
| def test_recommend_session_rac_van_200_row_nac_danh(client, db_mem, | |
| monkeypatch, capfd): | |
| """Session không tồn tại KHÔNG chặn request, KHÔNG mất row — chỉ mất | |
| tenant (NULL) và một warning cho dev.""" | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", | |
| json={"balls": BALLS_FULL, "session_id": "khong-co-that"}) | |
| assert r.status_code == 200 | |
| rows = _rows() | |
| assert len(rows) == 1 and rows[0].tenant_id is None | |
| assert "session_id la/het han" in capfd.readouterr().out | |
| def test_recommend_session_het_han_bo_qua_nhu_rac(client, db_mem, | |
| monkeypatch, capfd): | |
| """Hết hạn TTL 24h là hết tư cách gắn tenant — cùng nhánh với session | |
| rác, không phải lỗi 4xx (phiên chỉ thuộc phần log).""" | |
| _, session_id = _seed_session(hours=-1) | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", | |
| json={"balls": BALLS_FULL, "session_id": session_id}) | |
| assert r.status_code == 200 | |
| assert _rows()[0].tenant_id is None | |
| assert "session_id la/het han" in capfd.readouterr().out | |
| def test_recommend_response_khong_doi_mot_byte_vi_session(client, db_mem, | |
| monkeypatch): | |
| """Bất biến bàn giao 10 mở rộng cho session_id: cùng thế bàn, có phiên | |
| hay không — body trả về BẰNG NHAU TỪNG BYTE.""" | |
| _, session_id = _seed_session() | |
| patch_v2(monkeypatch, v2_result([make_shot(1), make_shot(2)])) | |
| with_s = client.post("/api/recommend", json={ | |
| "balls": BALLS_FULL, "session_id": session_id}).content | |
| without_s = client.post("/api/recommend", | |
| json={"balls": BALLS_FULL}).content | |
| assert with_s == without_s | |
| def test_recommend_khong_db_mang_session_van_200(client, no_db, monkeypatch): | |
| """Degraded (Space không DATABASE_URL): client vẫn gửi session_id — | |
| request phải 200 y hệt, không log, không lỗi.""" | |
| patch_v2(monkeypatch, v2_result([make_shot(1)])) | |
| r = client.post("/api/recommend", json={ | |
| "balls": BALLS_FULL, "session_id": "phien-nao-do"}) | |
| assert r.status_code == 200 | |
| assert r.json()["shots"][0]["phi"] == 45.0 | |