# -*- coding: utf-8 -*- """访问统计内存缓冲与批量落库测试(离线)。 运行:uv run python tests/test_visits.py """ import os import sys import tempfile from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.stdout.reconfigure(encoding="utf-8") from reaction_board.database import Database # noqa: E402 from reaction_board.timeutil import day_bounds # noqa: E402 from reaction_board.web.visits import VisitCollector # noqa: E402 DAY_A = "2026-10-30" DAY_B = "2026-10-31" def ts(day: str, hour: int = 12) -> int: return int(day_bounds(day)[0]) + hour * 3600 class _StubDB: """只让 transaction/write 抛错,用于验证 flush 失败时的并回行为。""" def transaction(self, fn): raise RuntimeError("db down") def write(self, fn): raise RuntimeError("db down") def _open(tmp: str) -> Database: db = Database(Path(tmp) / "app.db") db.initialize() return db def _rows(db: Database): return db.read(lambda conn: [ dict(row) for row in conn.execute( "SELECT stat_date, ip, path, hits, first_at, last_at FROM visit " "ORDER BY stat_date, ip, path" ).fetchall() ]) def test_record_and_aggregate(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0) assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True assert collector.record("1.1.1.1", "/videos", now=ts(DAY_A)) is True assert collector.record("2.2.2.2", "/home", now=ts(DAY_A)) is True # 跨日单独成键 assert collector.record("1.1.1.1", "/home", now=ts(DAY_B)) is True assert collector.pending_keys == 4 rows = {(r["stat_date"], r["ip"], r["path"]): r for r in collector.pending_rows()} home_a = rows[(DAY_A, "1.1.1.1", "/home")] assert home_a["hits"] == 2 assert home_a["first_at"] == ts(DAY_A) assert home_a["last_at"] == ts(DAY_A) assert rows[(DAY_B, "1.1.1.1", "/home")]["hits"] == 1 # 空值不计数 assert collector.record("", "/home", now=ts(DAY_A)) is False assert collector.record("1.1.1.1", "", now=ts(DAY_A)) is False finally: db.close() def test_flush_writes_and_is_idempotent(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0) assert collector.flush() == 0 # 空缓冲 no-op collector.record("1.1.1.1", "/home", now=ts(DAY_A)) collector.record("1.1.1.1", "/home", now=ts(DAY_A)) assert collector.flush() == 1 assert collector.pending_keys == 0 rows = _rows(db) assert len(rows) == 1 assert rows[0]["hits"] == 2 assert rows[0]["ip"] == "1.1.1.1" assert rows[0]["path"] == "/home" # 再次 flush 不重复累加 assert collector.flush() == 0 assert _rows(db)[0]["hits"] == 2 # 与已有行累加(先在库中放一条 hits=5 的记录) db.write(lambda conn: conn.execute( "INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) " "VALUES (?, '3.3.3.3', '/home', 5, ?, ?)", (DAY_A, ts(DAY_A), ts(DAY_A)), )) collector.record("3.3.3.3", "/home", now=ts(DAY_A, hour=13)) assert collector.flush() == 1 row = next(r for r in _rows(db) if r["ip"] == "3.3.3.3") assert row["hits"] == 6 assert row["first_at"] == ts(DAY_A) assert row["last_at"] == ts(DAY_A, hour=13) finally: db.close() def test_flush_failure_merges_back(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0) collector.record("1.1.1.1", "/home", now=ts(DAY_A)) collector.record("1.1.1.1", "/home", now=ts(DAY_A)) original = collector._db collector._db = _StubDB() try: assert collector.flush() == 0 assert collector.pending_keys == 1 assert collector.pending_rows()[0]["hits"] == 2 finally: collector._db = original # 恢复后能正常落库且计数不丢、不重复 assert collector.flush() == 1 assert _rows(db)[0]["hits"] == 2 finally: db.close() def test_max_hits_per_key_cap(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0, max_hits_per_key=2) assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is False assert collector.pending_rows()[0]["hits"] == 2 # 不同键各自独立 assert collector.record("1.1.1.1", "/videos", now=ts(DAY_A)) is True finally: db.close() def test_prune_old_rows(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0, keep_days=7) # 30 天前的老记录 old_day = "2026-09-30" db.write(lambda conn: conn.execute( "INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) " "VALUES (?, '9.9.9.9', '/home', 1, 1, 1)", (old_day,), )) collector.record("1.1.1.1", "/home", now=ts(DAY_A)) assert collector.flush() == 1 remaining = {row["stat_date"] for row in _rows(db)} assert old_day not in remaining assert DAY_A in remaining finally: db.close() def test_stop_flushes_pending(): with tempfile.TemporaryDirectory() as tmp: db = _open(tmp) try: collector = VisitCollector(db, flush_seconds=0) collector.start() # flush_seconds=0 → 不启动线程 assert collector._thread is None collector.record("1.1.1.1", "/home", now=ts(DAY_A)) collector.stop() assert collector.pending_keys == 0 assert _rows(db)[0]["hits"] == 1 finally: db.close() def main(): test_record_and_aggregate() test_flush_writes_and_is_idempotent() test_flush_failure_merges_back() test_max_hits_per_key_cap() test_prune_old_rows() test_stop_flushes_pending() print("OK: 访问统计内存缓冲与批量落库测试全部通过") if __name__ == "__main__": main()