"""
Tests for Splitwise soft delete handling.
These tests verify that:
1. get_expenses() filters out soft-deleted expenses
2. sync_expenses() removes deleted entries from cache
3. delete → sync cycle properly removes entries
Bug Reference: BETA_TEST_JOURNAL.md - "P0 Bug: Splitwise Soft Delete Not Handled"
Date: December 14, 2025
"""
import pytest
import sqlite3
import tempfile
import os
from unittest.mock import Mock, patch
# Add parent directory to path for imports
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from splitwise_mcp.cache import CacheManager
from splitwise_mcp.client import SplitwiseClient
class TestSoftDeleteFiltering:
"""Tests for filtering soft-deleted expenses from API responses."""
def test_get_expenses_filters_deleted(self):
"""get_expenses should not return soft-deleted expenses."""
# Mock API response with both active and deleted expenses
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"expenses": [
{"id": 1, "description": "Active expense", "deleted_at": None, "cost": "10.00"},
{"id": 2, "description": "Deleted expense", "deleted_at": "2025-12-13T03:23:49Z", "cost": "20.00"},
{"id": 3, "description": "Another active", "deleted_at": None, "cost": "30.00"},
]
}
config = {
"api_key": "test_key",
"group_id": "123",
"payer_id": "1",
"partner_id": "2"
}
client = SplitwiseClient(config)
with patch('requests.get', return_value=mock_response):
expenses = client.get_expenses()
# Should only return 2 active expenses
assert len(expenses) == 2
assert all(e.get("deleted_at") is None for e in expenses)
assert expenses[0]["id"] == 1
assert expenses[1]["id"] == 3
def test_get_expenses_for_sync_includes_deleted(self):
"""get_expenses_for_sync should return ALL expenses including deleted."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"expenses": [
{"id": 1, "description": "Active expense", "deleted_at": None, "cost": "10.00"},
{"id": 2, "description": "Deleted expense", "deleted_at": "2025-12-13T03:23:49Z", "cost": "20.00"},
]
}
config = {
"api_key": "test_key",
"group_id": "123",
"payer_id": "1",
"partner_id": "2"
}
client = SplitwiseClient(config)
with patch('requests.get', return_value=mock_response):
expenses = client.get_expenses_for_sync()
# Should return ALL 2 expenses
assert len(expenses) == 2
class TestCacheSyncDeletedExpenses:
"""Tests for sync_expenses handling of soft-deleted entries."""
@pytest.fixture
def temp_db(self):
"""Create a temporary database for testing."""
fd, path = tempfile.mkstemp(suffix='.db')
os.close(fd)
yield path
os.unlink(path)
def test_sync_removes_deleted_from_cache(self, temp_db):
"""sync_expenses should remove soft-deleted expenses from cache."""
cache = CacheManager(temp_db)
# Pre-populate cache with an expense that will be "deleted"
cache.sync_expenses([
{
"id": 1,
"description": "Will be deleted",
"cost": "10.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": None # Initially active
}
])
# Verify expense is in cache
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 1
# Now sync with the expense marked as deleted
cache.sync_expenses([
{
"id": 1,
"description": "Will be deleted",
"cost": "10.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": "2025-12-13T03:23:49Z" # Now deleted
}
])
# Verify expense is removed from cache
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 0
def test_sync_keeps_active_expenses(self, temp_db):
"""sync_expenses should keep active expenses in cache."""
cache = CacheManager(temp_db)
# Sync with mixed active and deleted expenses
cache.sync_expenses([
{
"id": 1,
"description": "Active expense",
"cost": "10.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": None
},
{
"id": 2,
"description": "Deleted expense",
"cost": "20.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": "2025-12-13T03:23:49Z"
}
])
# Should only have 1 expense in cache
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 1
assert expenses[0].expense_id == "1"
class TestDeleteSyncCycle:
"""Tests for the full delete → sync cycle."""
@pytest.fixture
def temp_db(self):
"""Create a temporary database for testing."""
fd, path = tempfile.mkstemp(suffix='.db')
os.close(fd)
yield path
os.unlink(path)
def test_delete_then_sync_removes_from_cache(self, temp_db):
"""After deleting expense, sync should remove it from cache."""
cache = CacheManager(temp_db)
# Add expense to cache
cache.sync_expenses([
{
"id": 100,
"description": "Test expense",
"cost": "50.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": None
}
])
# Verify it's in cache
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 1
# Delete from cache directly (simulating local delete)
cache.delete_expense("100")
# Verify it's removed
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 0
# Now sync with the expense marked as deleted in API
# This ensures sync doesn't re-add it
cache.sync_expenses([
{
"id": 100,
"description": "Test expense",
"cost": "50.00",
"date": "2025-12-11",
"created_at": "2025-12-11T00:00:00Z",
"group_id": "123",
"deleted_at": "2025-12-14T00:00:00Z" # Marked deleted
}
])
# Should still be empty
expenses = cache.get_expenses_for_timeframe("2025-12-01", "2025-12-31")
assert len(expenses) == 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])