From 00bdde997596fdd3009da53bb61dd8421d86d81a Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 12 Mar 2026 23:32:20 -0500 Subject: [PATCH] test(09-01): add failing tests for retention cleanup service - Test cleanup deletes expired snapshots - Test snapshots within retention window are kept - Test deleted count is returned - Test empty table handled gracefully Co-Authored-By: Claude Opus 4.6 --- .planning/REQUIREMENTS.md | 4 +- .planning/STATE.md | 28 +++--- backend/tests/test_retention_service.py | 128 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_retention_service.py diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 45741a4..3e12790 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -41,7 +41,7 @@ - [x] **UI-01**: Device page shows Configuration History section below Remote Access - [x] **UI-02**: Timeline displays change entries with component, summary, and timestamp - [x] **UI-03**: Diff viewer shows unified diff with add/remove highlighting -- [ ] **UI-04**: User can download snapshot as `router-{device_name}-{timestamp}.rsc` +- [x] **UI-04**: User can download snapshot as `router-{device_name}-{timestamp}.rsc` ### Observability @@ -90,7 +90,7 @@ | UI-01 | Phase 7: Config History UI | Complete | | UI-02 | Phase 7: Config History UI | Complete | | UI-03 | Phase 8: Diff Viewer & Download | Complete | -| UI-04 | Phase 8: Diff Viewer & Download | Pending | +| UI-04 | Phase 8: Diff Viewer & Download | Complete | | OBS-01 | Phase 10: Audit & Observability | Pending | | OBS-02 | Phase 10: Audit & Observability | Pending | diff --git a/.planning/STATE.md b/.planning/STATE.md index 8e4bd3e..beae16e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,15 +3,15 @@ gsd_state_version: 1.0 milestone: v9.6 milestone_name: milestone status: completed -stopped_at: Completed 08-01-PLAN.md -last_updated: "2026-03-13T04:21:58.035Z" -last_activity: 2026-03-13 -- Completed 07-01 config history UI timeline component +stopped_at: Completed 08-02-PLAN.md +last_updated: "2026-03-13T04:24:44.396Z" +last_activity: 2026-03-13 -- Completed 08-02 snapshot download progress: total_phases: 10 - completed_phases: 7 + completed_phases: 8 total_plans: 12 - completed_plans: 11 - percent: 100 + completed_plans: 12 + percent: 92 --- # Project State @@ -25,12 +25,12 @@ See: .planning/PROJECT.md (updated 2026-03-12) ## Current Position -Phase: 8 of 10 (Diff Viewer & Download) -Plan: 1 of 2 in current phase -Status: Plan 08-01 complete -Last activity: 2026-03-13 -- Completed 08-01 diff viewer component +Phase: 8 of 10 (Diff Viewer & Download) -- COMPLETE +Plan: 2 of 2 in current phase +Status: Phase 08 complete +Last activity: 2026-03-13 -- Completed 08-02 snapshot download -Progress: [█████████░] 92% +Progress: [██████████] 100% ## Performance Metrics @@ -59,6 +59,7 @@ Progress: [█████████░] 92% | Phase 06 P02 | 2min | 2 tasks | 3 files | | Phase 07 P01 | 3min | 2 tasks | 3 files | | Phase 08 P01 | 1min | 2 tasks | 3 files | +| Phase 08 P02 | 1min | 1 tasks | 3 files | ## Accumulated Context @@ -92,6 +93,7 @@ Recent decisions affecting current work: - [Phase 07]: 60s refetchInterval polling for near-real-time config change visibility - [Phase 08]: DiffViewer rendered inline above timeline (not modal) for context preservation - [Phase 08]: Line classification function for unified diff: +green, -red, @@blue, ---/+++ muted +- [Phase 08]: Blob URL download pattern consistent with existing exportMyData and auditLogsApi.exportCsv patterns ### Pending Todos @@ -103,6 +105,6 @@ None yet. ## Session Continuity -Last session: 2026-03-13T04:21:58.032Z -Stopped at: Completed 08-01-PLAN.md +Last session: 2026-03-13T04:24:44.393Z +Stopped at: Completed 08-02-PLAN.md Resume file: None diff --git a/backend/tests/test_retention_service.py b/backend/tests/test_retention_service.py new file mode 100644 index 0000000..8ec2f97 --- /dev/null +++ b/backend/tests/test_retention_service.py @@ -0,0 +1,128 @@ +"""Tests for retention cleanup service. + +Tests the cleanup_expired_snapshots function with mocked AdminAsyncSessionLocal +and mocked settings.CONFIG_RETENTION_DAYS. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.mark.asyncio +async def test_cleanup_deletes_expired_snapshots(): + """Test 1: cleanup_expired_snapshots deletes snapshots with collected_at older than retention_days.""" + from app.services.retention_service import cleanup_expired_snapshots + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.rowcount = 5 + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch( + "app.services.retention_service.AdminAsyncSessionLocal", + return_value=mock_ctx, + ), patch( + "app.services.retention_service.settings", + ) as mock_settings: + mock_settings.CONFIG_RETENTION_DAYS = 90 + count = await cleanup_expired_snapshots() + + # Should execute the DELETE query + mock_session.execute.assert_called_once() + # Verify DELETE uses make_interval with the configured days + sql_text = str(mock_session.execute.call_args[0][0].text) + assert "make_interval" in sql_text + assert "DELETE" in sql_text + assert "router_config_snapshots" in sql_text + # Should commit + mock_session.commit.assert_called_once() + # Should return the deleted count + assert count == 5 + + +@pytest.mark.asyncio +async def test_cleanup_keeps_snapshots_within_retention_window(): + """Test 2: cleanup_expired_snapshots keeps snapshots within the retention window.""" + from app.services.retention_service import cleanup_expired_snapshots + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.rowcount = 0 # No rows deleted means all within window + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch( + "app.services.retention_service.AdminAsyncSessionLocal", + return_value=mock_ctx, + ), patch( + "app.services.retention_service.settings", + ) as mock_settings: + mock_settings.CONFIG_RETENTION_DAYS = 90 + count = await cleanup_expired_snapshots() + + assert count == 0 + + +@pytest.mark.asyncio +async def test_cleanup_returns_deleted_count(): + """Test 3: cleanup_expired_snapshots returns count of deleted rows.""" + from app.services.retention_service import cleanup_expired_snapshots + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.rowcount = 42 + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch( + "app.services.retention_service.AdminAsyncSessionLocal", + return_value=mock_ctx, + ), patch( + "app.services.retention_service.settings", + ) as mock_settings: + mock_settings.CONFIG_RETENTION_DAYS = 30 + count = await cleanup_expired_snapshots() + + assert count == 42 + + +@pytest.mark.asyncio +async def test_cleanup_handles_empty_table(): + """Test 4: cleanup_expired_snapshots handles empty table (returns 0).""" + from app.services.retention_service import cleanup_expired_snapshots + + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.rowcount = 0 + mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.commit = AsyncMock() + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_session) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + with patch( + "app.services.retention_service.AdminAsyncSessionLocal", + return_value=mock_ctx, + ), patch( + "app.services.retention_service.settings", + ) as mock_settings: + mock_settings.CONFIG_RETENTION_DAYS = 90 + count = await cleanup_expired_snapshots() + + assert count == 0 + mock_session.execute.assert_called_once() + mock_session.commit.assert_called_once()