import sqlite3 import tempfile import unittest from pathlib import Path from unittest.mock import patch from app.stats_store import StatsStore, business_date class StatsStoreDurationRepairTests(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.database_path = Path(self.temp_dir.name) / "statistics.sqlite3" self.connection = sqlite3.connect(self.database_path) StatsStore._migrate(self.connection) self.store = StatsStore(self.database_path) def tearDown(self): self.connection.close() self.temp_dir.cleanup() def _insert_bilibili(self, connection_id, started, ended=None, reason=None): self.connection.execute( "INSERT INTO bilibili_connections (" "connection_id, connected_at_utc, disconnected_at_utc, business_date, status, disconnect_reason" ") VALUES (?, ?, ?, ?, ?, ?)", (connection_id, started, ended, business_date(started), "failed" if ended else "connected", reason), ) StatsStore._upsert_live_session(self.connection, { "session_id": connection_id, "kind": "bilibili_connection", "started_at_utc": started, "ended_at_utc": ended, "status": "failed" if ended else "connected", "source": "bilibili_connections", }) def test_daily_query_merges_overlapping_lifecycles(self): self._insert_bilibili("b1", "2026-07-16T00:00:00Z", "2026-07-16T02:00:00Z") self._insert_bilibili("b2", "2026-07-16T01:00:00Z", "2026-07-16T03:00:00Z") self.connection.execute( "INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) " "VALUES ('g1', '2026-07-16T00:00:00Z', '2026-07-16T02:00:00Z', '2026-07-16', 'completed')" ) self.connection.execute( "INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) " "VALUES ('g2', '2026-07-16T01:00:00Z', '2026-07-16T04:00:00Z', '2026-07-16', 'completed')" ) self.connection.commit() row = self.store._query_daily_sync("2026-07-16", "2026-07-16")[0] self.assertEqual(row["bilibili_connection_duration_ms"], 3 * 60 * 60 * 1000) self.assertEqual(row["group_run_total_duration_ms"], 4 * 60 * 60 * 1000) def test_daily_query_splits_at_beijing_0400_boundary(self): self.connection.execute( "INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) " "VALUES ('g1', '2026-07-16T19:30:00Z', '2026-07-16T20:30:00Z', '2026-07-16', 'completed')" ) self.connection.commit() rows = self.store._query_daily_sync("2026-07-16", "2026-07-17") by_day = {row["business_date"]: row for row in rows} self.assertEqual(by_day["2026-07-16"]["group_run_total_duration_ms"], 30 * 60 * 1000) self.assertEqual(by_day["2026-07-17"]["group_run_total_duration_ms"], 30 * 60 * 1000) def test_reconciliation_ends_stale_connection_at_next_start(self): self._insert_bilibili("b1", "2026-07-16T00:00:00Z") self._insert_bilibili("b2", "2026-07-16T00:05:00Z") self.connection.commit() with patch.object(StatsStore, "_pid_is_running", return_value=False): StatsStore._reconcile_stale_lifecycles(self.connection) first = self.connection.execute( "SELECT disconnected_at_utc, disconnect_reason FROM bilibili_connections WHERE connection_id='b1'" ).fetchone() self.assertEqual(first, ("2026-07-16T00:05:00Z", "startup_reconciliation")) def test_historical_repair_is_idempotent_and_rebuilds_daily_duration(self): self._insert_bilibili( "b1", "2026-07-16T00:00:00Z", "2026-07-17T00:00:00Z", "startup_reconciliation" ) self._insert_bilibili("b2", "2026-07-16T00:05:00Z", "2026-07-16T00:10:00Z") self.connection.commit() first = StatsStore._repair_startup_reconciliation_history(self.connection) second = StatsStore._repair_startup_reconciliation_history(self.connection) ended = self.connection.execute( "SELECT disconnected_at_utc FROM bilibili_connections WHERE connection_id='b1'" ).fetchone()[0] duration = self.connection.execute( "SELECT SUM(duration_ms) FROM live_session_daily_durations WHERE session_id='b1'" ).fetchone()[0] self.assertEqual(first["bilibili_connections"], 1) self.assertEqual(second["bilibili_connections"], 0) self.assertEqual(ended, "2026-07-16T00:05:00Z") self.assertEqual(duration, 5 * 60 * 1000) if __name__ == "__main__": unittest.main()