"""Tests for data retention and lifecycle controls. Validates retention policy resolution, expired object detection, cleanup logic, and DB record cleanup. Requirements: N3 """ from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock from services.shared.config import RetentionConfig from services.shared.retention import ( RetentionPolicy, cleanup_bucket, cutoff_date, default_retention_days, delete_expired_objects, list_expired_objects, merge_policies, resolve_policies, ) class TestDefaultRetentionDays: def test_known_buckets(self): config = RetentionConfig() assert default_retention_days("stonks-raw-market", config) == 90 assert default_retention_days("stonks-raw-news", config) == 180 assert default_retention_days("stonks-raw-filings", config) == 365 assert default_retention_days("stonks-lakehouse", config) == 730 assert default_retention_days("stonks-audit", config) == 730 def test_unknown_bucket_defaults_to_365(self): config = RetentionConfig() assert default_retention_days("unknown-bucket", config) == 365 def test_custom_config_values(self): config = RetentionConfig(raw_market_days=30, audit_days=1000) assert default_retention_days("stonks-raw-market", config) == 30 assert default_retention_days("stonks-audit", config) == 1000 class TestResolvePolicies: def test_returns_policy_per_bucket(self): config = RetentionConfig() policies = resolve_policies(config) bucket_names = [p.bucket_name for p in policies] assert "stonks-raw-market" in bucket_names assert "stonks-lakehouse" in bucket_names assert len(policies) == 8 def test_uses_config_values(self): config = RetentionConfig(raw_news_days=60) policies = resolve_policies(config) news_policy = next(p for p in policies if p.bucket_name == "stonks-raw-news") assert news_policy.retention_days == 60 class TestMergePolicies: def test_db_overrides_config(self): config_policies = [ RetentionPolicy("stonks-raw-market", 90), RetentionPolicy("stonks-raw-news", 180), ] db_policies = { "stonks-raw-market": RetentionPolicy("stonks-raw-market", 30), } merged = merge_policies(config_policies, db_policies) market = next(p for p in merged if p.bucket_name == "stonks-raw-market") news = next(p for p in merged if p.bucket_name == "stonks-raw-news") assert market.retention_days == 30 # DB override assert news.retention_days == 180 # config default def test_empty_db_uses_all_config(self): config_policies = [RetentionPolicy("stonks-audit", 730)] merged = merge_policies(config_policies, {}) assert len(merged) == 1 assert merged[0].retention_days == 730 class TestCutoffDate: def test_calculates_cutoff(self): now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) cutoff = cutoff_date(90, now) expected = now - timedelta(days=90) assert cutoff == expected def test_uses_current_time_when_none(self): cutoff = cutoff_date(30) assert cutoff < datetime.now(timezone.utc) class TestListExpiredObjects: def test_finds_expired_objects(self): client = MagicMock() now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) old_obj = MagicMock() old_obj.object_name = "old/file.json" old_obj.last_modified = now - timedelta(days=100) new_obj = MagicMock() new_obj.object_name = "new/file.json" new_obj.last_modified = now - timedelta(days=10) client.list_objects.return_value = [old_obj, new_obj] expired = list_expired_objects(client, "stonks-raw-market", 90, now=now) assert expired == ["old/file.json"] def test_respects_batch_size(self): client = MagicMock() now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) objects = [] for i in range(10): obj = MagicMock() obj.object_name = f"file_{i}.json" obj.last_modified = now - timedelta(days=200) objects.append(obj) client.list_objects.return_value = objects expired = list_expired_objects(client, "test-bucket", 90, batch_size=3, now=now) assert len(expired) == 3 def test_handles_list_error(self): client = MagicMock() client.list_objects.side_effect = Exception("connection error") expired = list_expired_objects(client, "test-bucket", 90) assert expired == [] class TestDeleteExpiredObjects: def test_deletes_all(self): client = MagicMock() count = delete_expired_objects(client, "test-bucket", ["a.json", "b.json"]) assert count == 2 assert client.remove_object.call_count == 2 def test_handles_partial_failure(self): client = MagicMock() client.remove_object.side_effect = [None, Exception("fail"), None] count = delete_expired_objects(client, "test-bucket", ["a", "b", "c"]) assert count == 2 class TestCleanupBucket: def test_full_cleanup_flow(self): client = MagicMock() now = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc) old_obj = MagicMock() old_obj.object_name = "expired.json" old_obj.last_modified = now - timedelta(days=200) client.list_objects.return_value = [old_obj] policy = RetentionPolicy("stonks-raw-market", 90) result = cleanup_bucket(client, policy, now=now) assert result.bucket_name == "stonks-raw-market" assert result.objects_scanned == 1 assert result.objects_deleted == 1 def test_no_expired_objects(self): client = MagicMock() client.list_objects.return_value = [] policy = RetentionPolicy("stonks-raw-news", 180) result = cleanup_bucket(client, policy) assert result.objects_scanned == 0 assert result.objects_deleted == 0