2Tests for backend storage implementations.
5from abc import ABC, abstractmethod
6from collections.abc import Generator, Mapping
7from contextlib import closing, contextmanager
8from pathlib import Path
11from unittest.mock import MagicMock
15from funkycache.storage import (
19 CannotOverwriteValueException,
23class CacheStorageTestSuite(ABC):
25 A common test suite for all implementations of CacheStorage.
27 This should be subclassed as a `test*` class, where you write fixtures
28 and tests that are specific to a single implementation.
33 def create_storage[V](
34 self, tmp_path: Path, data_type: Any
35 ) -> Generator[CacheStorage[V]]:
37 Return an empty cache storage backend.
40 @pytest.mark.parametrize(
43 pytest.param({"n": "10"}, id="str"),
44 pytest.param({"n": 10}, id="int"),
46 # A Path isn't serialisable by json.dumps(…) with the
47 # default arguments, but we still want to cache it.
48 pytest.param({"n": Path("10.txt")}, id="path"),
50 # A dict with Path keys isn't serialisable by json.dumps(…),
51 # even if you pass default=str.
52 pytest.param({Path("n"): "numbers"}, id="path_key"),
53 pytest.param([{Path("n"): "numbers"}], id="path_key_nested_in_list"),
54 pytest.param({"1": {Path("n"): "numbers"}}, id="path_key_nested_in_obj"),
57 def test_cache(self, key: Mapping[str, Any], tmp_path: Path) -> None:
59 Test the basic properties of a cache.
61 with self.create_storage(tmp_path, data_type=int) as storage:
66 with pytest.raises(KeyError):
69 # Store and retrieve a value
70 storage.set(key, value=fib10)
71 assert storage.get(key) == fib10
73 # Overwriting an existing value is an error.
74 with pytest.raises(CannotOverwriteValueException) as exc:
75 storage.set(key, value=fib20)
76 assert repr(key) in exc.value.args[0]
78 assert storage.get(key) == fib10
80 # Writing a key with the same value as the already-stored
82 storage.set(key, value=fib10)
84 def test_can_store_non_ascii_values(self, tmp_path: Path) -> None:
86 The storage can hold values which don't encode as ASCII.
88 with self.create_storage(tmp_path, data_type=str) as storage:
89 key = {"type": "place"}
90 storage.set(key, value="café")
91 assert storage.get(key) == "café"
94class TestMemoryCacheStorage(CacheStorageTestSuite):
96 Tests for MemoryCacheStorage.
100 def create_storage[V](
101 self, tmp_path: Path, data_type: Any
102 ) -> Generator[CacheStorage[V]]:
104 Return an empty cache storage backend.
106 yield MemoryCacheStorage[V]()
109class TestSQLiteCacheStorage(CacheStorageTestSuite):
111 Tests for SQLiteCacheStorage.
115 def create_storage[V](
116 self, tmp_path: Path, data_type: Any
117 ) -> Generator[CacheStorage[V]]:
119 Return an empty cache storage backend.
121 storage = SQLiteCacheStorage(":memory:", data_type=data_type)
125 def test_operations_persist(self, tmp_path: Path) -> None:
127 Cache operations persist over opening/closing the cache.
130 db_path = tmp_path / "cache.db"
132 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
133 cache.set(key, value=1)
135 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
136 assert cache.get(key) == 1
138 def test_unexpected_error_during_set(self) -> None:
140 If we get an unexpected IntegrityError during set() that isn't about
141 overwriting an existing key, it's re-raised.
144 class OtherIntegrityError(sqlite3.IntegrityError):
145 sqlite_errorname = "SQLITE_CONSTRAINT_UNIQUE"
147 with SQLiteCacheStorage[int](":memory:", data_type=int) as cache:
148 # Close the original connection, then replace it with a mock
149 # that simulates a different IntegrityError.
152 mock_con = MagicMock()
153 mock_con.execute.side_effect = MagicMock(
154 side_effect=OtherIntegrityError("UNIQUE constraint failed")
158 with pytest.raises(sqlite3.IntegrityError) as exc_info:
159 cache.set(key={"n": "one"}, value=1)
161 assert exc_info.value.sqlite_errorname == "SQLITE_CONSTRAINT_UNIQUE"
163 def test_reset_if_fingerprint_is_wrong(self, tmp_path: Path) -> None:
165 If the database contains a fingerprint from an older version of
166 FunkyCache, old cache entries are dropped.
169 db_path = tmp_path / "cache.db"
171 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
172 cache.set(key, value=1)
174 with closing(sqlite3.connect(db_path)) as con:
177 "UPDATE funkycache_meta SET value=? WHERE key=?",
178 ("old-fingerprint", "fingerprint"),
182 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
183 with pytest.raises(KeyError):
186 def test_reset_if_fingerprint_is_missing(self, tmp_path: Path) -> None:
188 If the database doesn't have a fingerprint from an older version of
189 FunkyCache, old cache entries are dropped.
192 db_path = tmp_path / "cache.db"
194 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
195 cache.set(key, value=1)
197 with closing(sqlite3.connect(db_path)) as con:
199 cur.execute("DELETE FROM funkycache_meta")
202 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
203 with pytest.raises(KeyError):
206 def test_error_if_meta_is_wrong_format(self, tmp_path: Path) -> None:
208 If looking up the fingerprint funkycache_meta table fails for
209 a reason other than the table missing, the error is re-raised.
212 db_path = tmp_path / "cache.db"
214 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
215 cache.set(key, value=1)
217 # Delete the original table, then recreate it without a "key" column.
218 # Looking up by key will now fail.
219 with closing(sqlite3.connect(db_path)) as con:
221 cur.execute("DROP TABLE funkycache_meta")
222 cur.execute("CREATE TABLE funkycache_meta(value)")
225 with pytest.raises(sqlite3.OperationalError, match="no such column: key"):
226 SQLiteCacheStorage[int](db_path, data_type="int")