Files

203 lines
6.9 KiB
Python

# -*- 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()