"""Raw sample pruning tests. Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and day aggregates are retained indefinitely. Issue #74: Boundary anchors required for successor evidence are retained. """ import sqlite3 import sys from datetime import datetime, timezone, timedelta from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from fenris.store import init_store from fenris.pruning import prune_old_samples @pytest.fixture def store_conn(tmp_path: Path): db_path = tmp_path / "test.db" conn = init_store(db_path) yield conn conn.close() def _insert_sample(conn, ts_iso, device="/dev/nvme0"): conn.execute( "INSERT OR IGNORE INTO controller_segments " "(id, opened_at, identity_key) VALUES (1, '2026-01-01T00:00:00+00:00', 'test')" ) conn.execute( "INSERT INTO samples (ts, device, data_units_written, data_units_read, " " bytes_written, bytes_read, percentage_used, segment_id, local_tz) " "VALUES (?, ?, 0, 0, 0, 0, 0, 1, 'UTC')", (ts_iso, device), ) conn.commit() class TestPruneOldSamples: """Spec §3.4: Raw samples pruned to 14 days.""" def test_keeps_recent_samples(self, store_conn): now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) # Insert a sample 1 day ago ts = (now - timedelta(days=1)).isoformat() _insert_sample(store_conn, ts) pruned = prune_old_samples(store_conn, now, retention_days=14) assert pruned == 0 cursor = store_conn.execute("SELECT COUNT(*) FROM samples") assert cursor.fetchone()[0] == 1 def test_keeps_old_samples_without_replacement_evidence(self, store_conn): now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) # Old samples stay until UTC and local-day evidence replace them. for days_ago in [10, 14, 15]: ts = (now - timedelta(days=days_ago)).isoformat() _insert_sample(store_conn, ts) pruned = prune_old_samples(store_conn, now, retention_days=14) assert pruned == 0 cursor = store_conn.execute("SELECT COUNT(*) FROM samples") assert cursor.fetchone()[0] == 3 def test_keeps_many_old_samples_without_replacement_evidence(self, store_conn): now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) for days_ago in range(1, 30): ts = (now - timedelta(days=days_ago)).isoformat() _insert_sample(store_conn, ts) pruned = prune_old_samples(store_conn, now, retention_days=14) assert pruned == 0 cursor = store_conn.execute("SELECT COUNT(*) FROM samples") assert cursor.fetchone()[0] == 29 def test_empty_store_no_error(self, store_conn): now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) pruned = prune_old_samples(store_conn, now, retention_days=14) assert pruned == 0 def test_hour_observations_not_pruned(self, store_conn): """Hour observations are retained indefinitely.""" now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) # Insert an old sample and a recent sample # The old sample is a boundary anchor (needed for derivation) _insert_sample(store_conn, (now - timedelta(days=20)).isoformat()) _insert_sample(store_conn, (now - timedelta(days=1)).isoformat()) # Insert an old hour observation store_conn.execute( "INSERT INTO hour_observations (hour, active_seconds, sample_count) " "VALUES (?, 3600, 1)", ((now - timedelta(days=20)).replace(hour=0, minute=0, second=0).isoformat(),), ) store_conn.commit() prune_old_samples(store_conn, now, retention_days=14) # Old sample is retained as boundary anchor (needed for derivation) cursor = store_conn.execute("SELECT COUNT(*) FROM samples") assert cursor.fetchone()[0] == 2 # Hour observation retained cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations") assert cursor.fetchone()[0] == 1 def test_boundary_anchor_retained(self, store_conn): """Boundary anchors required for derivation are retained.""" now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) # Old sample before boundary (2026-09-14T23:55:00) # is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00) _insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000) # Sample after boundary (2026-09-16T12:30:00) # is 13 days and 23.5 hours old (within retention) _insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000) # Run pruning pruned = prune_old_samples(store_conn, now, retention_days=14) # The boundary anchor should be retained cursor = store_conn.execute( "SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'" ) assert cursor.fetchone()[0] == 1 def test_hour_rows_alone_do_not_replace_local_day_evidence(self, store_conn): """An hour row alone cannot authorize source-sample deletion.""" now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc) # Old sample with derived interval _insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000) _insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000) # Hour observation exists for the interval store_conn.execute( "INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) " "VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)", ) store_conn.commit() # Run pruning pruned = prune_old_samples(store_conn, now, retention_days=14) # Keep the source samples until local-day replacement evidence exists. cursor = store_conn.execute( "SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'" ) assert cursor.fetchone()[0] == 1