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 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-12 23:32:20 -05:00
parent be41add4e9
commit 00bdde9975
3 changed files with 145 additions and 15 deletions

View File

@@ -41,7 +41,7 @@
- [x] **UI-01**: Device page shows Configuration History section below Remote Access - [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-02**: Timeline displays change entries with component, summary, and timestamp
- [x] **UI-03**: Diff viewer shows unified diff with add/remove highlighting - [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 ### Observability
@@ -90,7 +90,7 @@
| UI-01 | Phase 7: Config History UI | Complete | | UI-01 | Phase 7: Config History UI | Complete |
| UI-02 | Phase 7: Config History UI | Complete | | UI-02 | Phase 7: Config History UI | Complete |
| UI-03 | Phase 8: Diff Viewer & Download | 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-01 | Phase 10: Audit & Observability | Pending |
| OBS-02 | Phase 10: Audit & Observability | Pending | | OBS-02 | Phase 10: Audit & Observability | Pending |

View File

@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v9.6 milestone: v9.6
milestone_name: milestone milestone_name: milestone
status: completed status: completed
stopped_at: Completed 08-01-PLAN.md stopped_at: Completed 08-02-PLAN.md
last_updated: "2026-03-13T04:21:58.035Z" last_updated: "2026-03-13T04:24:44.396Z"
last_activity: 2026-03-13 -- Completed 07-01 config history UI timeline component last_activity: 2026-03-13 -- Completed 08-02 snapshot download
progress: progress:
total_phases: 10 total_phases: 10
completed_phases: 7 completed_phases: 8
total_plans: 12 total_plans: 12
completed_plans: 11 completed_plans: 12
percent: 100 percent: 92
--- ---
# Project State # Project State
@@ -25,12 +25,12 @@ See: .planning/PROJECT.md (updated 2026-03-12)
## Current Position ## Current Position
Phase: 8 of 10 (Diff Viewer & Download) Phase: 8 of 10 (Diff Viewer & Download) -- COMPLETE
Plan: 1 of 2 in current phase Plan: 2 of 2 in current phase
Status: Plan 08-01 complete Status: Phase 08 complete
Last activity: 2026-03-13 -- Completed 08-01 diff viewer component Last activity: 2026-03-13 -- Completed 08-02 snapshot download
Progress: [█████████] 92% Progress: [█████████] 100%
## Performance Metrics ## Performance Metrics
@@ -59,6 +59,7 @@ Progress: [█████████░] 92%
| Phase 06 P02 | 2min | 2 tasks | 3 files | | Phase 06 P02 | 2min | 2 tasks | 3 files |
| Phase 07 P01 | 3min | 2 tasks | 3 files | | Phase 07 P01 | 3min | 2 tasks | 3 files |
| Phase 08 P01 | 1min | 2 tasks | 3 files | | Phase 08 P01 | 1min | 2 tasks | 3 files |
| Phase 08 P02 | 1min | 1 tasks | 3 files |
## Accumulated Context ## Accumulated Context
@@ -92,6 +93,7 @@ Recent decisions affecting current work:
- [Phase 07]: 60s refetchInterval polling for near-real-time config change visibility - [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]: DiffViewer rendered inline above timeline (not modal) for context preservation
- [Phase 08]: Line classification function for unified diff: +green, -red, @@blue, ---/+++ muted - [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 ### Pending Todos
@@ -103,6 +105,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-03-13T04:21:58.032Z Last session: 2026-03-13T04:24:44.393Z
Stopped at: Completed 08-01-PLAN.md Stopped at: Completed 08-02-PLAN.md
Resume file: None Resume file: None

View File

@@ -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()