Skip to main content

tests/test_storage.py

1"""
2Tests for backend storage implementations.
3"""
5from abc import ABC, abstractmethod
6from collections.abc import Generator, Mapping
7from contextlib import closing, contextmanager
8from pathlib import Path
9import sqlite3
10from typing import Any
11from unittest.mock import MagicMock
13import pytest
15from funkycache.storage import (
16 CacheStorage,
17 MemoryCacheStorage,
18 SQLiteCacheStorage,
19 CannotOverwriteValueException,
23class CacheStorageTestSuite(ABC):
24 """
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.
29 """
31 @abstractmethod
32 @contextmanager
33 def create_storage[V](
34 self, tmp_path: Path, data_type: Any
35 ) -> Generator[CacheStorage[V]]:
36 """
37 Return an empty cache storage backend.
38 """
40 @pytest.mark.parametrize(
41 "key",
42 [
43 pytest.param({"n": "10"}, id="str"),
44 pytest.param({"n": 10}, id="int"),
45 #
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"),
49 #
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"),
55 ],
56 )
57 def test_cache(self, key: Mapping[str, Any], tmp_path: Path) -> None:
58 """
59 Test the basic properties of a cache.
60 """
61 with self.create_storage(tmp_path, data_type=int) as storage:
62 fib10 = 55
63 fib20 = 6765
65 # Test empty storage
66 with pytest.raises(KeyError):
67 storage.get(key)
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
81 # value is allowed.
82 storage.set(key, value=fib10)
84 def test_can_store_non_ascii_values(self, tmp_path: Path) -> None:
85 """
86 The storage can hold values which don't encode as ASCII.
87 """
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):
95 """
96 Tests for MemoryCacheStorage.
97 """
99 @contextmanager
100 def create_storage[V](
101 self, tmp_path: Path, data_type: Any
102 ) -> Generator[CacheStorage[V]]:
103 """
104 Return an empty cache storage backend.
105 """
106 yield MemoryCacheStorage[V]()
109class TestSQLiteCacheStorage(CacheStorageTestSuite):
110 """
111 Tests for SQLiteCacheStorage.
112 """
114 @contextmanager
115 def create_storage[V](
116 self, tmp_path: Path, data_type: Any
117 ) -> Generator[CacheStorage[V]]:
118 """
119 Return an empty cache storage backend.
120 """
121 storage = SQLiteCacheStorage(":memory:", data_type=data_type)
122 yield storage
123 storage.close()
125 def test_operations_persist(self, tmp_path: Path) -> None:
126 """
127 Cache operations persist over opening/closing the cache.
128 """
129 key = {"n": "one"}
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:
139 """
140 If we get an unexpected IntegrityError during set() that isn't about
141 overwriting an existing key, it's re-raised.
142 """
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.
150 cache.con.close()
152 mock_con = MagicMock()
153 mock_con.execute.side_effect = MagicMock(
154 side_effect=OtherIntegrityError("UNIQUE constraint failed")
155 )
156 cache.con = mock_con
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:
164 """
165 If the database contains a fingerprint from an older version of
166 FunkyCache, old cache entries are dropped.
167 """
168 key = {"n": "one"}
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:
175 cur = con.cursor()
176 cur.execute(
177 "UPDATE funkycache_meta SET value=? WHERE key=?",
178 ("old-fingerprint", "fingerprint"),
179 )
180 con.commit()
182 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
183 with pytest.raises(KeyError):
184 cache.get(key)
186 def test_reset_if_fingerprint_is_missing(self, tmp_path: Path) -> None:
187 """
188 If the database doesn't have a fingerprint from an older version of
189 FunkyCache, old cache entries are dropped.
190 """
191 key = {"n": "one"}
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:
198 cur = con.cursor()
199 cur.execute("DELETE FROM funkycache_meta")
200 con.commit()
202 with SQLiteCacheStorage[int](db_path, data_type="int") as cache:
203 with pytest.raises(KeyError):
204 cache.get(key)
206 def test_error_if_meta_is_wrong_format(self, tmp_path: Path) -> None:
207 """
208 If looking up the fingerprint funkycache_meta table fails for
209 a reason other than the table missing, the error is re-raised.
210 """
211 key = {"n": "one"}
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:
220 cur = con.cursor()
221 cur.execute("DROP TABLE funkycache_meta")
222 cur.execute("CREATE TABLE funkycache_meta(value)")
223 con.commit()
225 with pytest.raises(sqlite3.OperationalError, match="no such column: key"):
226 SQLiteCacheStorage[int](db_path, data_type="int")