"""Local-day activity derivation tests (issue #90, ADR 0010). Verifies that local-day summaries are correctly derived from UTC hour observations, that the schema migration creates the local_days table, that timezone boundaries are preserved across system-timezone changes, and that midnight-spanning intervals are retained as shared evidence without double counting. Seams: - write side: collector.run_collection() → local_day derivation - read side: local_day.query_current_local_day(), query_local_day_summary() - persistence: store.init_store() → local_days table, migration 2→3 """ import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict import pytest import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from fenris.store import init_store, SCHEMA_VERSION from fenris.local_day import ( derive_local_day_summary, persist_local_day, query_local_day_summary, query_current_local_day, LocalDaySummary, ) from fenris.monitoring_periods import ensure_period_open, close_period # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]: return { "json_format_version": [1, 0], "smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"}, "nvme_smart_health_information_log": { "critical_warning": 0, "temperature": 35, "available_spare": 100, "available_spare_threshold": 10, "percentage_used": 5, "data_units_written": duw, "data_units_read": dur, "power_on_hours": 8765, "power_cycles": 1234, "unsafe_shutdowns": 5, "media_errors": 0, "num_err_log_entries": 0, }, "user_capacity": {"bytes": 1024000000000, "units": "bytes"}, "model_name": "Samsung SSD 970 EVO Plus 1TB", "serial_number": "S4EWNX0N123456", "firmware_version": "2B2QEXM7", } @pytest.fixture def sysfs_tree(tmp_path: Path) -> Path: ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0" ctrl_dir.mkdir(parents=True) (ctrl_dir / "subsysnqn").write_text( "nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n" ) (ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n") (ctrl_dir / "serial").write_text("S4EWNX0N123456\n") (ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n") transport_dir = ctrl_dir / "transport" transport_dir.mkdir() (transport_dir / "address").write_text("0000:03:00.0") (transport_dir / "trstring").write_text("pcie") return tmp_path class _Clock: def __init__(self, initial: datetime): self.now = initial def utcnow(self): return self.now def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1, active=3600, idle=0, powered_off=0, unknown=0): """Insert a UTC hour observation.""" known = active + idle + powered_off coverage = known / 3600.0 conn.execute( "INSERT INTO hour_observations " "(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, " " bytes_written_delta, bytes_read_delta, sample_count, coverage) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (hour_iso, active, idle, powered_off, unknown, bw, br, sample_count, coverage), ) conn.commit() # --------------------------------------------------------------------------- # Schema migration: local_days table is created # --------------------------------------------------------------------------- class TestSchemaMigration: """Store version 3 includes the local_days table.""" def test_fresh_store_has_local_days(self, tmp_path): conn = init_store(tmp_path / "obs.db") tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} assert "local_days" in tables conn.close() def test_migration_from_v2_creates_local_days(self, tmp_path): db = tmp_path / "obs.db" conn = sqlite3.connect(str(db)) conn.execute("PRAGMA user_version=2") conn.commit() conn.close() conn = init_store(db) tables = {row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} assert "local_days" in tables assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION conn.close() def test_migration_is_idempotent(self, tmp_path): db = tmp_path / "obs.db" conn = init_store(db) conn.close() # Reopening should not fail conn = init_store(db) count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0] assert count == 0 conn.close() # --------------------------------------------------------------------------- # Local-day derivation from UTC hours # --------------------------------------------------------------------------- class TestDeriveLocalDay: """Derive local-day summaries from UTC hour observations.""" def test_utc_plus_zero(self, tmp_path): """UTC timezone: local day boundaries = UTC day boundaries.""" conn = init_store(tmp_path / "obs.db") ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc)) _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None assert summary.local_date == "2026-09-01" assert summary.tz_name == "UTC" assert summary.tz_offset == "+00:00" assert summary.bytes_written == 300 assert summary.bytes_read == 130 assert summary.complete is False # not all 24 hours covered conn.close() def test_utc_plus_five_thirty(self, tmp_path): """Asia/Kolkata half-hour offset: UTC boundaries are offset.""" conn = init_store(tmp_path / "obs.db") # Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30) # Insert UTC hours that belong to this local day _insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local summary = derive_local_day_summary(conn, "Asia/Kolkata", clock) assert summary is not None assert summary.local_date == "2026-09-01" assert summary.tz_name == "Asia/Kolkata" assert summary.tz_offset == "+05:30" assert summary.bytes_written == 300 assert summary.bytes_read == 130 conn.close() def test_midnight_spanning_hour_included(self, tmp_path): """UTC hour straddling local midnight is included in the local day.""" conn = init_store(tmp_path / "obs.db") # For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC # UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight _insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50) _insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "Asia/Kolkata", clock) assert summary is not None # The midnight-spanning hour (18:00) is included assert summary.bytes_written == 300 assert summary.bytes_read == 130 conn.close() def test_no_hours_returns_none(self, tmp_path): conn = init_store(tmp_path / "obs.db") clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is None conn.close() # --------------------------------------------------------------------------- # Persistence and querying # --------------------------------------------------------------------------- class TestLocalDayPersistence: """Local-day summaries persist and can be queried.""" def test_persist_and_query(self, tmp_path): conn = init_store(tmp_path / "obs.db") summary = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=300, bytes_read=130, coverage=0.5, sample_count=4, complete=False, ) created = persist_local_day(conn, summary) assert created is True # Upsert again — should update, not create created2 = persist_local_day(conn, summary) assert created2 is False result = query_local_day_summary(conn, "2026-09-01") assert result is not None assert result["bytes_written"] == 300 assert result["tz_name"] == "UTC" conn.close() def test_query_nonexistent_returns_none(self, tmp_path): conn = init_store(tmp_path / "obs.db") assert query_local_day_summary(conn, "2026-01-01") is None conn.close() def test_query_current_local_day(self, tmp_path): conn = init_store(tmp_path / "obs.db") summary = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=500, bytes_read=200, coverage=0.8, sample_count=10, complete=True, ) persist_local_day(conn, summary) clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc) result = query_current_local_day(conn, clock, "UTC") assert result is not None assert result["bytes_written"] == 500 conn.close() # --------------------------------------------------------------------------- # Timezone change handling # --------------------------------------------------------------------------- class TestTimezoneChange: """Historical summaries retain their recorded timezone.""" def test_old_tz_preserved_after_change(self, tmp_path): conn = init_store(tmp_path / "obs.db") # Old summary in UTC old = LocalDaySummary( local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00", utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=100, bytes_read=50, coverage=0.5, sample_count=5, complete=False, ) persist_local_day(conn, old) # System timezone changes to Asia/Kolkata # Old summary retains UTC result = query_local_day_summary(conn, "2026-09-01") assert result is not None assert result["tz_name"] == "UTC" assert result["tz_offset"] == "+00:00" conn.close() def test_different_tz_same_date_stored_separately(self, tmp_path): conn = init_store(tmp_path / "obs.db") # Two summaries for same date in different timezones for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]: summary = LocalDaySummary( local_date="2026-09-01", tz_name=tz, tz_offset=offset, utc_start="2026-09-01T00:00:00+00:00", utc_end="2026-09-02T00:00:00+00:00", bytes_written=100, bytes_read=50, coverage=0.5, sample_count=5, complete=False, ) persist_local_day(conn, summary) # Both exist independently — query by specific timezone utc_row = conn.execute( "SELECT tz_name, bytes_written FROM local_days " "WHERE local_date = '2026-09-01' AND tz_name = 'UTC'" ).fetchone() assert utc_row is not None assert utc_row[0] == "UTC" assert utc_row[1] == 100 in_row = conn.execute( "SELECT tz_name, bytes_written FROM local_days " "WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'" ).fetchone() assert in_row is not None assert in_row[0] == "Asia/Kolkata" conn.close() # --------------------------------------------------------------------------- # Coverage and completeness # --------------------------------------------------------------------------- class TestLocalDayCompleteness: """Complete days vs partial current day.""" def test_incomplete_when_missing_hours(self, tmp_path): conn = init_store(tmp_path / "obs.db") _insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50) clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None assert summary.complete is False assert summary.coverage < 1.0 conn.close() def test_coverage_ratio(self, tmp_path): conn = init_store(tmp_path / "obs.db") # 2 hours of known data out of 24 total UTC hours in a UTC day _insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600) _insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600) clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) summary = derive_local_day_summary(conn, "UTC", clock) assert summary is not None # 7200 known seconds / 86400 total = ~0.083 assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01) conn.close() # --------------------------------------------------------------------------- # Collector integration # --------------------------------------------------------------------------- class TestCollectorIntegration: """Collector derives local-day summaries during collection.""" def test_collection_creates_local_day(self, tmp_path, sysfs_tree): from fenris.collector import run_collection t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc) store = str(tmp_path / "obs.db") cfg = {"device": "/dev/nvme0", "store_path": store} sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0" run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1)) run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)) conn = sqlite3.connect(store) count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0] assert count >= 1 # Verify the local day has the expected write/read data row = conn.execute( "SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1" ).fetchone() assert row is not None assert row[0] == 50 * 512000 # writes assert row[1] == 30 * 512000 # reads conn.close() # --------------------------------------------------------------------------- # DST handling (23/25-hour days) # --------------------------------------------------------------------------- class TestDSTHandling: """Local days with non-24-hour durations from DST transitions.""" def test_short_day_23_hours(self, tmp_path): """Spring forward: local day is 23 hours.""" conn = init_store(tmp_path / "obs.db") # Simulate a 23-hour day in a timezone with DST # For simplicity, just verify the summary records the correct UTC range clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc) # US/Eastern springs forward on 2026-03-08 # Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d) _insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600) _insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600) summary = derive_local_day_summary(conn, "US/Eastern", clock) assert summary is not None assert summary.local_date == "2026-03-08" # UTC range should be approximately 23 hours utc_start = datetime.fromisoformat(summary.utc_start) utc_end = datetime.fromisoformat(summary.utc_end) duration = (utc_end - utc_start).total_seconds() assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance conn.close()