fleet-python 0.2.66b2__py3-none-any.whl → 0.2.105__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/export_tasks.py +16 -5
- examples/export_tasks_filtered.py +245 -0
- examples/fetch_tasks.py +230 -0
- examples/import_tasks.py +140 -8
- examples/iterate_verifiers.py +725 -0
- fleet/__init__.py +128 -5
- fleet/_async/__init__.py +27 -3
- fleet/_async/base.py +24 -9
- fleet/_async/client.py +938 -41
- fleet/_async/env/client.py +60 -3
- fleet/_async/instance/client.py +52 -7
- fleet/_async/models.py +15 -0
- fleet/_async/resources/api.py +200 -0
- fleet/_async/resources/sqlite.py +1801 -46
- fleet/_async/tasks.py +122 -25
- fleet/_async/verifiers/bundler.py +22 -21
- fleet/_async/verifiers/verifier.py +25 -19
- fleet/agent/__init__.py +32 -0
- fleet/agent/gemini_cua/Dockerfile +45 -0
- fleet/agent/gemini_cua/__init__.py +10 -0
- fleet/agent/gemini_cua/agent.py +759 -0
- fleet/agent/gemini_cua/mcp/main.py +108 -0
- fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
- fleet/agent/gemini_cua/mcp_server/main.py +105 -0
- fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
- fleet/agent/gemini_cua/requirements.txt +5 -0
- fleet/agent/gemini_cua/start.sh +30 -0
- fleet/agent/orchestrator.py +854 -0
- fleet/agent/types.py +49 -0
- fleet/agent/utils.py +34 -0
- fleet/base.py +34 -9
- fleet/cli.py +1061 -0
- fleet/client.py +1060 -48
- fleet/config.py +1 -1
- fleet/env/__init__.py +16 -0
- fleet/env/client.py +60 -3
- fleet/eval/__init__.py +15 -0
- fleet/eval/uploader.py +231 -0
- fleet/exceptions.py +8 -0
- fleet/instance/client.py +53 -8
- fleet/instance/models.py +1 -0
- fleet/models.py +303 -0
- fleet/proxy/__init__.py +25 -0
- fleet/proxy/proxy.py +453 -0
- fleet/proxy/whitelist.py +244 -0
- fleet/resources/api.py +200 -0
- fleet/resources/sqlite.py +1845 -46
- fleet/tasks.py +113 -20
- fleet/utils/__init__.py +7 -0
- fleet/utils/http_logging.py +178 -0
- fleet/utils/logging.py +13 -0
- fleet/utils/playwright.py +440 -0
- fleet/verifiers/bundler.py +22 -21
- fleet/verifiers/db.py +985 -1
- fleet/verifiers/decorator.py +1 -1
- fleet/verifiers/verifier.py +25 -19
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
- fleet_python-0.2.105.dist-info/RECORD +115 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
- fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
- tests/test_app_method.py +85 -0
- tests/test_expect_exactly.py +4148 -0
- tests/test_expect_only.py +2593 -0
- tests/test_instance_dispatch.py +607 -0
- tests/test_sqlite_resource_dual_mode.py +263 -0
- tests/test_sqlite_shared_memory_behavior.py +117 -0
- fleet_python-0.2.66b2.dist-info/RECORD +0 -81
- tests/test_verifier_security.py +0 -427
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,2593 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test to verify expect_only and expect_only_v2 work correctly.
|
|
3
|
+
|
|
4
|
+
expect_only: Original simple implementation - only supports whole-row specs for additions/deletions
|
|
5
|
+
expect_only_v2: Enhanced implementation with field-level spec support for additions/deletions
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
import tempfile
|
|
10
|
+
import os
|
|
11
|
+
import pytest
|
|
12
|
+
from fleet.verifiers.db import DatabaseSnapshot, IgnoreConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ============================================================================
|
|
16
|
+
# Tests for expect_only_v2 (field-level spec support)
|
|
17
|
+
# ============================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_field_level_specs_for_added_row():
|
|
21
|
+
"""Test that bulk field specs work for row additions in expect_only_v2"""
|
|
22
|
+
|
|
23
|
+
# Create two temporary databases
|
|
24
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
25
|
+
before_db = f.name
|
|
26
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
27
|
+
after_db = f.name
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Setup before database
|
|
31
|
+
conn = sqlite3.connect(before_db)
|
|
32
|
+
conn.execute(
|
|
33
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
34
|
+
)
|
|
35
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
36
|
+
conn.commit()
|
|
37
|
+
conn.close()
|
|
38
|
+
|
|
39
|
+
# Setup after database - add a new row
|
|
40
|
+
conn = sqlite3.connect(after_db)
|
|
41
|
+
conn.execute(
|
|
42
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
43
|
+
)
|
|
44
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
45
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
46
|
+
conn.commit()
|
|
47
|
+
conn.close()
|
|
48
|
+
|
|
49
|
+
# Create snapshots
|
|
50
|
+
before = DatabaseSnapshot(before_db)
|
|
51
|
+
after = DatabaseSnapshot(after_db)
|
|
52
|
+
|
|
53
|
+
# Bulk field specs should work for added rows in v2
|
|
54
|
+
before.diff(after).expect_only_v2(
|
|
55
|
+
[
|
|
56
|
+
{
|
|
57
|
+
"table": "users",
|
|
58
|
+
"pk": 2,
|
|
59
|
+
"type": "insert",
|
|
60
|
+
"fields": [("id", 2), ("name", "Bob"), ("status", "inactive")],
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
finally:
|
|
66
|
+
os.unlink(before_db)
|
|
67
|
+
os.unlink(after_db)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_field_level_specs_with_wrong_values():
|
|
71
|
+
"""Test that wrong values are detected in expect_only_v2"""
|
|
72
|
+
|
|
73
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
74
|
+
before_db = f.name
|
|
75
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
76
|
+
after_db = f.name
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
conn = sqlite3.connect(before_db)
|
|
80
|
+
conn.execute(
|
|
81
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
82
|
+
)
|
|
83
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
84
|
+
conn.commit()
|
|
85
|
+
conn.close()
|
|
86
|
+
|
|
87
|
+
conn = sqlite3.connect(after_db)
|
|
88
|
+
conn.execute(
|
|
89
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
90
|
+
)
|
|
91
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
92
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
93
|
+
conn.commit()
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
before = DatabaseSnapshot(before_db)
|
|
97
|
+
after = DatabaseSnapshot(after_db)
|
|
98
|
+
|
|
99
|
+
# Should fail because status value is wrong
|
|
100
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
101
|
+
before.diff(after).expect_only_v2(
|
|
102
|
+
[
|
|
103
|
+
{
|
|
104
|
+
"table": "users",
|
|
105
|
+
"pk": 2,
|
|
106
|
+
"type": "insert",
|
|
107
|
+
"fields": [
|
|
108
|
+
("id", 2),
|
|
109
|
+
("name", "Bob"),
|
|
110
|
+
("status", "WRONG_VALUE"),
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
finally:
|
|
117
|
+
os.unlink(before_db)
|
|
118
|
+
os.unlink(after_db)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_modification_with_bulk_fields_spec():
|
|
122
|
+
"""Test that bulk field specs work for row modifications in expect_only_v2"""
|
|
123
|
+
|
|
124
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
125
|
+
before_db = f.name
|
|
126
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
127
|
+
after_db = f.name
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
conn = sqlite3.connect(before_db)
|
|
131
|
+
conn.execute(
|
|
132
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, role TEXT)"
|
|
133
|
+
)
|
|
134
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', 'user')")
|
|
135
|
+
conn.commit()
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
conn = sqlite3.connect(after_db)
|
|
139
|
+
conn.execute(
|
|
140
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, role TEXT)"
|
|
141
|
+
)
|
|
142
|
+
# Both name and status changed
|
|
143
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', 'user')")
|
|
144
|
+
conn.commit()
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
before = DatabaseSnapshot(before_db)
|
|
148
|
+
after = DatabaseSnapshot(after_db)
|
|
149
|
+
|
|
150
|
+
# Bulk field specs for modifications - specify all changed fields
|
|
151
|
+
# no_other_changes=True ensures no other fields changed
|
|
152
|
+
before.diff(after).expect_only_v2(
|
|
153
|
+
[
|
|
154
|
+
{
|
|
155
|
+
"table": "users",
|
|
156
|
+
"pk": 1,
|
|
157
|
+
"type": "modify",
|
|
158
|
+
"resulting_fields": [("name", "Alice Updated"), ("status", "inactive")],
|
|
159
|
+
"no_other_changes": True,
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
finally:
|
|
165
|
+
os.unlink(before_db)
|
|
166
|
+
os.unlink(after_db)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_modification_with_bulk_fields_spec_wrong_value():
|
|
170
|
+
"""Test that wrong values in modification bulk field specs are detected"""
|
|
171
|
+
|
|
172
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
173
|
+
before_db = f.name
|
|
174
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
175
|
+
after_db = f.name
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
conn = sqlite3.connect(before_db)
|
|
179
|
+
conn.execute(
|
|
180
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
181
|
+
)
|
|
182
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
183
|
+
conn.commit()
|
|
184
|
+
conn.close()
|
|
185
|
+
|
|
186
|
+
conn = sqlite3.connect(after_db)
|
|
187
|
+
conn.execute(
|
|
188
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
189
|
+
)
|
|
190
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
191
|
+
conn.commit()
|
|
192
|
+
conn.close()
|
|
193
|
+
|
|
194
|
+
before = DatabaseSnapshot(before_db)
|
|
195
|
+
after = DatabaseSnapshot(after_db)
|
|
196
|
+
|
|
197
|
+
# Should fail because status value is wrong
|
|
198
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
199
|
+
before.diff(after).expect_only_v2(
|
|
200
|
+
[
|
|
201
|
+
{
|
|
202
|
+
"table": "users",
|
|
203
|
+
"pk": 1,
|
|
204
|
+
"type": "modify",
|
|
205
|
+
"resulting_fields": [
|
|
206
|
+
("name", "Alice Updated"),
|
|
207
|
+
("status", "WRONG_VALUE"), # Wrong!
|
|
208
|
+
],
|
|
209
|
+
"no_other_changes": True,
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
finally:
|
|
215
|
+
os.unlink(before_db)
|
|
216
|
+
os.unlink(after_db)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_modification_with_bulk_fields_spec_missing_field():
|
|
220
|
+
"""Test that missing fields in modification bulk field specs are detected when no_other_changes=True"""
|
|
221
|
+
|
|
222
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
223
|
+
before_db = f.name
|
|
224
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
225
|
+
after_db = f.name
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
conn = sqlite3.connect(before_db)
|
|
229
|
+
conn.execute(
|
|
230
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
231
|
+
)
|
|
232
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
233
|
+
conn.commit()
|
|
234
|
+
conn.close()
|
|
235
|
+
|
|
236
|
+
conn = sqlite3.connect(after_db)
|
|
237
|
+
conn.execute(
|
|
238
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
239
|
+
)
|
|
240
|
+
# Both name and status changed
|
|
241
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
242
|
+
conn.commit()
|
|
243
|
+
conn.close()
|
|
244
|
+
|
|
245
|
+
before = DatabaseSnapshot(before_db)
|
|
246
|
+
after = DatabaseSnapshot(after_db)
|
|
247
|
+
|
|
248
|
+
# Should fail because status change is not in resulting_fields and no_other_changes=True
|
|
249
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
250
|
+
before.diff(after).expect_only_v2(
|
|
251
|
+
[
|
|
252
|
+
{
|
|
253
|
+
"table": "users",
|
|
254
|
+
"pk": 1,
|
|
255
|
+
"type": "modify",
|
|
256
|
+
"resulting_fields": [
|
|
257
|
+
("name", "Alice Updated"),
|
|
258
|
+
# status is missing - should fail with no_other_changes=True
|
|
259
|
+
],
|
|
260
|
+
"no_other_changes": True,
|
|
261
|
+
},
|
|
262
|
+
]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
assert "status" in str(exc_info.value)
|
|
266
|
+
assert "NOT_IN_RESULTING_FIELDS" in str(exc_info.value)
|
|
267
|
+
|
|
268
|
+
finally:
|
|
269
|
+
os.unlink(before_db)
|
|
270
|
+
os.unlink(after_db)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_modification_no_other_changes_false_allows_extra_changes():
|
|
274
|
+
"""Test that no_other_changes=False allows other fields to change without checking them"""
|
|
275
|
+
|
|
276
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
277
|
+
before_db = f.name
|
|
278
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
279
|
+
after_db = f.name
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
conn = sqlite3.connect(before_db)
|
|
283
|
+
conn.execute(
|
|
284
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
285
|
+
)
|
|
286
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
287
|
+
conn.commit()
|
|
288
|
+
conn.close()
|
|
289
|
+
|
|
290
|
+
conn = sqlite3.connect(after_db)
|
|
291
|
+
conn.execute(
|
|
292
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
293
|
+
)
|
|
294
|
+
# All three fields changed: name, status, updated_at
|
|
295
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-15')")
|
|
296
|
+
conn.commit()
|
|
297
|
+
conn.close()
|
|
298
|
+
|
|
299
|
+
before = DatabaseSnapshot(before_db)
|
|
300
|
+
after = DatabaseSnapshot(after_db)
|
|
301
|
+
|
|
302
|
+
# With no_other_changes=False, we only need to specify the fields we care about
|
|
303
|
+
# status and updated_at changed but we don't check them
|
|
304
|
+
before.diff(after).expect_only_v2(
|
|
305
|
+
[
|
|
306
|
+
{
|
|
307
|
+
"table": "users",
|
|
308
|
+
"pk": 1,
|
|
309
|
+
"type": "modify",
|
|
310
|
+
"resulting_fields": [
|
|
311
|
+
("name", "Alice Updated"),
|
|
312
|
+
# status and updated_at not specified - that's OK with no_other_changes=False
|
|
313
|
+
],
|
|
314
|
+
"no_other_changes": False, # Allows other changes
|
|
315
|
+
},
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
finally:
|
|
320
|
+
os.unlink(before_db)
|
|
321
|
+
os.unlink(after_db)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_modification_no_other_changes_true_with_ellipsis():
|
|
325
|
+
"""
|
|
326
|
+
Test that no_other_changes=True and specifying fields with ... means only the specified
|
|
327
|
+
fields are checked, and all other fields must remain unchanged (even if ... is used as the value).
|
|
328
|
+
"""
|
|
329
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
330
|
+
before_db = f.name
|
|
331
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
332
|
+
after_db = f.name
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
# Initial table and row
|
|
336
|
+
conn = sqlite3.connect(before_db)
|
|
337
|
+
conn.execute(
|
|
338
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
339
|
+
)
|
|
340
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
341
|
+
conn.commit()
|
|
342
|
+
conn.close()
|
|
343
|
+
|
|
344
|
+
# After: only 'name' changes (others should remain exactly the same)
|
|
345
|
+
conn = sqlite3.connect(after_db)
|
|
346
|
+
conn.execute(
|
|
347
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
348
|
+
)
|
|
349
|
+
conn.execute(
|
|
350
|
+
"INSERT INTO users VALUES (1, 'Alice Updated', 'active', '2024-01-01')"
|
|
351
|
+
)
|
|
352
|
+
conn.commit()
|
|
353
|
+
conn.close()
|
|
354
|
+
|
|
355
|
+
before = DatabaseSnapshot(before_db)
|
|
356
|
+
after = DatabaseSnapshot(after_db)
|
|
357
|
+
|
|
358
|
+
# Specify to check only name with ..., others must remain the same (enforced by no_other_changes=True)
|
|
359
|
+
before.diff(after).expect_only_v2(
|
|
360
|
+
[
|
|
361
|
+
{
|
|
362
|
+
"table": "users",
|
|
363
|
+
"pk": 1,
|
|
364
|
+
"type": "modify",
|
|
365
|
+
"resulting_fields": [
|
|
366
|
+
("name", ...), # Only check that field changed, but not checking its value
|
|
367
|
+
],
|
|
368
|
+
"no_other_changes": True,
|
|
369
|
+
},
|
|
370
|
+
]
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Now, test that a change to a non-listed field triggers an error
|
|
374
|
+
# We'll modify status, which is not covered by 'resulting_fields'
|
|
375
|
+
conn = sqlite3.connect(after_db)
|
|
376
|
+
conn.execute(
|
|
377
|
+
"DELETE FROM users WHERE id=1"
|
|
378
|
+
)
|
|
379
|
+
conn.execute(
|
|
380
|
+
"INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-01')"
|
|
381
|
+
)
|
|
382
|
+
conn.commit()
|
|
383
|
+
conn.close()
|
|
384
|
+
|
|
385
|
+
with pytest.raises(AssertionError):
|
|
386
|
+
before.diff(after).expect_only_v2(
|
|
387
|
+
[
|
|
388
|
+
{
|
|
389
|
+
"table": "users",
|
|
390
|
+
"pk": 1,
|
|
391
|
+
"type": "modify",
|
|
392
|
+
"resulting_fields": [
|
|
393
|
+
("name", ...), # Only allow name to change, not status
|
|
394
|
+
],
|
|
395
|
+
"no_other_changes": True,
|
|
396
|
+
},
|
|
397
|
+
]
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
finally:
|
|
401
|
+
os.unlink(before_db)
|
|
402
|
+
os.unlink(after_db)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_modification_no_other_changes_false_still_validates_specified():
|
|
406
|
+
"""Test that no_other_changes=False still validates the fields that ARE specified"""
|
|
407
|
+
|
|
408
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
409
|
+
before_db = f.name
|
|
410
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
411
|
+
after_db = f.name
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
conn = sqlite3.connect(before_db)
|
|
415
|
+
conn.execute(
|
|
416
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
417
|
+
)
|
|
418
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
419
|
+
conn.commit()
|
|
420
|
+
conn.close()
|
|
421
|
+
|
|
422
|
+
conn = sqlite3.connect(after_db)
|
|
423
|
+
conn.execute(
|
|
424
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
425
|
+
)
|
|
426
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
427
|
+
conn.commit()
|
|
428
|
+
conn.close()
|
|
429
|
+
|
|
430
|
+
before = DatabaseSnapshot(before_db)
|
|
431
|
+
after = DatabaseSnapshot(after_db)
|
|
432
|
+
|
|
433
|
+
# Should fail because name value is wrong, even with no_other_changes=False
|
|
434
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
435
|
+
before.diff(after).expect_only_v2(
|
|
436
|
+
[
|
|
437
|
+
{
|
|
438
|
+
"table": "users",
|
|
439
|
+
"pk": 1,
|
|
440
|
+
"type": "modify",
|
|
441
|
+
"resulting_fields": [
|
|
442
|
+
("name", "WRONG VALUE"), # This is wrong
|
|
443
|
+
],
|
|
444
|
+
"no_other_changes": False, # Allows status to change unvalidated
|
|
445
|
+
},
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
finally:
|
|
450
|
+
os.unlink(before_db)
|
|
451
|
+
os.unlink(after_db)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_modification_missing_no_other_changes_raises_error():
|
|
455
|
+
"""Test that missing no_other_changes field raises a ValueError"""
|
|
456
|
+
|
|
457
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
458
|
+
before_db = f.name
|
|
459
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
460
|
+
after_db = f.name
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
conn = sqlite3.connect(before_db)
|
|
464
|
+
conn.execute(
|
|
465
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
466
|
+
)
|
|
467
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
468
|
+
conn.commit()
|
|
469
|
+
conn.close()
|
|
470
|
+
|
|
471
|
+
conn = sqlite3.connect(after_db)
|
|
472
|
+
conn.execute(
|
|
473
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
474
|
+
)
|
|
475
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
476
|
+
conn.commit()
|
|
477
|
+
conn.close()
|
|
478
|
+
|
|
479
|
+
before = DatabaseSnapshot(before_db)
|
|
480
|
+
after = DatabaseSnapshot(after_db)
|
|
481
|
+
|
|
482
|
+
# Should fail because no_other_changes is missing
|
|
483
|
+
with pytest.raises(ValueError, match="missing required 'no_other_changes'"):
|
|
484
|
+
before.diff(after).expect_only_v2(
|
|
485
|
+
[
|
|
486
|
+
{
|
|
487
|
+
"table": "users",
|
|
488
|
+
"pk": 1,
|
|
489
|
+
"type": "modify",
|
|
490
|
+
"resulting_fields": [
|
|
491
|
+
("name", "Alice Updated"),
|
|
492
|
+
("status", "inactive"),
|
|
493
|
+
],
|
|
494
|
+
# no_other_changes is MISSING - should raise ValueError
|
|
495
|
+
},
|
|
496
|
+
]
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
finally:
|
|
500
|
+
os.unlink(before_db)
|
|
501
|
+
os.unlink(after_db)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_modification_with_bulk_fields_spec_ellipsis():
|
|
505
|
+
"""Test that Ellipsis works in modification bulk field specs to skip value check"""
|
|
506
|
+
|
|
507
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
508
|
+
before_db = f.name
|
|
509
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
510
|
+
after_db = f.name
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
conn = sqlite3.connect(before_db)
|
|
514
|
+
conn.execute(
|
|
515
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
516
|
+
)
|
|
517
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
518
|
+
conn.commit()
|
|
519
|
+
conn.close()
|
|
520
|
+
|
|
521
|
+
conn = sqlite3.connect(after_db)
|
|
522
|
+
conn.execute(
|
|
523
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
524
|
+
)
|
|
525
|
+
# All three fields changed
|
|
526
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-15')")
|
|
527
|
+
conn.commit()
|
|
528
|
+
conn.close()
|
|
529
|
+
|
|
530
|
+
before = DatabaseSnapshot(before_db)
|
|
531
|
+
after = DatabaseSnapshot(after_db)
|
|
532
|
+
|
|
533
|
+
# Using Ellipsis to skip value check for updated_at
|
|
534
|
+
before.diff(after).expect_only_v2(
|
|
535
|
+
[
|
|
536
|
+
{
|
|
537
|
+
"table": "users",
|
|
538
|
+
"pk": 1,
|
|
539
|
+
"type": "modify",
|
|
540
|
+
"resulting_fields": [
|
|
541
|
+
("name", "Alice Updated"),
|
|
542
|
+
("status", "inactive"),
|
|
543
|
+
("updated_at", ...), # Don't check value
|
|
544
|
+
],
|
|
545
|
+
"no_other_changes": True,
|
|
546
|
+
},
|
|
547
|
+
]
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
finally:
|
|
551
|
+
os.unlink(before_db)
|
|
552
|
+
os.unlink(after_db)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def test_multiple_table_changes_with_mixed_specs():
|
|
556
|
+
"""Test complex scenario with multiple tables and mixed bulk field/whole-row specs in expect_only_v2"""
|
|
557
|
+
|
|
558
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
559
|
+
before_db = f.name
|
|
560
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
561
|
+
after_db = f.name
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
# Setup before database with multiple tables
|
|
565
|
+
conn = sqlite3.connect(before_db)
|
|
566
|
+
conn.execute(
|
|
567
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
|
|
568
|
+
)
|
|
569
|
+
conn.execute(
|
|
570
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
|
|
571
|
+
)
|
|
572
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
|
|
573
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
|
|
574
|
+
conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'pending')")
|
|
575
|
+
conn.commit()
|
|
576
|
+
conn.close()
|
|
577
|
+
|
|
578
|
+
# Setup after database with complex changes
|
|
579
|
+
conn = sqlite3.connect(after_db)
|
|
580
|
+
conn.execute(
|
|
581
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
|
|
582
|
+
)
|
|
583
|
+
conn.execute(
|
|
584
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
|
|
585
|
+
)
|
|
586
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
|
|
587
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
|
|
588
|
+
conn.execute(
|
|
589
|
+
"INSERT INTO users VALUES (3, 'Charlie', 'charlie@test.com', 'user')"
|
|
590
|
+
)
|
|
591
|
+
conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'completed')")
|
|
592
|
+
conn.execute("INSERT INTO orders VALUES (2, 2, 50.0, 'pending')")
|
|
593
|
+
conn.commit()
|
|
594
|
+
conn.close()
|
|
595
|
+
|
|
596
|
+
before = DatabaseSnapshot(before_db)
|
|
597
|
+
after = DatabaseSnapshot(after_db)
|
|
598
|
+
|
|
599
|
+
# Mixed specs: bulk fields for new user, bulk fields for modification, and whole-row for new order
|
|
600
|
+
before.diff(after).expect_only_v2(
|
|
601
|
+
[
|
|
602
|
+
# Bulk field specs for new user
|
|
603
|
+
{
|
|
604
|
+
"table": "users",
|
|
605
|
+
"pk": 3,
|
|
606
|
+
"type": "insert",
|
|
607
|
+
"fields": [
|
|
608
|
+
("id", 3),
|
|
609
|
+
("name", "Charlie"),
|
|
610
|
+
("email", "charlie@test.com"),
|
|
611
|
+
("role", "user"),
|
|
612
|
+
],
|
|
613
|
+
},
|
|
614
|
+
# Bulk field specs for order status modification (using new format)
|
|
615
|
+
{
|
|
616
|
+
"table": "orders",
|
|
617
|
+
"pk": 1,
|
|
618
|
+
"type": "modify",
|
|
619
|
+
"resulting_fields": [("status", "completed")],
|
|
620
|
+
"no_other_changes": True,
|
|
621
|
+
},
|
|
622
|
+
# Whole-row spec for new order (legacy)
|
|
623
|
+
{"table": "orders", "pk": 2, "fields": None, "after": "__added__"},
|
|
624
|
+
]
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
finally:
|
|
628
|
+
os.unlink(before_db)
|
|
629
|
+
os.unlink(after_db)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def test_partial_field_specs_with_unexpected_changes():
|
|
633
|
+
"""Test that partial field specs catch unexpected changes in unspecified fields"""
|
|
634
|
+
|
|
635
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
636
|
+
before_db = f.name
|
|
637
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
638
|
+
after_db = f.name
|
|
639
|
+
|
|
640
|
+
try:
|
|
641
|
+
conn = sqlite3.connect(before_db)
|
|
642
|
+
conn.execute(
|
|
643
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, category TEXT, stock INTEGER)"
|
|
644
|
+
)
|
|
645
|
+
conn.execute(
|
|
646
|
+
"INSERT INTO products VALUES (1, 'Widget', 10.99, 'electronics', 100)"
|
|
647
|
+
)
|
|
648
|
+
conn.commit()
|
|
649
|
+
conn.close()
|
|
650
|
+
|
|
651
|
+
conn = sqlite3.connect(after_db)
|
|
652
|
+
conn.execute(
|
|
653
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, category TEXT, stock INTEGER)"
|
|
654
|
+
)
|
|
655
|
+
conn.execute(
|
|
656
|
+
"INSERT INTO products VALUES (1, 'Widget', 12.99, 'electronics', 95)"
|
|
657
|
+
)
|
|
658
|
+
conn.commit()
|
|
659
|
+
conn.close()
|
|
660
|
+
|
|
661
|
+
before = DatabaseSnapshot(before_db)
|
|
662
|
+
after = DatabaseSnapshot(after_db)
|
|
663
|
+
|
|
664
|
+
# Only specify price change, but stock also changed - should fail
|
|
665
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
666
|
+
before.diff(after).expect_only(
|
|
667
|
+
[
|
|
668
|
+
{"table": "products", "pk": 1, "field": "price", "after": 12.99},
|
|
669
|
+
]
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
finally:
|
|
673
|
+
os.unlink(before_db)
|
|
674
|
+
os.unlink(after_db)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_numeric_type_conversion_in_specs():
|
|
678
|
+
"""Test that numeric type conversions work correctly in bulk field specs with expect_only_v2"""
|
|
679
|
+
|
|
680
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
681
|
+
before_db = f.name
|
|
682
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
683
|
+
after_db = f.name
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
conn = sqlite3.connect(before_db)
|
|
687
|
+
conn.execute(
|
|
688
|
+
"CREATE TABLE metrics (id INTEGER PRIMARY KEY, value REAL, count INTEGER)"
|
|
689
|
+
)
|
|
690
|
+
conn.execute("INSERT INTO metrics VALUES (1, 3.14, 42)")
|
|
691
|
+
conn.commit()
|
|
692
|
+
conn.close()
|
|
693
|
+
|
|
694
|
+
conn = sqlite3.connect(after_db)
|
|
695
|
+
conn.execute(
|
|
696
|
+
"CREATE TABLE metrics (id INTEGER PRIMARY KEY, value REAL, count INTEGER)"
|
|
697
|
+
)
|
|
698
|
+
conn.execute("INSERT INTO metrics VALUES (1, 3.14, 42)")
|
|
699
|
+
conn.execute("INSERT INTO metrics VALUES (2, 2.71, 17)")
|
|
700
|
+
conn.commit()
|
|
701
|
+
conn.close()
|
|
702
|
+
|
|
703
|
+
before = DatabaseSnapshot(before_db)
|
|
704
|
+
after = DatabaseSnapshot(after_db)
|
|
705
|
+
|
|
706
|
+
# Test string vs integer comparison for primary key
|
|
707
|
+
before.diff(after).expect_only_v2(
|
|
708
|
+
[
|
|
709
|
+
{
|
|
710
|
+
"table": "metrics",
|
|
711
|
+
"pk": "2",
|
|
712
|
+
"type": "insert",
|
|
713
|
+
"fields": [("id", 2), ("value", 2.71), ("count", 17)],
|
|
714
|
+
},
|
|
715
|
+
]
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
finally:
|
|
719
|
+
os.unlink(before_db)
|
|
720
|
+
os.unlink(after_db)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def test_deletion_with_field_level_specs():
|
|
724
|
+
"""Test that bulk field specs work for row deletions in expect_only_v2"""
|
|
725
|
+
|
|
726
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
727
|
+
before_db = f.name
|
|
728
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
729
|
+
after_db = f.name
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
conn = sqlite3.connect(before_db)
|
|
733
|
+
conn.execute(
|
|
734
|
+
"CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
|
|
735
|
+
)
|
|
736
|
+
conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
|
|
737
|
+
conn.execute("INSERT INTO inventory VALUES (2, 'Widget B', 5, 'Warehouse 2')")
|
|
738
|
+
conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
|
|
739
|
+
conn.commit()
|
|
740
|
+
conn.close()
|
|
741
|
+
|
|
742
|
+
conn = sqlite3.connect(after_db)
|
|
743
|
+
conn.execute(
|
|
744
|
+
"CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
|
|
745
|
+
)
|
|
746
|
+
conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
|
|
747
|
+
conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
|
|
748
|
+
conn.commit()
|
|
749
|
+
conn.close()
|
|
750
|
+
|
|
751
|
+
before = DatabaseSnapshot(before_db)
|
|
752
|
+
after = DatabaseSnapshot(after_db)
|
|
753
|
+
|
|
754
|
+
# Bulk field specs for deleted row (with "type": "delete")
|
|
755
|
+
before.diff(after).expect_only_v2(
|
|
756
|
+
[
|
|
757
|
+
{
|
|
758
|
+
"table": "inventory",
|
|
759
|
+
"pk": 2,
|
|
760
|
+
"type": "delete",
|
|
761
|
+
"fields": [
|
|
762
|
+
("id", 2),
|
|
763
|
+
("item", "Widget B"),
|
|
764
|
+
("quantity", 5),
|
|
765
|
+
("location", "Warehouse 2"),
|
|
766
|
+
],
|
|
767
|
+
},
|
|
768
|
+
# also do a whole-row check (legacy)
|
|
769
|
+
{
|
|
770
|
+
"table": "inventory",
|
|
771
|
+
"pk": 2,
|
|
772
|
+
"fields": None,
|
|
773
|
+
"after": "__removed__",
|
|
774
|
+
},
|
|
775
|
+
]
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
finally:
|
|
779
|
+
os.unlink(before_db)
|
|
780
|
+
os.unlink(after_db)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def test_mixed_data_types_and_null_values():
|
|
784
|
+
"""Test bulk field specs with mixed data types and null values in expect_only_v2"""
|
|
785
|
+
|
|
786
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
787
|
+
before_db = f.name
|
|
788
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
789
|
+
after_db = f.name
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
conn = sqlite3.connect(before_db)
|
|
793
|
+
conn.execute(
|
|
794
|
+
"CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
|
|
795
|
+
)
|
|
796
|
+
conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
|
|
797
|
+
conn.commit()
|
|
798
|
+
conn.close()
|
|
799
|
+
|
|
800
|
+
conn = sqlite3.connect(after_db)
|
|
801
|
+
conn.execute(
|
|
802
|
+
"CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
|
|
803
|
+
)
|
|
804
|
+
conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
|
|
805
|
+
conn.execute("INSERT INTO mixed_data VALUES (2, NULL, 0.0, 0, 'not_null')")
|
|
806
|
+
conn.commit()
|
|
807
|
+
conn.close()
|
|
808
|
+
|
|
809
|
+
before = DatabaseSnapshot(before_db)
|
|
810
|
+
after = DatabaseSnapshot(after_db)
|
|
811
|
+
|
|
812
|
+
# Test various data types and null handling
|
|
813
|
+
# ("text_val", None) checks that the value is SQL NULL
|
|
814
|
+
# ("field", ...) means don't check the value
|
|
815
|
+
before.diff(after).expect_only_v2(
|
|
816
|
+
[
|
|
817
|
+
{
|
|
818
|
+
"table": "mixed_data",
|
|
819
|
+
"pk": 2,
|
|
820
|
+
"type": "insert",
|
|
821
|
+
"fields": [
|
|
822
|
+
("id", 2),
|
|
823
|
+
("text_val", None), # Check that value IS NULL
|
|
824
|
+
("num_val", 0.0),
|
|
825
|
+
("bool_val", 0),
|
|
826
|
+
("null_val", "not_null"),
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
]
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
finally:
|
|
833
|
+
os.unlink(before_db)
|
|
834
|
+
os.unlink(after_db)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def test_whole_row_spec_backward_compat():
|
|
838
|
+
"""Test that whole-row specs still work (backward compatibility)"""
|
|
839
|
+
|
|
840
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
841
|
+
before_db = f.name
|
|
842
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
843
|
+
after_db = f.name
|
|
844
|
+
|
|
845
|
+
try:
|
|
846
|
+
conn = sqlite3.connect(before_db)
|
|
847
|
+
conn.execute(
|
|
848
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
849
|
+
)
|
|
850
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
851
|
+
conn.commit()
|
|
852
|
+
conn.close()
|
|
853
|
+
|
|
854
|
+
conn = sqlite3.connect(after_db)
|
|
855
|
+
conn.execute(
|
|
856
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
857
|
+
)
|
|
858
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
859
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
860
|
+
conn.commit()
|
|
861
|
+
conn.close()
|
|
862
|
+
|
|
863
|
+
before = DatabaseSnapshot(before_db)
|
|
864
|
+
after = DatabaseSnapshot(after_db)
|
|
865
|
+
|
|
866
|
+
# Whole-row spec should still work
|
|
867
|
+
before.diff(after).expect_only(
|
|
868
|
+
[{"table": "users", "pk": 2, "fields": None, "after": "__added__"}]
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
finally:
|
|
872
|
+
os.unlink(before_db)
|
|
873
|
+
os.unlink(after_db)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_missing_field_specs():
|
|
877
|
+
"""Test that missing fields in bulk field specs are detected in expect_only_v2"""
|
|
878
|
+
|
|
879
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
880
|
+
before_db = f.name
|
|
881
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
882
|
+
after_db = f.name
|
|
883
|
+
|
|
884
|
+
try:
|
|
885
|
+
conn = sqlite3.connect(before_db)
|
|
886
|
+
conn.execute(
|
|
887
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
888
|
+
)
|
|
889
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
890
|
+
conn.commit()
|
|
891
|
+
conn.close()
|
|
892
|
+
|
|
893
|
+
conn = sqlite3.connect(after_db)
|
|
894
|
+
conn.execute(
|
|
895
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
896
|
+
)
|
|
897
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
898
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
899
|
+
conn.commit()
|
|
900
|
+
conn.close()
|
|
901
|
+
|
|
902
|
+
before = DatabaseSnapshot(before_db)
|
|
903
|
+
after = DatabaseSnapshot(after_db)
|
|
904
|
+
|
|
905
|
+
# Should fail because status field is missing from the fields spec
|
|
906
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
907
|
+
before.diff(after).expect_only_v2(
|
|
908
|
+
[
|
|
909
|
+
{
|
|
910
|
+
"table": "users",
|
|
911
|
+
"pk": 2,
|
|
912
|
+
"type": "insert",
|
|
913
|
+
"fields": [
|
|
914
|
+
("id", 2),
|
|
915
|
+
("name", "Bob"),
|
|
916
|
+
# Missing status field - should fail
|
|
917
|
+
],
|
|
918
|
+
},
|
|
919
|
+
]
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
finally:
|
|
923
|
+
os.unlink(before_db)
|
|
924
|
+
os.unlink(after_db)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def test_modified_row_with_unauthorized_field_change():
|
|
928
|
+
"""Test that unauthorized changes to existing rows are detected"""
|
|
929
|
+
|
|
930
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
931
|
+
before_db = f.name
|
|
932
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
933
|
+
after_db = f.name
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
conn = sqlite3.connect(before_db)
|
|
937
|
+
conn.execute(
|
|
938
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
939
|
+
)
|
|
940
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
941
|
+
conn.commit()
|
|
942
|
+
conn.close()
|
|
943
|
+
|
|
944
|
+
conn = sqlite3.connect(after_db)
|
|
945
|
+
conn.execute(
|
|
946
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
947
|
+
)
|
|
948
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'suspended')")
|
|
949
|
+
conn.commit()
|
|
950
|
+
conn.close()
|
|
951
|
+
|
|
952
|
+
before = DatabaseSnapshot(before_db)
|
|
953
|
+
after = DatabaseSnapshot(after_db)
|
|
954
|
+
|
|
955
|
+
# Should fail because status change is not allowed
|
|
956
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
957
|
+
before.diff(after).expect_only(
|
|
958
|
+
[
|
|
959
|
+
{
|
|
960
|
+
"table": "users",
|
|
961
|
+
"pk": 1,
|
|
962
|
+
"field": "name",
|
|
963
|
+
"after": "Alice Updated",
|
|
964
|
+
},
|
|
965
|
+
# Missing status field spec - status should not have changed
|
|
966
|
+
]
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
finally:
|
|
970
|
+
os.unlink(before_db)
|
|
971
|
+
os.unlink(after_db)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def test_fields_spec_basic():
|
|
975
|
+
"""Test that bulk fields spec works correctly for added rows in expect_only_v2"""
|
|
976
|
+
|
|
977
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
978
|
+
before_db = f.name
|
|
979
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
980
|
+
after_db = f.name
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
conn = sqlite3.connect(before_db)
|
|
984
|
+
conn.execute(
|
|
985
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
986
|
+
)
|
|
987
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
988
|
+
conn.commit()
|
|
989
|
+
conn.close()
|
|
990
|
+
|
|
991
|
+
conn = sqlite3.connect(after_db)
|
|
992
|
+
conn.execute(
|
|
993
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
994
|
+
)
|
|
995
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
996
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
997
|
+
conn.commit()
|
|
998
|
+
conn.close()
|
|
999
|
+
|
|
1000
|
+
before = DatabaseSnapshot(before_db)
|
|
1001
|
+
after = DatabaseSnapshot(after_db)
|
|
1002
|
+
|
|
1003
|
+
# Test: All fields specified with exact values - should pass
|
|
1004
|
+
before.diff(after).expect_only_v2(
|
|
1005
|
+
[
|
|
1006
|
+
{
|
|
1007
|
+
"table": "users",
|
|
1008
|
+
"pk": 2,
|
|
1009
|
+
"type": "insert",
|
|
1010
|
+
"fields": [
|
|
1011
|
+
("id", 2),
|
|
1012
|
+
("name", "Bob"),
|
|
1013
|
+
("status", "inactive"),
|
|
1014
|
+
("updated_at", "2024-01-02"),
|
|
1015
|
+
],
|
|
1016
|
+
},
|
|
1017
|
+
]
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
finally:
|
|
1021
|
+
os.unlink(before_db)
|
|
1022
|
+
os.unlink(after_db)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def test_fields_spec_with_ellipsis_means_dont_check():
|
|
1026
|
+
"""Test that Ellipsis (...) in a 2-tuple means 'don't check this field's value'"""
|
|
1027
|
+
|
|
1028
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1029
|
+
before_db = f.name
|
|
1030
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1031
|
+
after_db = f.name
|
|
1032
|
+
|
|
1033
|
+
try:
|
|
1034
|
+
conn = sqlite3.connect(before_db)
|
|
1035
|
+
conn.execute(
|
|
1036
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1037
|
+
)
|
|
1038
|
+
conn.commit()
|
|
1039
|
+
conn.close()
|
|
1040
|
+
|
|
1041
|
+
conn = sqlite3.connect(after_db)
|
|
1042
|
+
conn.execute(
|
|
1043
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1044
|
+
)
|
|
1045
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1046
|
+
conn.commit()
|
|
1047
|
+
conn.close()
|
|
1048
|
+
|
|
1049
|
+
before = DatabaseSnapshot(before_db)
|
|
1050
|
+
after = DatabaseSnapshot(after_db)
|
|
1051
|
+
|
|
1052
|
+
# Test: Using Ellipsis means don't check the value - should pass
|
|
1053
|
+
# even though updated_at is '2024-01-02'
|
|
1054
|
+
before.diff(after).expect_only_v2(
|
|
1055
|
+
[
|
|
1056
|
+
{
|
|
1057
|
+
"table": "users",
|
|
1058
|
+
"pk": 2,
|
|
1059
|
+
"type": "insert",
|
|
1060
|
+
"fields": [
|
|
1061
|
+
("id", 2),
|
|
1062
|
+
("name", "Bob"),
|
|
1063
|
+
("status", "inactive"),
|
|
1064
|
+
("updated_at", ...), # Don't check this value
|
|
1065
|
+
],
|
|
1066
|
+
},
|
|
1067
|
+
]
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
finally:
|
|
1071
|
+
os.unlink(before_db)
|
|
1072
|
+
os.unlink(after_db)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def test_fields_spec_with_none_checks_for_null():
|
|
1076
|
+
"""Test that None in a 2-tuple means 'check that field is SQL NULL'"""
|
|
1077
|
+
|
|
1078
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1079
|
+
before_db = f.name
|
|
1080
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1081
|
+
after_db = f.name
|
|
1082
|
+
|
|
1083
|
+
try:
|
|
1084
|
+
conn = sqlite3.connect(before_db)
|
|
1085
|
+
conn.execute(
|
|
1086
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1087
|
+
)
|
|
1088
|
+
conn.commit()
|
|
1089
|
+
conn.close()
|
|
1090
|
+
|
|
1091
|
+
conn = sqlite3.connect(after_db)
|
|
1092
|
+
conn.execute(
|
|
1093
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1094
|
+
)
|
|
1095
|
+
# deleted_at is NULL
|
|
1096
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'active', NULL)")
|
|
1097
|
+
conn.commit()
|
|
1098
|
+
conn.close()
|
|
1099
|
+
|
|
1100
|
+
before = DatabaseSnapshot(before_db)
|
|
1101
|
+
after = DatabaseSnapshot(after_db)
|
|
1102
|
+
|
|
1103
|
+
# Test: Using None means check that value is SQL NULL - should pass
|
|
1104
|
+
before.diff(after).expect_only_v2(
|
|
1105
|
+
[
|
|
1106
|
+
{
|
|
1107
|
+
"table": "users",
|
|
1108
|
+
"pk": 2,
|
|
1109
|
+
"type": "insert",
|
|
1110
|
+
"fields": [
|
|
1111
|
+
("id", 2),
|
|
1112
|
+
("name", "Bob"),
|
|
1113
|
+
("status", "active"),
|
|
1114
|
+
("deleted_at", None), # Check that this IS NULL
|
|
1115
|
+
],
|
|
1116
|
+
},
|
|
1117
|
+
]
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
finally:
|
|
1121
|
+
os.unlink(before_db)
|
|
1122
|
+
os.unlink(after_db)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def test_fields_spec_with_none_fails_when_not_null():
|
|
1126
|
+
"""Test that None check fails when field is not actually NULL"""
|
|
1127
|
+
|
|
1128
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1129
|
+
before_db = f.name
|
|
1130
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1131
|
+
after_db = f.name
|
|
1132
|
+
|
|
1133
|
+
try:
|
|
1134
|
+
conn = sqlite3.connect(before_db)
|
|
1135
|
+
conn.execute(
|
|
1136
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1137
|
+
)
|
|
1138
|
+
conn.commit()
|
|
1139
|
+
conn.close()
|
|
1140
|
+
|
|
1141
|
+
conn = sqlite3.connect(after_db)
|
|
1142
|
+
conn.execute(
|
|
1143
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1144
|
+
)
|
|
1145
|
+
# deleted_at is NOT NULL - has a value
|
|
1146
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'active', '2024-01-15')")
|
|
1147
|
+
conn.commit()
|
|
1148
|
+
conn.close()
|
|
1149
|
+
|
|
1150
|
+
before = DatabaseSnapshot(before_db)
|
|
1151
|
+
after = DatabaseSnapshot(after_db)
|
|
1152
|
+
|
|
1153
|
+
# Test: Using None to check for NULL, but field is NOT NULL - should fail
|
|
1154
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1155
|
+
before.diff(after).expect_only_v2(
|
|
1156
|
+
[
|
|
1157
|
+
{
|
|
1158
|
+
"table": "users",
|
|
1159
|
+
"pk": 2,
|
|
1160
|
+
"type": "insert",
|
|
1161
|
+
"fields": [
|
|
1162
|
+
("id", 2),
|
|
1163
|
+
("name", "Bob"),
|
|
1164
|
+
("status", "active"),
|
|
1165
|
+
("deleted_at", None), # Expect NULL, but actual is '2024-01-15'
|
|
1166
|
+
],
|
|
1167
|
+
},
|
|
1168
|
+
]
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
assert "deleted_at" in str(exc_info.value)
|
|
1172
|
+
assert "expected None" in str(exc_info.value)
|
|
1173
|
+
|
|
1174
|
+
finally:
|
|
1175
|
+
os.unlink(before_db)
|
|
1176
|
+
os.unlink(after_db)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def test_fields_spec_1_tuple_raises_error():
|
|
1180
|
+
"""Test that a 1-tuple raises an error (use Ellipsis instead)"""
|
|
1181
|
+
|
|
1182
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1183
|
+
before_db = f.name
|
|
1184
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1185
|
+
after_db = f.name
|
|
1186
|
+
|
|
1187
|
+
try:
|
|
1188
|
+
conn = sqlite3.connect(before_db)
|
|
1189
|
+
conn.execute(
|
|
1190
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1191
|
+
)
|
|
1192
|
+
conn.commit()
|
|
1193
|
+
conn.close()
|
|
1194
|
+
|
|
1195
|
+
conn = sqlite3.connect(after_db)
|
|
1196
|
+
conn.execute(
|
|
1197
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1198
|
+
)
|
|
1199
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1200
|
+
conn.commit()
|
|
1201
|
+
conn.close()
|
|
1202
|
+
|
|
1203
|
+
before = DatabaseSnapshot(before_db)
|
|
1204
|
+
after = DatabaseSnapshot(after_db)
|
|
1205
|
+
|
|
1206
|
+
# Test: 1-tuple is no longer supported - should raise ValueError
|
|
1207
|
+
with pytest.raises(ValueError, match="Invalid field spec tuple"):
|
|
1208
|
+
before.diff(after).expect_only_v2(
|
|
1209
|
+
[
|
|
1210
|
+
{
|
|
1211
|
+
"table": "users",
|
|
1212
|
+
"pk": 2,
|
|
1213
|
+
"type": "insert",
|
|
1214
|
+
"fields": [
|
|
1215
|
+
("id", 2),
|
|
1216
|
+
("name", "Bob"),
|
|
1217
|
+
("status",), # 1-tuple: NOT SUPPORTED - use ("status", ...) instead
|
|
1218
|
+
("updated_at",),
|
|
1219
|
+
],
|
|
1220
|
+
},
|
|
1221
|
+
]
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
finally:
|
|
1225
|
+
os.unlink(before_db)
|
|
1226
|
+
os.unlink(after_db)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def test_fields_spec_missing_field_fails():
|
|
1230
|
+
"""Test that missing a field in the fields spec causes validation to fail"""
|
|
1231
|
+
|
|
1232
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1233
|
+
before_db = f.name
|
|
1234
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1235
|
+
after_db = f.name
|
|
1236
|
+
|
|
1237
|
+
try:
|
|
1238
|
+
conn = sqlite3.connect(before_db)
|
|
1239
|
+
conn.execute(
|
|
1240
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1241
|
+
)
|
|
1242
|
+
conn.commit()
|
|
1243
|
+
conn.close()
|
|
1244
|
+
|
|
1245
|
+
conn = sqlite3.connect(after_db)
|
|
1246
|
+
conn.execute(
|
|
1247
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1248
|
+
)
|
|
1249
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1250
|
+
conn.commit()
|
|
1251
|
+
conn.close()
|
|
1252
|
+
|
|
1253
|
+
before = DatabaseSnapshot(before_db)
|
|
1254
|
+
after = DatabaseSnapshot(after_db)
|
|
1255
|
+
|
|
1256
|
+
# Test: Missing 'status' field should cause validation to fail
|
|
1257
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1258
|
+
before.diff(after).expect_only_v2(
|
|
1259
|
+
[
|
|
1260
|
+
{
|
|
1261
|
+
"table": "users",
|
|
1262
|
+
"pk": 2,
|
|
1263
|
+
"type": "insert",
|
|
1264
|
+
"fields": [
|
|
1265
|
+
("id", 2),
|
|
1266
|
+
("name", "Bob"),
|
|
1267
|
+
# status is MISSING - should fail
|
|
1268
|
+
("updated_at", ...), # Don't check this value
|
|
1269
|
+
],
|
|
1270
|
+
},
|
|
1271
|
+
]
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
assert "status" in str(exc_info.value)
|
|
1275
|
+
assert "NOT_IN_FIELDS_SPEC" in str(exc_info.value)
|
|
1276
|
+
|
|
1277
|
+
finally:
|
|
1278
|
+
os.unlink(before_db)
|
|
1279
|
+
os.unlink(after_db)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def test_fields_spec_wrong_value_fails():
|
|
1283
|
+
"""Test that wrong field value in fields spec causes validation to fail"""
|
|
1284
|
+
|
|
1285
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1286
|
+
before_db = f.name
|
|
1287
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1288
|
+
after_db = f.name
|
|
1289
|
+
|
|
1290
|
+
try:
|
|
1291
|
+
conn = sqlite3.connect(before_db)
|
|
1292
|
+
conn.execute(
|
|
1293
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1294
|
+
)
|
|
1295
|
+
conn.commit()
|
|
1296
|
+
conn.close()
|
|
1297
|
+
|
|
1298
|
+
conn = sqlite3.connect(after_db)
|
|
1299
|
+
conn.execute(
|
|
1300
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1301
|
+
)
|
|
1302
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1303
|
+
conn.commit()
|
|
1304
|
+
conn.close()
|
|
1305
|
+
|
|
1306
|
+
before = DatabaseSnapshot(before_db)
|
|
1307
|
+
after = DatabaseSnapshot(after_db)
|
|
1308
|
+
|
|
1309
|
+
# Test: Wrong value for 'status' should fail
|
|
1310
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1311
|
+
before.diff(after).expect_only_v2(
|
|
1312
|
+
[
|
|
1313
|
+
{
|
|
1314
|
+
"table": "users",
|
|
1315
|
+
"pk": 2,
|
|
1316
|
+
"type": "insert",
|
|
1317
|
+
"fields": [
|
|
1318
|
+
("id", 2),
|
|
1319
|
+
("name", "Bob"),
|
|
1320
|
+
("status", "active"), # Wrong value - row has 'inactive'
|
|
1321
|
+
("updated_at", ...), # Don't check this value
|
|
1322
|
+
],
|
|
1323
|
+
},
|
|
1324
|
+
]
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
assert "status" in str(exc_info.value)
|
|
1328
|
+
assert "expected 'active'" in str(exc_info.value)
|
|
1329
|
+
|
|
1330
|
+
finally:
|
|
1331
|
+
os.unlink(before_db)
|
|
1332
|
+
os.unlink(after_db)
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def test_fields_spec_with_ignore_config():
|
|
1336
|
+
"""Test that ignore_config works correctly with bulk fields spec"""
|
|
1337
|
+
|
|
1338
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1339
|
+
before_db = f.name
|
|
1340
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1341
|
+
after_db = f.name
|
|
1342
|
+
|
|
1343
|
+
try:
|
|
1344
|
+
conn = sqlite3.connect(before_db)
|
|
1345
|
+
conn.execute(
|
|
1346
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1347
|
+
)
|
|
1348
|
+
conn.commit()
|
|
1349
|
+
conn.close()
|
|
1350
|
+
|
|
1351
|
+
conn = sqlite3.connect(after_db)
|
|
1352
|
+
conn.execute(
|
|
1353
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1354
|
+
)
|
|
1355
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1356
|
+
conn.commit()
|
|
1357
|
+
conn.close()
|
|
1358
|
+
|
|
1359
|
+
before = DatabaseSnapshot(before_db)
|
|
1360
|
+
after = DatabaseSnapshot(after_db)
|
|
1361
|
+
|
|
1362
|
+
# Ignore the updated_at field globally
|
|
1363
|
+
ignore_config = IgnoreConfig(table_fields={"users": {"updated_at"}})
|
|
1364
|
+
|
|
1365
|
+
# Test: With ignore_config, we don't need to specify updated_at
|
|
1366
|
+
before.diff(after, ignore_config).expect_only_v2(
|
|
1367
|
+
[
|
|
1368
|
+
{
|
|
1369
|
+
"table": "users",
|
|
1370
|
+
"pk": 2,
|
|
1371
|
+
"type": "insert",
|
|
1372
|
+
"fields": [
|
|
1373
|
+
("id", 2),
|
|
1374
|
+
("name", "Bob"),
|
|
1375
|
+
("status", "inactive"),
|
|
1376
|
+
# updated_at is ignored, so we don't need to specify it
|
|
1377
|
+
],
|
|
1378
|
+
},
|
|
1379
|
+
]
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
finally:
|
|
1383
|
+
os.unlink(before_db)
|
|
1384
|
+
os.unlink(after_db)
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
# ============================================================================
|
|
1388
|
+
# Tests demonstrating expect_only vs expect_only_v2 behavior
|
|
1389
|
+
# These tests show cases where expect_only (whole-row only) is more permissive
|
|
1390
|
+
# than expect_only_v2 (field-level specs).
|
|
1391
|
+
# ============================================================================
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def test_security_whole_row_spec_allows_any_values():
|
|
1395
|
+
"""
|
|
1396
|
+
expect_only with whole-row specs allows ANY field values.
|
|
1397
|
+
|
|
1398
|
+
This demonstrates that expect_only with field=None (whole-row spec)
|
|
1399
|
+
is permissive - it only checks that a row was added, not what values it has.
|
|
1400
|
+
Use expect_only_v2 with field-level specs for stricter validation.
|
|
1401
|
+
"""
|
|
1402
|
+
|
|
1403
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1404
|
+
before_db = f.name
|
|
1405
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1406
|
+
after_db = f.name
|
|
1407
|
+
|
|
1408
|
+
try:
|
|
1409
|
+
conn = sqlite3.connect(before_db)
|
|
1410
|
+
conn.execute(
|
|
1411
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1412
|
+
)
|
|
1413
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1414
|
+
conn.commit()
|
|
1415
|
+
conn.close()
|
|
1416
|
+
|
|
1417
|
+
conn = sqlite3.connect(after_db)
|
|
1418
|
+
conn.execute(
|
|
1419
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1420
|
+
)
|
|
1421
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1422
|
+
# User added with admin role
|
|
1423
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'admin', 1)")
|
|
1424
|
+
conn.commit()
|
|
1425
|
+
conn.close()
|
|
1426
|
+
|
|
1427
|
+
before = DatabaseSnapshot(before_db)
|
|
1428
|
+
after = DatabaseSnapshot(after_db)
|
|
1429
|
+
|
|
1430
|
+
# expect_only with whole-row spec passes - doesn't check field values
|
|
1431
|
+
before.diff(after).expect_only(
|
|
1432
|
+
[{"table": "users", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
finally:
|
|
1436
|
+
os.unlink(before_db)
|
|
1437
|
+
os.unlink(after_db)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def test_security_field_level_specs_catch_wrong_role():
|
|
1441
|
+
"""
|
|
1442
|
+
expect_only_v2 with bulk field specs catches unauthorized values.
|
|
1443
|
+
|
|
1444
|
+
If someone tries to add a user with 'admin' role when we expected 'user',
|
|
1445
|
+
expect_only_v2 will catch it.
|
|
1446
|
+
"""
|
|
1447
|
+
|
|
1448
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1449
|
+
before_db = f.name
|
|
1450
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1451
|
+
after_db = f.name
|
|
1452
|
+
|
|
1453
|
+
try:
|
|
1454
|
+
conn = sqlite3.connect(before_db)
|
|
1455
|
+
conn.execute(
|
|
1456
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1457
|
+
)
|
|
1458
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1459
|
+
conn.commit()
|
|
1460
|
+
conn.close()
|
|
1461
|
+
|
|
1462
|
+
conn = sqlite3.connect(after_db)
|
|
1463
|
+
conn.execute(
|
|
1464
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1465
|
+
)
|
|
1466
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1467
|
+
# User added with admin role
|
|
1468
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'admin', 1)")
|
|
1469
|
+
conn.commit()
|
|
1470
|
+
conn.close()
|
|
1471
|
+
|
|
1472
|
+
before = DatabaseSnapshot(before_db)
|
|
1473
|
+
after = DatabaseSnapshot(after_db)
|
|
1474
|
+
|
|
1475
|
+
# expect_only_v2 correctly FAILS because role is 'admin' not 'user'
|
|
1476
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1477
|
+
before.diff(after).expect_only_v2(
|
|
1478
|
+
[
|
|
1479
|
+
{
|
|
1480
|
+
"table": "users",
|
|
1481
|
+
"pk": 2,
|
|
1482
|
+
"type": "insert",
|
|
1483
|
+
"fields": [
|
|
1484
|
+
("id", 2),
|
|
1485
|
+
("name", "Bob"),
|
|
1486
|
+
("role", "user"), # Expected 'user', but actual is 'admin'
|
|
1487
|
+
("active", 1),
|
|
1488
|
+
],
|
|
1489
|
+
},
|
|
1490
|
+
]
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
finally:
|
|
1494
|
+
os.unlink(before_db)
|
|
1495
|
+
os.unlink(after_db)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def test_financial_data_validation():
|
|
1499
|
+
"""
|
|
1500
|
+
Demonstrates difference between expect_only and expect_only_v2 for financial data.
|
|
1501
|
+
"""
|
|
1502
|
+
|
|
1503
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1504
|
+
before_db = f.name
|
|
1505
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1506
|
+
after_db = f.name
|
|
1507
|
+
|
|
1508
|
+
try:
|
|
1509
|
+
conn = sqlite3.connect(before_db)
|
|
1510
|
+
conn.execute(
|
|
1511
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
|
|
1512
|
+
)
|
|
1513
|
+
conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
|
|
1514
|
+
conn.commit()
|
|
1515
|
+
conn.close()
|
|
1516
|
+
|
|
1517
|
+
conn = sqlite3.connect(after_db)
|
|
1518
|
+
conn.execute(
|
|
1519
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
|
|
1520
|
+
)
|
|
1521
|
+
conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
|
|
1522
|
+
# Order with 100% discount
|
|
1523
|
+
conn.execute("INSERT INTO orders VALUES (2, 200, 1000.00, 1000.00)")
|
|
1524
|
+
conn.commit()
|
|
1525
|
+
conn.close()
|
|
1526
|
+
|
|
1527
|
+
before = DatabaseSnapshot(before_db)
|
|
1528
|
+
after = DatabaseSnapshot(after_db)
|
|
1529
|
+
|
|
1530
|
+
# expect_only with whole-row spec passes - doesn't check discount value
|
|
1531
|
+
before.diff(after).expect_only(
|
|
1532
|
+
[{"table": "orders", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
# expect_only_v2 with bulk field specs catches unexpected discount
|
|
1536
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1537
|
+
before.diff(after).expect_only_v2(
|
|
1538
|
+
[
|
|
1539
|
+
{
|
|
1540
|
+
"table": "orders",
|
|
1541
|
+
"pk": 2,
|
|
1542
|
+
"type": "insert",
|
|
1543
|
+
"fields": [
|
|
1544
|
+
("id", 2),
|
|
1545
|
+
("user_id", 200),
|
|
1546
|
+
("amount", 1000.00),
|
|
1547
|
+
("discount", 0.0), # Expected no discount, but actual is 1000.00
|
|
1548
|
+
],
|
|
1549
|
+
},
|
|
1550
|
+
]
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
finally:
|
|
1554
|
+
os.unlink(before_db)
|
|
1555
|
+
os.unlink(after_db)
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def test_permissions_validation():
|
|
1559
|
+
"""
|
|
1560
|
+
Demonstrates difference between expect_only and expect_only_v2 for permissions.
|
|
1561
|
+
"""
|
|
1562
|
+
|
|
1563
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1564
|
+
before_db = f.name
|
|
1565
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1566
|
+
after_db = f.name
|
|
1567
|
+
|
|
1568
|
+
try:
|
|
1569
|
+
conn = sqlite3.connect(before_db)
|
|
1570
|
+
conn.execute(
|
|
1571
|
+
"CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
|
|
1572
|
+
)
|
|
1573
|
+
conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
|
|
1574
|
+
conn.commit()
|
|
1575
|
+
conn.close()
|
|
1576
|
+
|
|
1577
|
+
conn = sqlite3.connect(after_db)
|
|
1578
|
+
conn.execute(
|
|
1579
|
+
"CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
|
|
1580
|
+
)
|
|
1581
|
+
conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
|
|
1582
|
+
# Grant full permissions including delete
|
|
1583
|
+
conn.execute("INSERT INTO permissions VALUES (2, 200, 'admin_panel', 1, 1, 1)")
|
|
1584
|
+
conn.commit()
|
|
1585
|
+
conn.close()
|
|
1586
|
+
|
|
1587
|
+
before = DatabaseSnapshot(before_db)
|
|
1588
|
+
after = DatabaseSnapshot(after_db)
|
|
1589
|
+
|
|
1590
|
+
# expect_only with whole-row spec passes - doesn't check permission values
|
|
1591
|
+
before.diff(after).expect_only(
|
|
1592
|
+
[{"table": "permissions", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
# expect_only_v2 with bulk field specs catches unexpected delete permission
|
|
1596
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1597
|
+
before.diff(after).expect_only_v2(
|
|
1598
|
+
[
|
|
1599
|
+
{
|
|
1600
|
+
"table": "permissions",
|
|
1601
|
+
"pk": 2,
|
|
1602
|
+
"type": "insert",
|
|
1603
|
+
"fields": [
|
|
1604
|
+
("id", 2),
|
|
1605
|
+
("user_id", 200),
|
|
1606
|
+
("resource", "admin_panel"),
|
|
1607
|
+
("can_read", 1),
|
|
1608
|
+
("can_write", 1),
|
|
1609
|
+
("can_delete", 0), # Expected NO delete, but actual is 1
|
|
1610
|
+
],
|
|
1611
|
+
},
|
|
1612
|
+
]
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
finally:
|
|
1616
|
+
os.unlink(before_db)
|
|
1617
|
+
os.unlink(after_db)
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def test_json_field_validation():
|
|
1621
|
+
"""
|
|
1622
|
+
Demonstrates difference between expect_only and expect_only_v2 for JSON/text fields.
|
|
1623
|
+
"""
|
|
1624
|
+
|
|
1625
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1626
|
+
before_db = f.name
|
|
1627
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1628
|
+
after_db = f.name
|
|
1629
|
+
|
|
1630
|
+
try:
|
|
1631
|
+
conn = sqlite3.connect(before_db)
|
|
1632
|
+
conn.execute(
|
|
1633
|
+
"CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
|
|
1634
|
+
)
|
|
1635
|
+
conn.execute(
|
|
1636
|
+
"INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
|
|
1637
|
+
)
|
|
1638
|
+
conn.commit()
|
|
1639
|
+
conn.close()
|
|
1640
|
+
|
|
1641
|
+
conn = sqlite3.connect(after_db)
|
|
1642
|
+
conn.execute(
|
|
1643
|
+
"CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
|
|
1644
|
+
)
|
|
1645
|
+
conn.execute(
|
|
1646
|
+
"INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
|
|
1647
|
+
)
|
|
1648
|
+
# Config with different settings
|
|
1649
|
+
conn.execute(
|
|
1650
|
+
'INSERT INTO configs VALUES (2, \'user_config\', \'{"debug": true, "extra": "value"}\')'
|
|
1651
|
+
)
|
|
1652
|
+
conn.commit()
|
|
1653
|
+
conn.close()
|
|
1654
|
+
|
|
1655
|
+
before = DatabaseSnapshot(before_db)
|
|
1656
|
+
after = DatabaseSnapshot(after_db)
|
|
1657
|
+
|
|
1658
|
+
# expect_only with whole-row spec passes - doesn't check settings value
|
|
1659
|
+
before.diff(after).expect_only(
|
|
1660
|
+
[{"table": "configs", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
# expect_only_v2 with bulk field specs catches unexpected settings
|
|
1664
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1665
|
+
before.diff(after).expect_only_v2(
|
|
1666
|
+
[
|
|
1667
|
+
{
|
|
1668
|
+
"table": "configs",
|
|
1669
|
+
"pk": 2,
|
|
1670
|
+
"type": "insert",
|
|
1671
|
+
"fields": [
|
|
1672
|
+
("id", 2),
|
|
1673
|
+
("name", "user_config"),
|
|
1674
|
+
("settings", '{"debug": false}'), # Wrong value
|
|
1675
|
+
],
|
|
1676
|
+
},
|
|
1677
|
+
]
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
finally:
|
|
1681
|
+
os.unlink(before_db)
|
|
1682
|
+
os.unlink(after_db)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
# ============================================================================
|
|
1686
|
+
# Tests showing expect_only vs expect_only_v2 behavior with conflicting specs
|
|
1687
|
+
# ============================================================================
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def test_expect_only_ignores_field_specs_with_whole_row():
|
|
1691
|
+
"""
|
|
1692
|
+
expect_only with whole-row spec ignores any additional field specs.
|
|
1693
|
+
expect_only_v2 with bulk field specs validates field values.
|
|
1694
|
+
"""
|
|
1695
|
+
|
|
1696
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1697
|
+
before_db = f.name
|
|
1698
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1699
|
+
after_db = f.name
|
|
1700
|
+
|
|
1701
|
+
try:
|
|
1702
|
+
conn = sqlite3.connect(before_db)
|
|
1703
|
+
conn.execute(
|
|
1704
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
|
|
1705
|
+
)
|
|
1706
|
+
conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
|
|
1707
|
+
conn.commit()
|
|
1708
|
+
conn.close()
|
|
1709
|
+
|
|
1710
|
+
conn = sqlite3.connect(after_db)
|
|
1711
|
+
conn.execute(
|
|
1712
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
|
|
1713
|
+
)
|
|
1714
|
+
conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
|
|
1715
|
+
# Add product with price=999.99 and stock=1
|
|
1716
|
+
conn.execute("INSERT INTO products VALUES (2, 'Gadget', 999.99, 1)")
|
|
1717
|
+
conn.commit()
|
|
1718
|
+
conn.close()
|
|
1719
|
+
|
|
1720
|
+
before = DatabaseSnapshot(before_db)
|
|
1721
|
+
after = DatabaseSnapshot(after_db)
|
|
1722
|
+
|
|
1723
|
+
# expect_only with whole-row spec passes - ignores field specs
|
|
1724
|
+
before.diff(after).expect_only(
|
|
1725
|
+
[{"table": "products", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
# expect_only_v2 with wrong field values fails
|
|
1729
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1730
|
+
before.diff(after).expect_only_v2(
|
|
1731
|
+
[
|
|
1732
|
+
{
|
|
1733
|
+
"table": "products",
|
|
1734
|
+
"pk": 2,
|
|
1735
|
+
"type": "insert",
|
|
1736
|
+
"fields": [
|
|
1737
|
+
("id", 2),
|
|
1738
|
+
("name", "Gadget"),
|
|
1739
|
+
("price", 50.0), # WRONG! Actually 999.99
|
|
1740
|
+
("stock", 500), # WRONG! Actually 1
|
|
1741
|
+
],
|
|
1742
|
+
},
|
|
1743
|
+
]
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
finally:
|
|
1747
|
+
os.unlink(before_db)
|
|
1748
|
+
os.unlink(after_db)
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def test_expect_only_v2_validates_field_values():
|
|
1752
|
+
"""
|
|
1753
|
+
expect_only_v2 validates field values for added rows.
|
|
1754
|
+
"""
|
|
1755
|
+
|
|
1756
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1757
|
+
before_db = f.name
|
|
1758
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1759
|
+
after_db = f.name
|
|
1760
|
+
|
|
1761
|
+
try:
|
|
1762
|
+
conn = sqlite3.connect(before_db)
|
|
1763
|
+
conn.execute(
|
|
1764
|
+
"CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
|
|
1765
|
+
)
|
|
1766
|
+
conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
|
|
1767
|
+
conn.commit()
|
|
1768
|
+
conn.close()
|
|
1769
|
+
|
|
1770
|
+
conn = sqlite3.connect(after_db)
|
|
1771
|
+
conn.execute(
|
|
1772
|
+
"CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
|
|
1773
|
+
)
|
|
1774
|
+
conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
|
|
1775
|
+
# Actual: role=admin, balance=1000000.0
|
|
1776
|
+
conn.execute("INSERT INTO accounts VALUES (2, 'bob', 'admin', 1000000.0)")
|
|
1777
|
+
conn.commit()
|
|
1778
|
+
conn.close()
|
|
1779
|
+
|
|
1780
|
+
before = DatabaseSnapshot(before_db)
|
|
1781
|
+
after = DatabaseSnapshot(after_db)
|
|
1782
|
+
|
|
1783
|
+
# expect_only with whole-row spec passes
|
|
1784
|
+
before.diff(after).expect_only(
|
|
1785
|
+
[{"table": "accounts", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
# expect_only_v2 with wrong field values fails
|
|
1789
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1790
|
+
before.diff(after).expect_only_v2(
|
|
1791
|
+
[
|
|
1792
|
+
{
|
|
1793
|
+
"table": "accounts",
|
|
1794
|
+
"pk": 2,
|
|
1795
|
+
"type": "insert",
|
|
1796
|
+
"fields": [
|
|
1797
|
+
("id", 2),
|
|
1798
|
+
("username", "bob"),
|
|
1799
|
+
("role", "user"), # Actually "admin"!
|
|
1800
|
+
("balance", 0.0), # Actually 1000000.0!
|
|
1801
|
+
],
|
|
1802
|
+
},
|
|
1803
|
+
]
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
finally:
|
|
1807
|
+
os.unlink(before_db)
|
|
1808
|
+
os.unlink(after_db)
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def test_expect_only_v2_validates_is_public():
|
|
1812
|
+
"""
|
|
1813
|
+
expect_only_v2 validates field values including boolean-like fields.
|
|
1814
|
+
"""
|
|
1815
|
+
|
|
1816
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1817
|
+
before_db = f.name
|
|
1818
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1819
|
+
after_db = f.name
|
|
1820
|
+
|
|
1821
|
+
try:
|
|
1822
|
+
conn = sqlite3.connect(before_db)
|
|
1823
|
+
conn.execute(
|
|
1824
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
|
|
1825
|
+
)
|
|
1826
|
+
conn.commit()
|
|
1827
|
+
conn.close()
|
|
1828
|
+
|
|
1829
|
+
conn = sqlite3.connect(after_db)
|
|
1830
|
+
conn.execute(
|
|
1831
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
|
|
1832
|
+
)
|
|
1833
|
+
# Add a setting with is_public=1
|
|
1834
|
+
conn.execute(
|
|
1835
|
+
"INSERT INTO settings VALUES (1, 'api_key', 'secret123', 1)"
|
|
1836
|
+
)
|
|
1837
|
+
conn.commit()
|
|
1838
|
+
conn.close()
|
|
1839
|
+
|
|
1840
|
+
before = DatabaseSnapshot(before_db)
|
|
1841
|
+
after = DatabaseSnapshot(after_db)
|
|
1842
|
+
|
|
1843
|
+
# expect_only with whole-row spec passes
|
|
1844
|
+
before.diff(after).expect_only(
|
|
1845
|
+
[{"table": "settings", "pk": 1, "fields": None, "after": "__added__"}]
|
|
1846
|
+
)
|
|
1847
|
+
|
|
1848
|
+
# expect_only_v2 with wrong is_public value fails
|
|
1849
|
+
with pytest.raises(AssertionError, match="Unexpected database changes"):
|
|
1850
|
+
before.diff(after).expect_only_v2(
|
|
1851
|
+
[
|
|
1852
|
+
{
|
|
1853
|
+
"table": "settings",
|
|
1854
|
+
"pk": 1,
|
|
1855
|
+
"type": "insert",
|
|
1856
|
+
"fields": [
|
|
1857
|
+
("id", 1),
|
|
1858
|
+
("key", "api_key"),
|
|
1859
|
+
("value", "secret123"),
|
|
1860
|
+
("is_public", 0), # Says private, but actually public!
|
|
1861
|
+
],
|
|
1862
|
+
},
|
|
1863
|
+
]
|
|
1864
|
+
)
|
|
1865
|
+
|
|
1866
|
+
finally:
|
|
1867
|
+
os.unlink(before_db)
|
|
1868
|
+
os.unlink(after_db)
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def test_deletion_with_bulk_fields_spec():
|
|
1872
|
+
"""
|
|
1873
|
+
expect_only_v2 validates field values for deleted rows using bulk field specs with 'type': 'delete',
|
|
1874
|
+
and without fields.
|
|
1875
|
+
"""
|
|
1876
|
+
|
|
1877
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1878
|
+
before_db = f.name
|
|
1879
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1880
|
+
after_db = f.name
|
|
1881
|
+
|
|
1882
|
+
try:
|
|
1883
|
+
conn = sqlite3.connect(before_db)
|
|
1884
|
+
conn.execute(
|
|
1885
|
+
"CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
|
|
1886
|
+
)
|
|
1887
|
+
conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
|
|
1888
|
+
conn.execute("INSERT INTO sessions VALUES (2, 101, 1, 1)") # Admin session!
|
|
1889
|
+
conn.commit()
|
|
1890
|
+
conn.close()
|
|
1891
|
+
|
|
1892
|
+
conn = sqlite3.connect(after_db)
|
|
1893
|
+
conn.execute(
|
|
1894
|
+
"CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
|
|
1895
|
+
)
|
|
1896
|
+
conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
|
|
1897
|
+
# Session 2 (admin session) is deleted
|
|
1898
|
+
conn.commit()
|
|
1899
|
+
conn.close()
|
|
1900
|
+
|
|
1901
|
+
before = DatabaseSnapshot(before_db)
|
|
1902
|
+
after = DatabaseSnapshot(after_db)
|
|
1903
|
+
|
|
1904
|
+
# expect_only with whole-row spec passes
|
|
1905
|
+
before.diff(after).expect_only(
|
|
1906
|
+
[{"table": "sessions", "pk": 2, "fields": None, "after": "__removed__"}]
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
before.diff(after).expect_only_v2(
|
|
1910
|
+
[
|
|
1911
|
+
{
|
|
1912
|
+
"table": "sessions",
|
|
1913
|
+
"pk": 2,
|
|
1914
|
+
"type": "delete",
|
|
1915
|
+
},
|
|
1916
|
+
]
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
finally:
|
|
1920
|
+
os.unlink(before_db)
|
|
1921
|
+
os.unlink(after_db)
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
# ============================================================================
|
|
1925
|
+
# Tests for targeted query optimization edge cases
|
|
1926
|
+
# These tests verify the _expect_only_targeted_v2 optimization works correctly
|
|
1927
|
+
# ============================================================================
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
def test_targeted_empty_allowed_changes_no_changes():
|
|
1931
|
+
"""Test that empty allowed_changes with no actual changes passes (uses _expect_no_changes)."""
|
|
1932
|
+
|
|
1933
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1934
|
+
before_db = f.name
|
|
1935
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1936
|
+
after_db = f.name
|
|
1937
|
+
|
|
1938
|
+
try:
|
|
1939
|
+
conn = sqlite3.connect(before_db)
|
|
1940
|
+
conn.execute(
|
|
1941
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1942
|
+
)
|
|
1943
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1944
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
1945
|
+
conn.commit()
|
|
1946
|
+
conn.close()
|
|
1947
|
+
|
|
1948
|
+
# Same data in after database
|
|
1949
|
+
conn = sqlite3.connect(after_db)
|
|
1950
|
+
conn.execute(
|
|
1951
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1952
|
+
)
|
|
1953
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1954
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
1955
|
+
conn.commit()
|
|
1956
|
+
conn.close()
|
|
1957
|
+
|
|
1958
|
+
before = DatabaseSnapshot(before_db)
|
|
1959
|
+
after = DatabaseSnapshot(after_db)
|
|
1960
|
+
|
|
1961
|
+
# Empty allowed_changes should pass when there are no changes
|
|
1962
|
+
before.diff(after).expect_only_v2([])
|
|
1963
|
+
|
|
1964
|
+
finally:
|
|
1965
|
+
os.unlink(before_db)
|
|
1966
|
+
os.unlink(after_db)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def test_targeted_empty_allowed_changes_with_changes_fails():
|
|
1970
|
+
"""Test that empty allowed_changes with actual changes fails."""
|
|
1971
|
+
|
|
1972
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1973
|
+
before_db = f.name
|
|
1974
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1975
|
+
after_db = f.name
|
|
1976
|
+
|
|
1977
|
+
try:
|
|
1978
|
+
conn = sqlite3.connect(before_db)
|
|
1979
|
+
conn.execute(
|
|
1980
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1981
|
+
)
|
|
1982
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1983
|
+
conn.commit()
|
|
1984
|
+
conn.close()
|
|
1985
|
+
|
|
1986
|
+
conn = sqlite3.connect(after_db)
|
|
1987
|
+
conn.execute(
|
|
1988
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1989
|
+
)
|
|
1990
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1991
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')") # New row!
|
|
1992
|
+
conn.commit()
|
|
1993
|
+
conn.close()
|
|
1994
|
+
|
|
1995
|
+
before = DatabaseSnapshot(before_db)
|
|
1996
|
+
after = DatabaseSnapshot(after_db)
|
|
1997
|
+
|
|
1998
|
+
# Empty allowed_changes should fail when there are changes
|
|
1999
|
+
# The error message depends on the optimization path taken
|
|
2000
|
+
with pytest.raises(AssertionError):
|
|
2001
|
+
before.diff(after).expect_only_v2([])
|
|
2002
|
+
|
|
2003
|
+
finally:
|
|
2004
|
+
os.unlink(before_db)
|
|
2005
|
+
os.unlink(after_db)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def test_targeted_unmentioned_table_row_added_fails():
|
|
2009
|
+
"""Test that row added in an unmentioned table is detected by targeted optimization."""
|
|
2010
|
+
|
|
2011
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2012
|
+
before_db = f.name
|
|
2013
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2014
|
+
after_db = f.name
|
|
2015
|
+
|
|
2016
|
+
try:
|
|
2017
|
+
conn = sqlite3.connect(before_db)
|
|
2018
|
+
conn.execute(
|
|
2019
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2020
|
+
)
|
|
2021
|
+
conn.execute(
|
|
2022
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER)"
|
|
2023
|
+
)
|
|
2024
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2025
|
+
conn.execute("INSERT INTO orders VALUES (1, 1)")
|
|
2026
|
+
conn.commit()
|
|
2027
|
+
conn.close()
|
|
2028
|
+
|
|
2029
|
+
conn = sqlite3.connect(after_db)
|
|
2030
|
+
conn.execute(
|
|
2031
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2032
|
+
)
|
|
2033
|
+
conn.execute(
|
|
2034
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER)"
|
|
2035
|
+
)
|
|
2036
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2037
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob')") # Allowed change
|
|
2038
|
+
conn.execute("INSERT INTO orders VALUES (1, 1)")
|
|
2039
|
+
conn.execute("INSERT INTO orders VALUES (2, 2)") # Sneaky unmentioned change!
|
|
2040
|
+
conn.commit()
|
|
2041
|
+
conn.close()
|
|
2042
|
+
|
|
2043
|
+
before = DatabaseSnapshot(before_db)
|
|
2044
|
+
after = DatabaseSnapshot(after_db)
|
|
2045
|
+
|
|
2046
|
+
# Only mention users table - orders change should be detected
|
|
2047
|
+
with pytest.raises(AssertionError, match="orders"):
|
|
2048
|
+
before.diff(after).expect_only_v2(
|
|
2049
|
+
[
|
|
2050
|
+
{
|
|
2051
|
+
"table": "users",
|
|
2052
|
+
"pk": 2,
|
|
2053
|
+
"type": "insert",
|
|
2054
|
+
"fields": [("id", 2), ("name", "Bob")],
|
|
2055
|
+
},
|
|
2056
|
+
]
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
finally:
|
|
2060
|
+
os.unlink(before_db)
|
|
2061
|
+
os.unlink(after_db)
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
def test_targeted_unmentioned_table_row_deleted_fails():
|
|
2065
|
+
"""Test that row deleted in an unmentioned table is detected."""
|
|
2066
|
+
|
|
2067
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2068
|
+
before_db = f.name
|
|
2069
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2070
|
+
after_db = f.name
|
|
2071
|
+
|
|
2072
|
+
try:
|
|
2073
|
+
conn = sqlite3.connect(before_db)
|
|
2074
|
+
conn.execute(
|
|
2075
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2076
|
+
)
|
|
2077
|
+
conn.execute(
|
|
2078
|
+
"CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT)"
|
|
2079
|
+
)
|
|
2080
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2081
|
+
conn.execute("INSERT INTO logs VALUES (1, 'Log entry 1')")
|
|
2082
|
+
conn.execute("INSERT INTO logs VALUES (2, 'Log entry 2')")
|
|
2083
|
+
conn.commit()
|
|
2084
|
+
conn.close()
|
|
2085
|
+
|
|
2086
|
+
conn = sqlite3.connect(after_db)
|
|
2087
|
+
conn.execute(
|
|
2088
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2089
|
+
)
|
|
2090
|
+
conn.execute(
|
|
2091
|
+
"CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT)"
|
|
2092
|
+
)
|
|
2093
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated')") # Allowed change
|
|
2094
|
+
conn.execute("INSERT INTO logs VALUES (1, 'Log entry 1')")
|
|
2095
|
+
# logs id=2 deleted - not mentioned!
|
|
2096
|
+
conn.commit()
|
|
2097
|
+
conn.close()
|
|
2098
|
+
|
|
2099
|
+
before = DatabaseSnapshot(before_db)
|
|
2100
|
+
after = DatabaseSnapshot(after_db)
|
|
2101
|
+
|
|
2102
|
+
# Only mention users table - logs deletion should be detected
|
|
2103
|
+
with pytest.raises(AssertionError, match="logs"):
|
|
2104
|
+
before.diff(after).expect_only_v2(
|
|
2105
|
+
[
|
|
2106
|
+
{
|
|
2107
|
+
"table": "users",
|
|
2108
|
+
"pk": 1,
|
|
2109
|
+
"type": "modify",
|
|
2110
|
+
"resulting_fields": [("name", "Alice Updated")],
|
|
2111
|
+
"no_other_changes": True,
|
|
2112
|
+
},
|
|
2113
|
+
]
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
finally:
|
|
2117
|
+
os.unlink(before_db)
|
|
2118
|
+
os.unlink(after_db)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def test_targeted_multiple_changes_same_table():
|
|
2122
|
+
"""Test targeted optimization with multiple changes to the same table."""
|
|
2123
|
+
|
|
2124
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2125
|
+
before_db = f.name
|
|
2126
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2127
|
+
after_db = f.name
|
|
2128
|
+
|
|
2129
|
+
try:
|
|
2130
|
+
conn = sqlite3.connect(before_db)
|
|
2131
|
+
conn.execute(
|
|
2132
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, quantity INTEGER)"
|
|
2133
|
+
)
|
|
2134
|
+
conn.execute("INSERT INTO items VALUES (1, 'Widget', 10)")
|
|
2135
|
+
conn.execute("INSERT INTO items VALUES (2, 'Gadget', 20)")
|
|
2136
|
+
conn.execute("INSERT INTO items VALUES (3, 'Gizmo', 30)")
|
|
2137
|
+
conn.commit()
|
|
2138
|
+
conn.close()
|
|
2139
|
+
|
|
2140
|
+
conn = sqlite3.connect(after_db)
|
|
2141
|
+
conn.execute(
|
|
2142
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, quantity INTEGER)"
|
|
2143
|
+
)
|
|
2144
|
+
conn.execute("INSERT INTO items VALUES (1, 'Widget Updated', 15)") # Modified
|
|
2145
|
+
conn.execute("INSERT INTO items VALUES (2, 'Gadget', 20)") # Unchanged
|
|
2146
|
+
# Item 3 deleted
|
|
2147
|
+
conn.execute("INSERT INTO items VALUES (4, 'New Item', 40)") # Added
|
|
2148
|
+
conn.commit()
|
|
2149
|
+
conn.close()
|
|
2150
|
+
|
|
2151
|
+
before = DatabaseSnapshot(before_db)
|
|
2152
|
+
after = DatabaseSnapshot(after_db)
|
|
2153
|
+
|
|
2154
|
+
# All changes properly specified
|
|
2155
|
+
before.diff(after).expect_only_v2(
|
|
2156
|
+
[
|
|
2157
|
+
{
|
|
2158
|
+
"table": "items",
|
|
2159
|
+
"pk": 1,
|
|
2160
|
+
"type": "modify",
|
|
2161
|
+
"resulting_fields": [("name", "Widget Updated"), ("quantity", 15)],
|
|
2162
|
+
"no_other_changes": True,
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
"table": "items",
|
|
2166
|
+
"pk": 3,
|
|
2167
|
+
"type": "delete",
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
"table": "items",
|
|
2171
|
+
"pk": 4,
|
|
2172
|
+
"type": "insert",
|
|
2173
|
+
"fields": [("id", 4), ("name", "New Item"), ("quantity", 40)],
|
|
2174
|
+
},
|
|
2175
|
+
]
|
|
2176
|
+
)
|
|
2177
|
+
|
|
2178
|
+
finally:
|
|
2179
|
+
os.unlink(before_db)
|
|
2180
|
+
os.unlink(after_db)
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def test_targeted_legacy_single_field_specs():
|
|
2184
|
+
"""Test that legacy single-field specs work with targeted optimization."""
|
|
2185
|
+
|
|
2186
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2187
|
+
before_db = f.name
|
|
2188
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2189
|
+
after_db = f.name
|
|
2190
|
+
|
|
2191
|
+
try:
|
|
2192
|
+
conn = sqlite3.connect(before_db)
|
|
2193
|
+
conn.execute(
|
|
2194
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT)"
|
|
2195
|
+
)
|
|
2196
|
+
conn.execute("INSERT INTO settings VALUES (1, 'theme', 'light')")
|
|
2197
|
+
conn.execute("INSERT INTO settings VALUES (2, 'language', 'en')")
|
|
2198
|
+
conn.commit()
|
|
2199
|
+
conn.close()
|
|
2200
|
+
|
|
2201
|
+
conn = sqlite3.connect(after_db)
|
|
2202
|
+
conn.execute(
|
|
2203
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT)"
|
|
2204
|
+
)
|
|
2205
|
+
conn.execute("INSERT INTO settings VALUES (1, 'theme', 'dark')") # Changed
|
|
2206
|
+
conn.execute("INSERT INTO settings VALUES (2, 'language', 'fr')") # Changed
|
|
2207
|
+
conn.commit()
|
|
2208
|
+
conn.close()
|
|
2209
|
+
|
|
2210
|
+
before = DatabaseSnapshot(before_db)
|
|
2211
|
+
after = DatabaseSnapshot(after_db)
|
|
2212
|
+
|
|
2213
|
+
# Legacy single-field specs
|
|
2214
|
+
before.diff(after).expect_only_v2(
|
|
2215
|
+
[
|
|
2216
|
+
{"table": "settings", "pk": 1, "field": "value", "after": "dark"},
|
|
2217
|
+
{"table": "settings", "pk": 2, "field": "value", "after": "fr"},
|
|
2218
|
+
]
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
finally:
|
|
2222
|
+
os.unlink(before_db)
|
|
2223
|
+
os.unlink(after_db)
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
def test_targeted_mixed_v2_and_legacy_specs():
|
|
2227
|
+
"""Test that mixed v2 and legacy specs work together."""
|
|
2228
|
+
|
|
2229
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2230
|
+
before_db = f.name
|
|
2231
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2232
|
+
after_db = f.name
|
|
2233
|
+
|
|
2234
|
+
try:
|
|
2235
|
+
conn = sqlite3.connect(before_db)
|
|
2236
|
+
conn.execute(
|
|
2237
|
+
"CREATE TABLE config (id INTEGER PRIMARY KEY, key TEXT, value TEXT, enabled INTEGER)"
|
|
2238
|
+
)
|
|
2239
|
+
conn.execute("INSERT INTO config VALUES (1, 'feature_a', 'off', 0)")
|
|
2240
|
+
conn.commit()
|
|
2241
|
+
conn.close()
|
|
2242
|
+
|
|
2243
|
+
conn = sqlite3.connect(after_db)
|
|
2244
|
+
conn.execute(
|
|
2245
|
+
"CREATE TABLE config (id INTEGER PRIMARY KEY, key TEXT, value TEXT, enabled INTEGER)"
|
|
2246
|
+
)
|
|
2247
|
+
conn.execute("INSERT INTO config VALUES (1, 'feature_a', 'on', 1)") # Both changed
|
|
2248
|
+
conn.execute("INSERT INTO config VALUES (2, 'feature_b', 'active', 1)") # Added
|
|
2249
|
+
conn.commit()
|
|
2250
|
+
conn.close()
|
|
2251
|
+
|
|
2252
|
+
before = DatabaseSnapshot(before_db)
|
|
2253
|
+
after = DatabaseSnapshot(after_db)
|
|
2254
|
+
|
|
2255
|
+
# Mix of v2 insert spec and legacy single-field specs
|
|
2256
|
+
before.diff(after).expect_only_v2(
|
|
2257
|
+
[
|
|
2258
|
+
# Legacy specs for modification
|
|
2259
|
+
{"table": "config", "pk": 1, "field": "value", "after": "on"},
|
|
2260
|
+
{"table": "config", "pk": 1, "field": "enabled", "after": 1},
|
|
2261
|
+
# V2 spec for insertion
|
|
2262
|
+
{
|
|
2263
|
+
"table": "config",
|
|
2264
|
+
"pk": 2,
|
|
2265
|
+
"type": "insert",
|
|
2266
|
+
"fields": [
|
|
2267
|
+
("id", 2),
|
|
2268
|
+
("key", "feature_b"),
|
|
2269
|
+
("value", "active"),
|
|
2270
|
+
("enabled", 1),
|
|
2271
|
+
],
|
|
2272
|
+
},
|
|
2273
|
+
]
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
finally:
|
|
2277
|
+
os.unlink(before_db)
|
|
2278
|
+
os.unlink(after_db)
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def test_targeted_with_ignore_config():
|
|
2282
|
+
"""Test that ignore_config works correctly with targeted optimization."""
|
|
2283
|
+
|
|
2284
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2285
|
+
before_db = f.name
|
|
2286
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2287
|
+
after_db = f.name
|
|
2288
|
+
|
|
2289
|
+
try:
|
|
2290
|
+
conn = sqlite3.connect(before_db)
|
|
2291
|
+
conn.execute(
|
|
2292
|
+
"CREATE TABLE audit (id INTEGER PRIMARY KEY, action TEXT, timestamp TEXT)"
|
|
2293
|
+
)
|
|
2294
|
+
conn.execute(
|
|
2295
|
+
"CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT, updated_at TEXT)"
|
|
2296
|
+
)
|
|
2297
|
+
conn.execute("INSERT INTO audit VALUES (1, 'init', '2024-01-01')")
|
|
2298
|
+
conn.execute("INSERT INTO data VALUES (1, 'original', '2024-01-01')")
|
|
2299
|
+
conn.commit()
|
|
2300
|
+
conn.close()
|
|
2301
|
+
|
|
2302
|
+
conn = sqlite3.connect(after_db)
|
|
2303
|
+
conn.execute(
|
|
2304
|
+
"CREATE TABLE audit (id INTEGER PRIMARY KEY, action TEXT, timestamp TEXT)"
|
|
2305
|
+
)
|
|
2306
|
+
conn.execute(
|
|
2307
|
+
"CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT, updated_at TEXT)"
|
|
2308
|
+
)
|
|
2309
|
+
conn.execute("INSERT INTO audit VALUES (1, 'init', '2024-01-01')")
|
|
2310
|
+
conn.execute("INSERT INTO audit VALUES (2, 'update', '2024-01-02')") # Ignored table
|
|
2311
|
+
conn.execute("INSERT INTO data VALUES (1, 'updated', '2024-01-02')") # Changed
|
|
2312
|
+
conn.commit()
|
|
2313
|
+
conn.close()
|
|
2314
|
+
|
|
2315
|
+
before = DatabaseSnapshot(before_db)
|
|
2316
|
+
after = DatabaseSnapshot(after_db)
|
|
2317
|
+
|
|
2318
|
+
# Ignore the audit table entirely, and updated_at field in data
|
|
2319
|
+
ignore_config = IgnoreConfig(
|
|
2320
|
+
tables={"audit"},
|
|
2321
|
+
table_fields={"data": {"updated_at"}},
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
# Only need to specify the value change, audit table is ignored
|
|
2325
|
+
before.diff(after, ignore_config).expect_only_v2(
|
|
2326
|
+
[
|
|
2327
|
+
{
|
|
2328
|
+
"table": "data",
|
|
2329
|
+
"pk": 1,
|
|
2330
|
+
"type": "modify",
|
|
2331
|
+
"resulting_fields": [("value", "updated")],
|
|
2332
|
+
"no_other_changes": True,
|
|
2333
|
+
},
|
|
2334
|
+
]
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
finally:
|
|
2338
|
+
os.unlink(before_db)
|
|
2339
|
+
os.unlink(after_db)
|
|
2340
|
+
|
|
2341
|
+
|
|
2342
|
+
def test_targeted_string_vs_int_pk_coercion():
|
|
2343
|
+
"""Test that PK comparison works with string vs int coercion."""
|
|
2344
|
+
|
|
2345
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2346
|
+
before_db = f.name
|
|
2347
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2348
|
+
after_db = f.name
|
|
2349
|
+
|
|
2350
|
+
try:
|
|
2351
|
+
conn = sqlite3.connect(before_db)
|
|
2352
|
+
conn.execute(
|
|
2353
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2354
|
+
)
|
|
2355
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2356
|
+
conn.commit()
|
|
2357
|
+
conn.close()
|
|
2358
|
+
|
|
2359
|
+
conn = sqlite3.connect(after_db)
|
|
2360
|
+
conn.execute(
|
|
2361
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2362
|
+
)
|
|
2363
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2364
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2')")
|
|
2365
|
+
conn.execute("INSERT INTO items VALUES (3, 'Item 3')")
|
|
2366
|
+
conn.commit()
|
|
2367
|
+
conn.close()
|
|
2368
|
+
|
|
2369
|
+
before = DatabaseSnapshot(before_db)
|
|
2370
|
+
after = DatabaseSnapshot(after_db)
|
|
2371
|
+
|
|
2372
|
+
# Use string PKs - should still work due to coercion
|
|
2373
|
+
before.diff(after).expect_only_v2(
|
|
2374
|
+
[
|
|
2375
|
+
{
|
|
2376
|
+
"table": "items",
|
|
2377
|
+
"pk": "2", # String PK
|
|
2378
|
+
"type": "insert",
|
|
2379
|
+
"fields": [("id", 2), ("name", "Item 2")],
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
"table": "items",
|
|
2383
|
+
"pk": 3, # Integer PK
|
|
2384
|
+
"type": "insert",
|
|
2385
|
+
"fields": [("id", 3), ("name", "Item 3")],
|
|
2386
|
+
},
|
|
2387
|
+
]
|
|
2388
|
+
)
|
|
2389
|
+
|
|
2390
|
+
finally:
|
|
2391
|
+
os.unlink(before_db)
|
|
2392
|
+
os.unlink(after_db)
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def test_targeted_modify_without_resulting_fields():
|
|
2396
|
+
"""Test that type: 'modify' without resulting_fields allows any modification."""
|
|
2397
|
+
|
|
2398
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2399
|
+
before_db = f.name
|
|
2400
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2401
|
+
after_db = f.name
|
|
2402
|
+
|
|
2403
|
+
try:
|
|
2404
|
+
conn = sqlite3.connect(before_db)
|
|
2405
|
+
conn.execute(
|
|
2406
|
+
"CREATE TABLE records (id INTEGER PRIMARY KEY, field_a TEXT, field_b TEXT, field_c TEXT)"
|
|
2407
|
+
)
|
|
2408
|
+
conn.execute("INSERT INTO records VALUES (1, 'a1', 'b1', 'c1')")
|
|
2409
|
+
conn.commit()
|
|
2410
|
+
conn.close()
|
|
2411
|
+
|
|
2412
|
+
conn = sqlite3.connect(after_db)
|
|
2413
|
+
conn.execute(
|
|
2414
|
+
"CREATE TABLE records (id INTEGER PRIMARY KEY, field_a TEXT, field_b TEXT, field_c TEXT)"
|
|
2415
|
+
)
|
|
2416
|
+
conn.execute("INSERT INTO records VALUES (1, 'a2', 'b2', 'c2')") # All fields changed
|
|
2417
|
+
conn.commit()
|
|
2418
|
+
conn.close()
|
|
2419
|
+
|
|
2420
|
+
before = DatabaseSnapshot(before_db)
|
|
2421
|
+
after = DatabaseSnapshot(after_db)
|
|
2422
|
+
|
|
2423
|
+
# type: "modify" without resulting_fields allows any modification
|
|
2424
|
+
before.diff(after).expect_only_v2(
|
|
2425
|
+
[
|
|
2426
|
+
{
|
|
2427
|
+
"table": "records",
|
|
2428
|
+
"pk": 1,
|
|
2429
|
+
"type": "modify",
|
|
2430
|
+
# No resulting_fields - allows any changes
|
|
2431
|
+
},
|
|
2432
|
+
]
|
|
2433
|
+
)
|
|
2434
|
+
|
|
2435
|
+
finally:
|
|
2436
|
+
os.unlink(before_db)
|
|
2437
|
+
os.unlink(after_db)
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
def test_targeted_insert_without_fields():
|
|
2441
|
+
"""Test that type: 'insert' without fields allows insertion with any values."""
|
|
2442
|
+
|
|
2443
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2444
|
+
before_db = f.name
|
|
2445
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2446
|
+
after_db = f.name
|
|
2447
|
+
|
|
2448
|
+
try:
|
|
2449
|
+
conn = sqlite3.connect(before_db)
|
|
2450
|
+
conn.execute(
|
|
2451
|
+
"CREATE TABLE entities (id INTEGER PRIMARY KEY, data TEXT, secret TEXT)"
|
|
2452
|
+
)
|
|
2453
|
+
conn.commit()
|
|
2454
|
+
conn.close()
|
|
2455
|
+
|
|
2456
|
+
conn = sqlite3.connect(after_db)
|
|
2457
|
+
conn.execute(
|
|
2458
|
+
"CREATE TABLE entities (id INTEGER PRIMARY KEY, data TEXT, secret TEXT)"
|
|
2459
|
+
)
|
|
2460
|
+
conn.execute("INSERT INTO entities VALUES (1, 'any data', 'any secret')")
|
|
2461
|
+
conn.commit()
|
|
2462
|
+
conn.close()
|
|
2463
|
+
|
|
2464
|
+
before = DatabaseSnapshot(before_db)
|
|
2465
|
+
after = DatabaseSnapshot(after_db)
|
|
2466
|
+
|
|
2467
|
+
# type: "insert" without fields allows insertion with any values
|
|
2468
|
+
before.diff(after).expect_only_v2(
|
|
2469
|
+
[
|
|
2470
|
+
{
|
|
2471
|
+
"table": "entities",
|
|
2472
|
+
"pk": 1,
|
|
2473
|
+
"type": "insert",
|
|
2474
|
+
# No fields - allows any values
|
|
2475
|
+
},
|
|
2476
|
+
]
|
|
2477
|
+
)
|
|
2478
|
+
|
|
2479
|
+
finally:
|
|
2480
|
+
os.unlink(before_db)
|
|
2481
|
+
os.unlink(after_db)
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def test_targeted_multiple_tables_all_specs():
|
|
2485
|
+
"""Test targeted optimization with multiple tables and all spec types."""
|
|
2486
|
+
|
|
2487
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2488
|
+
before_db = f.name
|
|
2489
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2490
|
+
after_db = f.name
|
|
2491
|
+
|
|
2492
|
+
try:
|
|
2493
|
+
conn = sqlite3.connect(before_db)
|
|
2494
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
2495
|
+
conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
|
|
2496
|
+
conn.execute("CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT)")
|
|
2497
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2498
|
+
conn.execute("INSERT INTO posts VALUES (1, 'Post 1')")
|
|
2499
|
+
conn.execute("INSERT INTO posts VALUES (2, 'Post 2')")
|
|
2500
|
+
conn.execute("INSERT INTO comments VALUES (1, 'Comment 1')")
|
|
2501
|
+
conn.commit()
|
|
2502
|
+
conn.close()
|
|
2503
|
+
|
|
2504
|
+
conn = sqlite3.connect(after_db)
|
|
2505
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
2506
|
+
conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
|
|
2507
|
+
conn.execute("CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT)")
|
|
2508
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2509
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob')") # Added
|
|
2510
|
+
conn.execute("INSERT INTO posts VALUES (1, 'Post 1 Updated')") # Modified
|
|
2511
|
+
# Post 2 deleted
|
|
2512
|
+
conn.execute("INSERT INTO comments VALUES (1, 'Comment 1')") # Unchanged
|
|
2513
|
+
conn.commit()
|
|
2514
|
+
conn.close()
|
|
2515
|
+
|
|
2516
|
+
before = DatabaseSnapshot(before_db)
|
|
2517
|
+
after = DatabaseSnapshot(after_db)
|
|
2518
|
+
|
|
2519
|
+
# Multiple tables with different change types
|
|
2520
|
+
before.diff(after).expect_only_v2(
|
|
2521
|
+
[
|
|
2522
|
+
{
|
|
2523
|
+
"table": "users",
|
|
2524
|
+
"pk": 2,
|
|
2525
|
+
"type": "insert",
|
|
2526
|
+
"fields": [("id", 2), ("name", "Bob")],
|
|
2527
|
+
},
|
|
2528
|
+
{
|
|
2529
|
+
"table": "posts",
|
|
2530
|
+
"pk": 1,
|
|
2531
|
+
"type": "modify",
|
|
2532
|
+
"resulting_fields": [("title", "Post 1 Updated")],
|
|
2533
|
+
"no_other_changes": True,
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
"table": "posts",
|
|
2537
|
+
"pk": 2,
|
|
2538
|
+
"type": "delete",
|
|
2539
|
+
},
|
|
2540
|
+
]
|
|
2541
|
+
)
|
|
2542
|
+
|
|
2543
|
+
finally:
|
|
2544
|
+
os.unlink(before_db)
|
|
2545
|
+
os.unlink(after_db)
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def test_targeted_row_exists_both_sides_no_change():
|
|
2549
|
+
"""Test that specifying a row that exists but didn't change works correctly."""
|
|
2550
|
+
|
|
2551
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2552
|
+
before_db = f.name
|
|
2553
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2554
|
+
after_db = f.name
|
|
2555
|
+
|
|
2556
|
+
try:
|
|
2557
|
+
conn = sqlite3.connect(before_db)
|
|
2558
|
+
conn.execute(
|
|
2559
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2560
|
+
)
|
|
2561
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2562
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2')")
|
|
2563
|
+
conn.commit()
|
|
2564
|
+
conn.close()
|
|
2565
|
+
|
|
2566
|
+
conn = sqlite3.connect(after_db)
|
|
2567
|
+
conn.execute(
|
|
2568
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2569
|
+
)
|
|
2570
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')") # Unchanged
|
|
2571
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2 Updated')") # Changed
|
|
2572
|
+
conn.commit()
|
|
2573
|
+
conn.close()
|
|
2574
|
+
|
|
2575
|
+
before = DatabaseSnapshot(before_db)
|
|
2576
|
+
after = DatabaseSnapshot(after_db)
|
|
2577
|
+
|
|
2578
|
+
# Specify the change correctly
|
|
2579
|
+
before.diff(after).expect_only_v2(
|
|
2580
|
+
[
|
|
2581
|
+
{
|
|
2582
|
+
"table": "items",
|
|
2583
|
+
"pk": 2,
|
|
2584
|
+
"type": "modify",
|
|
2585
|
+
"resulting_fields": [("name", "Item 2 Updated")],
|
|
2586
|
+
"no_other_changes": True,
|
|
2587
|
+
},
|
|
2588
|
+
]
|
|
2589
|
+
)
|
|
2590
|
+
|
|
2591
|
+
finally:
|
|
2592
|
+
os.unlink(before_db)
|
|
2593
|
+
os.unlink(after_db)
|