Files
DDNS/tests/test_cache.py
06fceed0f7 feat(cache): add configurable cache max age (#709)
* feat(cache): add configurable cache max age

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(cache): cover cache max age integration paths

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* test(cache): fix portable regression test formatting

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* style(test): format cache max age run test

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Future New (WEILAI LIU) <weilail@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-06 17:59:40 +08:00

713 lines
24 KiB
Python

# -*- coding: utf-8 -*-
# type: ignore[index,operator,assignment]
"""
Test cases for cache module
@author: GitHub Copilot
"""
from __init__ import patch, unittest
import os
import tempfile
from time import sleep
from ddns.cache import Cache # noqa: E402
class TestCache(unittest.TestCase):
"""Test cases for Cache class"""
def setUp(self):
"""Set up test fixtures"""
# Create a temporary directory for test cache files
self.cache_file = tempfile.mktemp(prefix="ddns_test_cache_", suffix="pk1")
# self.cache_file = os.path.join(self.test_dir, "test_cache.%d.pkl" % random.randint(1, 10000))
def tearDown(self):
"""Clean up test fixtures"""
# Remove the temporary directory and all its contents
if os.path.exists(self.cache_file):
os.remove(self.cache_file)
def test_init_new_cache(self):
"""Test cache initialization with new cache file"""
cache = Cache(self.cache_file)
# Verify initialization
self.assertEqual(len(cache), 0)
self.assertIsInstance(cache.time, float)
self.assertFalse(os.path.exists(self.cache_file)) # File not created until sync
def test_init_with_logger(self):
"""Test cache initialization with custom logger"""
import logging
logger = logging.getLogger("test_logger")
cache = Cache(self.cache_file, logger=logger)
self.assertEqual(len(cache), 0)
def test_init_with_sync(self):
"""Test cache initialization with sync enabled"""
cache = Cache(self.cache_file, sync=True)
self.assertEqual(len(cache), 0)
def test_setitem_and_getitem(self):
"""Test setting and getting cache items"""
cache = Cache(self.cache_file)
# Test setting items
cache["key1"] = "value1"
cache["key2"] = "value2"
# Test getting items
self.assertEqual(cache["key1"], "value1")
self.assertEqual(cache["key2"], "value2")
self.assertEqual(len(cache), 2)
def test_setitem_duplicate_value(self):
"""Test setting the same value twice doesn't trigger update"""
cache = Cache(self.cache_file)
with patch.object(cache, "_Cache__update") as mock_update:
cache["key1"] = "value1"
mock_update.assert_called_once()
# Setting the same value should not trigger update
mock_update.reset_mock()
cache["key1"] = "value1"
mock_update.assert_not_called()
def test_delitem(self):
"""Test deleting cache items"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
# Delete an item
del cache["key1"]
self.assertEqual(len(cache), 1)
self.assertNotIn("key1", cache)
self.assertIn("key2", cache)
def test_delitem_nonexistent_key(self):
"""Test deleting non-existent key doesn't raise error (silent handling)"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
# Should not raise any exception
del cache["nonexistent"]
# Original data should remain unchanged
self.assertEqual(len(cache), 1)
self.assertIn("key1", cache)
def test_delitem_idempotent(self):
"""Test that multiple deletions of the same key are safe"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
# First deletion should work
del cache["key1"]
self.assertEqual(len(cache), 0)
self.assertNotIn("key1", cache)
# Second deletion should be safe (no error)
del cache["key1"]
self.assertEqual(len(cache), 0)
# Third deletion should also be safe
del cache["key1"]
self.assertEqual(len(cache), 0)
def test_contains(self):
"""Test membership testing"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
self.assertIn("key1", cache)
self.assertNotIn("key2", cache)
def test_clear(self):
"""Test clearing cache"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
cache.clear()
self.assertEqual(len(cache), 0)
self.assertNotIn("key1", cache)
self.assertNotIn("key2", cache)
def test_clear_empty_cache(self):
"""Test clearing empty cache doesn't trigger update"""
cache = Cache(self.cache_file)
with patch.object(cache, "_Cache__update") as mock_update:
cache.clear()
mock_update.assert_not_called()
def test_clear_preserves_private_fields(self):
"""Test that clear only removes non-private fields"""
cache = Cache(self.cache_file)
cache["normal1"] = "value1"
cache["normal2"] = "value2"
cache["__private"] = "private_value"
# Check initial state
self.assertEqual(len(cache), 2) # Only counts non-private fields
# Clear should only remove non-private fields
cache.clear()
# Private field should still exist in underlying dict
self.assertEqual(len(cache), 0)
self.assertNotIn("normal1", cache)
self.assertNotIn("normal2", cache)
# Private field still exists but not counted/visible
self.assertTrue("__private" in dict(cache))
def test_iteration(self):
"""Test iterating over cache keys"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
keys = list(cache)
self.assertEqual(set(keys), {"key1", "key2"})
def test_private_fields_excluded(self):
"""Test that private fields (starting with __) are excluded from operations"""
cache = Cache(self.cache_file)
cache["normal_key"] = "normal_value"
# Manually add a private field to the underlying dict (for testing purposes)
super(Cache, cache).__setitem__("__private_field", "private_value")
# len() should exclude private fields
self.assertEqual(len(cache), 1)
# iteration should exclude private fields
keys = list(cache)
self.assertEqual(keys, ["normal_key"])
# data() should exclude private fields
data = cache.get(None)
self.assertEqual(data, {"normal_key": "normal_value"})
def test_private_field_operations_no_sync(self):
"""Test that private field operations don't trigger sync"""
cache = Cache(self.cache_file)
with patch.object(cache, "_Cache__update") as mock_update:
# Setting private field should not trigger sync
cache["__private"] = "private_value"
mock_update.assert_not_called()
# Modifying private field should not trigger sync
cache["__private"] = "new_private_value"
mock_update.assert_not_called()
# Deleting private field should not trigger sync
del cache["__private"]
mock_update.assert_not_called()
# Normal field operations should trigger sync
cache["normal"] = "value"
mock_update.assert_called_once()
def test_private_fields_not_saved_to_file(self):
"""Test that private fields are not saved to file"""
cache = Cache(self.cache_file)
cache["normal_key"] = "normal_value"
cache["__private_key"] = "private_value"
# Sync to file
cache.sync()
# Load new cache instance
cache2 = Cache(self.cache_file)
# Only normal fields should be loaded
self.assertEqual(len(cache2), 1)
self.assertIn("normal_key", cache2)
self.assertNotIn("__private_key", cache2)
self.assertEqual(cache2["normal_key"], "normal_value")
def test_data_method(self):
"""Test data method for getting cache contents"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
# Test getting all data
# data = cache.get()
# self.assertEqual(data, {"key1": "value1", "key2": "value2"})
# Test getting specific key
self.assertEqual(cache.get("key1"), "value1")
self.assertEqual(cache.get("nonexistent", "default"), "default")
def test_data_method_with_sync(self):
"""Test data method with sync enabled calls load"""
cache = Cache(self.cache_file, sync=True)
with patch.object(cache, "load") as mock_load:
cache.load()
mock_load.assert_called_once()
def test_sync_method(self):
"""Test sync method saves data to file"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
# Sync should save to file
result = cache.sync()
self.assertIs(result, cache) # Should return self
self.assertTrue(os.path.exists(self.cache_file))
# Load another cache instance to verify data was saved
cache2 = Cache(self.cache_file)
self.assertEqual(len(cache2), 2)
self.assertEqual(cache2["key1"], "value1")
self.assertEqual(cache2["key2"], "value2")
def test_sync_no_changes(self):
"""Test sync when no changes have been made after load"""
# Create and save initial cache
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache.sync() # This clears the __changed flag
with patch("ddns.cache.dump") as mock_dump:
cache.sync() # This should not call dump since no changes
mock_dump.assert_not_called()
def test_load_existing_file(self):
"""Test loading from existing cache file"""
# Create initial cache and save data
cache1 = Cache(self.cache_file)
cache1["key1"] = "value1"
cache1["key2"] = 2
cache1.sync()
# Load new cache instance
cache2 = Cache(self.cache_file)
self.assertEqual(len(cache2), 2)
self.assertEqual(cache2["key1"], "value1")
self.assertEqual(cache2["key2"], 2)
def test_load_corrupted_file(self):
"""Test loading from corrupted cache file"""
# Create a corrupted cache file
with open(self.cache_file, "w") as f:
f.write("corrupted data")
# Should handle corruption gracefully
cache = Cache(self.cache_file)
self.assertEqual(len(cache), 0)
def test_load_with_exception(self):
"""Test load method handles exceptions properly"""
# Create a file first
with open(self.cache_file, "wb") as f:
f.write(b"invalid pickle data")
cache = Cache(self.cache_file)
with patch("ddns.cache.load", side_effect=Exception("Test error")):
with patch.object(cache, "_Cache__logger") as mock_logger:
cache.load()
mock_logger.warning.assert_called_once()
def test_time_property(self):
"""Test time property returns modification time"""
cache = Cache(self.cache_file)
initial_time = cache.time # type: float # type: ignore[assignment]
self.assertIsInstance(initial_time, float)
self.assertGreater(initial_time, 0)
def test_close_method(self):
"""Test close method syncs and cleans up"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
with patch.object(cache, "sync") as mock_sync:
cache.close()
mock_sync.assert_called_once()
def test_str_representation(self):
"""Test string representation of cache"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
str_repr = str(cache)
self.assertIn("key1", str_repr)
self.assertIn("value1", str_repr)
def test_auto_sync_behavior(self):
"""Test auto-sync behavior when sync=True"""
cache = Cache(self.cache_file, sync=True)
with patch.object(cache, "sync") as mock_sync:
cache["key1"] = "value1"
mock_sync.assert_called()
def test_no_auto_sync_behavior(self):
"""Test no auto-sync behavior when sync=False (default)"""
cache = Cache(self.cache_file, sync=False)
with patch.object(cache, "sync") as mock_sync:
cache["key1"] = "value1"
mock_sync.assert_not_called()
def test_context_manager_like_usage(self):
"""Test using cache in a context-manager-like pattern"""
cache = Cache(self.cache_file)
cache["key1"] = "value1"
cache["key2"] = "value2"
# Manually call close (simulating __del__)
cache.close()
# Verify data was persisted
cache2 = Cache(self.cache_file)
self.assertEqual(len(cache2), 2)
self.assertEqual(cache2["key1"], "value1")
def test_update_time_on_changes(self):
"""Test that modification time is updated on changes"""
cache = Cache(self.cache_file)
initial_time = cache.time
# Small delay to ensure time difference
sleep(0.01)
cache["key1"] = "value1"
new_time = cache.time # type: float # type: ignore[assignment]
self.assertGreater(new_time, initial_time) # type: ignore[comparison-overlap]
def test_integration_multiple_operations(self):
"""Integration test with multiple operations"""
cache = Cache(self.cache_file)
# Add some data
cache["user1"] = {"name": "Alice", "age": 30}
cache["user2"] = {"name": "Bob", "age": 25}
cache["config"] = {"debug": True, "timeout": 30}
self.assertEqual(len(cache), 3)
# Modify data
cache["user1"]["age"] = 31 # This won't trigger update automatically
cache["user1"] = {"name": "Alice", "age": 31} # This will
# Delete data
del cache["user2"]
self.assertEqual(len(cache), 2)
self.assertEqual(cache["user1"]["age"], 31)
self.assertNotIn("user2", cache)
# Persist and reload
cache.sync()
cache2 = Cache(self.cache_file)
self.assertEqual(len(cache2), 2)
self.assertEqual(cache2["user1"]["age"], 31)
self.assertEqual(cache2["config"]["debug"], True)
def test_mixed_public_private_operations(self):
"""Test mixed operations with public and private fields"""
cache = Cache(self.cache_file)
# Add mixed data
cache["public1"] = "public_value1"
cache["__private1"] = "private_value1"
cache["public2"] = "public_value2"
cache["__private2"] = "private_value2"
# Only public fields should be counted
self.assertEqual(len(cache), 2)
# Only public fields should be iterable
public_keys = list(cache)
self.assertEqual(set(public_keys), {"public1", "public2"})
# data() should only return public fields
data = cache.get(None)
self.assertEqual(data, {"public1": "public_value1", "public2": "public_value2"})
# Delete operations
del cache["public1"] # Should work
del cache["__private1"] # Should work but not trigger sync
del cache["nonexistent"] # Should be safe
# Only one public field should remain
self.assertEqual(len(cache), 1)
self.assertEqual(list(cache), ["public2"])
# Sync and reload
cache.sync()
cache2 = Cache(self.cache_file)
# Only public field should be persisted
self.assertEqual(len(cache2), 1)
self.assertEqual(list(cache2), ["public2"])
self.assertEqual(cache2["public2"], "public_value2")
def test_str_representation_excludes_private(self):
"""Test that string representation only shows public fields"""
cache = Cache(self.cache_file)
cache["public"] = "public_value"
cache["__private"] = "private_value"
str_repr = str(cache)
self.assertIn("public", str_repr)
self.assertIn("public_value", str_repr)
# Note: private fields might still appear in str() since it calls super().__str__()
# This is acceptable as str() shows the raw dict content
def test_json_format_verification(self):
"""Test that cache files are saved in JSON format"""
import json
cache = Cache(self.cache_file)
cache["string_key"] = "string_value"
cache["number_key"] = 42
cache["list_key"] = [1, 2, 3]
cache["dict_key"] = {"nested": "value"}
# Save to file
cache.sync()
# Verify file exists and is valid JSON
self.assertTrue(os.path.exists(self.cache_file))
# Read the file and verify it's valid JSON
with open(self.cache_file, "r") as f:
file_content = f.read()
parsed_json = json.loads(file_content)
# Verify the content matches what we saved
self.assertEqual(parsed_json["string_key"], "string_value")
self.assertEqual(parsed_json["number_key"], 42)
self.assertEqual(parsed_json["list_key"], [1, 2, 3])
self.assertEqual(parsed_json["dict_key"], {"nested": "value"})
# Verify the file contains readable JSON (not binary pickle data)
self.assertIn('"string_key"', file_content)
self.assertIn('"string_value"', file_content)
def test_cache_new_disabled(self):
"""Test Cache.new with cache disabled (config_cache=False)"""
import logging
logger = logging.getLogger("test_logger")
cache = Cache.new(False, "test_hash", logger)
# Should return None when cache is disabled
self.assertIsNone(cache)
def test_cache_new_temp_file(self):
"""Test Cache.new with temp file creation (config_cache=True)"""
import logging
import tempfile
logger = logging.getLogger("test_logger")
test_hash = "test_hash_123"
cache = Cache.new(True, test_hash, logger)
# Should create a cache instance
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
# Should use temp directory with correct naming pattern
expected_pattern = "ddns.%s.cache" % test_hash
self.assertIn(expected_pattern, cache._Cache__filename)
self.assertIn(tempfile.gettempdir(), cache._Cache__filename)
# Clean up
cache.close()
def test_cache_new_custom_path(self):
"""Test Cache.new with custom cache file path"""
import logging
logger = logging.getLogger("test_logger")
custom_path = self.cache_file
cache = Cache.new(custom_path, "unused_hash", logger)
# Should create a cache instance with custom path
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
self.assertEqual(cache._Cache__filename, custom_path)
# Clean up
cache.close()
@patch("ddns.cache.time")
def test_cache_new_outdated_cache(self, mock_time):
"""Test Cache.new with outdated cache file (>72 hours old)"""
import logging
import json
logger = logging.getLogger("test_logger")
# Create a cache file with some data
test_data = {"test_key": "test_value"}
with open(self.cache_file, "w") as f:
json.dump(test_data, f)
# Mock current time to make cache appear outdated
# Cache file mtime will be recent, but we'll mock current time to be 73 hours later
current_time = 1000000
mock_time.return_value = current_time
# Mock the file modification time to be 73 hours ago
old_mtime = current_time - (73 * 3600) # 73 hours ago
with patch("ddns.cache.stat") as mock_stat:
mock_stat.return_value.st_mtime = old_mtime
cache = Cache.new(self.cache_file, "test_hash", logger)
# Should create cache instance but clear outdated data
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
self.assertEqual(len(cache), 0) # Should be empty due to age
# Clean up
cache.close()
def test_cache_new_empty_cache(self):
"""Test Cache.new with empty cache file"""
import logging
import json
logger = logging.getLogger("test_logger")
# Create an empty cache file
with open(self.cache_file, "w") as f:
json.dump({}, f)
cache = Cache.new(self.cache_file, "test_hash", logger)
# Should create cache instance
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
self.assertEqual(len(cache), 0)
# Clean up
cache.close()
@patch("ddns.cache.time")
def test_cache_new_valid_cache(self, mock_time):
"""Test Cache.new with valid cache file with data"""
import logging
import json
logger = logging.getLogger("test_logger")
# Create a cache file with test data
test_data = {
"domain1.com": {"ip": "1.2.3.4", "timestamp": 1234567890},
"domain2.com": {"ip": "5.6.7.8", "timestamp": 1234567891},
}
with open(self.cache_file, "w") as f:
json.dump(test_data, f)
# Mock current time to be within 72 hours of cache creation
current_time = 1000000
mock_time.return_value = current_time
# Mock file modification time to be recent (within 72 hours)
recent_mtime = current_time - (24 * 3600) # 24 hours ago
with patch("ddns.cache.stat") as mock_stat:
mock_stat.return_value.st_mtime = recent_mtime
cache = Cache.new(self.cache_file, "test_hash", logger)
# Should create cache instance with loaded data
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
self.assertEqual(len(cache), 2)
self.assertEqual(cache["domain1.com"]["ip"], "1.2.3.4")
self.assertEqual(cache["domain2.com"]["ip"], "5.6.7.8")
# Clean up
cache.close()
def test_cache_new_nonexistent_file(self):
"""Test Cache.new with nonexistent cache file"""
import logging
logger = logging.getLogger("test_logger")
nonexistent_path = tempfile.mktemp(prefix="ddns_test_nonexistent_", suffix=".cache")
# Ensure file doesn't exist
if os.path.exists(nonexistent_path):
os.remove(nonexistent_path)
cache = Cache.new(nonexistent_path, "test_hash", logger)
# Should create cache instance with empty data
self.assertIsNotNone(cache)
self.assertIsInstance(cache, Cache)
self.assertEqual(len(cache), 0)
# Clean up
cache.close()
@patch("ddns.cache.time")
def test_cache_new_custom_max_age_and_boundary(self, mock_time):
"""Test custom age, exact expiry boundary, and future mtimes."""
import json
import logging
with open(self.cache_file, "w") as data:
json.dump({"record": "1.2.3.4"}, data)
logger = logging.getLogger("test_logger")
mock_time.return_value = 1000
with patch("ddns.cache.stat") as mock_stat:
mock_stat.return_value.st_mtime = 900
cache = Cache.new(self.cache_file, "hash", logger, 100)
self.assertEqual(len(cache), 0)
cache.close()
with open(self.cache_file, "w") as data:
json.dump({"record": "1.2.3.4"}, data)
with patch("ddns.cache.stat") as mock_stat:
mock_stat.return_value.st_mtime = 1001
cache = Cache.new(self.cache_file, "hash", logger, 100)
self.assertEqual(len(cache), 0)
cache.close()
@patch("ddns.cache.time")
def test_cache_new_zero_age_clears_existing_cache(self, mock_time):
"""Test zero max age clears all existing public entries."""
import json
import logging
with open(self.cache_file, "w") as data:
json.dump({"a": 1, "b": 2}, data)
mock_time.return_value = 1000
with patch("ddns.cache.stat") as mock_stat:
mock_stat.return_value.st_mtime = 1000
cache = Cache.new(self.cache_file, "hash", logging.getLogger("test_logger"), 0)
self.assertEqual(len(cache), 0)
cache.close()
if __name__ == "__main__":
unittest.main()