# -*- coding: utf-8 -*- """黄金契约回归:FastAPI 严格复现 Flask 时代的响应。 tests/golden/responses.json 是**删除 Flask 之前**从 Flask 应用捕获的快照 (状态码 + 响应体)。本测试让 FastAPI(根路径模式)跑同一批请求, 逐字段断言一致——这样删除 Flask 巨石后,行为回归仍有硬证据。 说明: - 会话 Cookie 在运行时用当前库的 login_seq 动态构造(快照不存 Cookie)。 - 请求为只读/确定性调用,不含实时上游数据(那些由 parity/单测覆盖)。 """ import hashlib import json import sqlite3 import time from pathlib import Path import pytest from fastapi.testclient import TestClient import auth_system from core import session as cs _GOLDEN = Path(__file__).parent / 'golden' / 'responses.json' SECRET = 'golden-secret' def _load_cases(): return json.loads(_GOLDEN.read_text(encoding='utf-8')) def _session_for(role): """按当前库中该角色用户的 login_seq 造一份合法会话 Cookie。""" conn = sqlite3.connect('data/site.db') conn.row_factory = sqlite3.Row try: where = "role = 'admin'" if role == 'admin' else "role != 'admin' AND status = 1" row = conn.execute( f'SELECT id, login_seq FROM users WHERE {where} AND login_seq IS NOT NULL LIMIT 1' ).fetchone() finally: conn.close() if row is None: return None return cs.dumps(SECRET, { 'site_uid': int(row['id']), 'site_uid_seq': int(row['login_seq']), 'site_uid_ts': int(time.time()), }) @pytest.fixture() def client(tmp_path, monkeypatch): # 站点库仍用生产 data/site.db(只读),密钥 patch 成固定值以便造 Cookie monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) monkeypatch.setattr(auth_system, 'get_secret_key', lambda: SECRET) from web.app import create_app return TestClient(create_app(api_prefix='')) # 响应对比前剔除的易变字段(时钟) _VOLATILE = {'timestamp'} def _strip(obj): if isinstance(obj, dict): return {k: _strip(v) for k, v in obj.items() if k not in _VOLATILE} if isinstance(obj, list): return [_strip(v) for v in obj] return obj def _request(client, method, path, payload, cookie): headers = {'Cookie': f'session={cookie}'} if cookie else {} if method == 'GET': return client.get(path, headers=headers) return client.post(path, json=payload or {}, headers=headers) def test_golden_contract(client): """逐例复现 Flask 快照。""" cases = _load_cases() failures = [] for c in cases: if c.get('html_len'): r = client.get(c['path']) if r.status_code != c['status'] or len(r.content) != c['html_len']: failures.append(f"{c['name']}: html {r.status_code}/{len(r.content)} " f"!= {c['status']}/{c['html_len']}") continue cookie = None if c['auth'] == 'admin': cookie = _session_for('admin') elif c['auth'] == 'user': cookie = _session_for('user') r = _request(client, c['method'], c['path'], c['payload'], cookie) body = r.json() if r.headers.get('content-type', '').startswith('application/json') else None if r.status_code != c['status']: failures.append(f"{c['name']}: status {r.status_code} != {c['status']}") elif _strip(body) != _strip(c['body']): failures.append(f"{c['name']}: body\n fastapi={body}\n golden ={c['body']}") assert not failures, '黄金契约回归失败:\n' + '\n'.join(failures) def test_index_html_stable(client): """首页渲染逐字节稳定(模板未改时)。""" cases = {c['name']: c for c in _load_cases()} c = cases['index'] r = client.get('/') assert hashlib.sha256(r.content).hexdigest() == c['html_sha256']