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,4148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for expect_exactly method.
|
|
3
|
+
|
|
4
|
+
expect_exactly is stricter than expect_only_v2:
|
|
5
|
+
- ALL changes in diff must match a spec (no unexpected changes)
|
|
6
|
+
- ALL specs must have a matching change in diff (no missing expected changes)
|
|
7
|
+
|
|
8
|
+
This file contains:
|
|
9
|
+
1. Real database tests - end-to-end tests with actual SQLite databases
|
|
10
|
+
2. Mock-based tests - isolated tests for error message formatting
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
import tempfile
|
|
15
|
+
import os
|
|
16
|
+
import pytest
|
|
17
|
+
from typing import Dict, Any, List, Optional
|
|
18
|
+
from unittest.mock import MagicMock
|
|
19
|
+
|
|
20
|
+
from fleet.verifiers.db import DatabaseSnapshot, IgnoreConfig
|
|
21
|
+
from fleet.resources.sqlite import SyncSnapshotDiff, SyncDatabaseSnapshot
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Mock class for testing error messaging without real databases
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MockSnapshotDiff(SyncSnapshotDiff):
|
|
30
|
+
"""
|
|
31
|
+
A mock SyncSnapshotDiff that uses pre-defined diff data instead of
|
|
32
|
+
computing it from actual database snapshots.
|
|
33
|
+
|
|
34
|
+
This allows us to test the validation and error messaging logic
|
|
35
|
+
without needing actual database files.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, diff_data: Dict[str, Any], ignore_config: Optional[IgnoreConfig] = None):
|
|
39
|
+
# Create minimal mock snapshots
|
|
40
|
+
mock_before = MagicMock(spec=SyncDatabaseSnapshot)
|
|
41
|
+
mock_after = MagicMock(spec=SyncDatabaseSnapshot)
|
|
42
|
+
|
|
43
|
+
# Mock the resource for HTTP mode detection (we want local mode for tests)
|
|
44
|
+
mock_resource = MagicMock()
|
|
45
|
+
mock_resource.client = None # No HTTP client = local mode
|
|
46
|
+
mock_resource._mode = "local"
|
|
47
|
+
mock_after.resource = mock_resource
|
|
48
|
+
|
|
49
|
+
# Call parent init
|
|
50
|
+
super().__init__(mock_before, mock_after, ignore_config)
|
|
51
|
+
|
|
52
|
+
# Store the mock diff data
|
|
53
|
+
self._mock_diff_data = diff_data
|
|
54
|
+
|
|
55
|
+
def _collect(self) -> Dict[str, Any]:
|
|
56
|
+
"""Return the pre-defined mock diff data instead of computing it."""
|
|
57
|
+
return self._mock_diff_data
|
|
58
|
+
|
|
59
|
+
def _get_primary_key_columns(self, table: str) -> List[str]:
|
|
60
|
+
"""Return a default primary key since we don't have real tables."""
|
|
61
|
+
return ["id"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Tests for expect_exactly (stricter than expect_only_v2)
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_field_level_specs_for_added_row():
|
|
70
|
+
"""Test that bulk field specs work for row additions in expect_exactly"""
|
|
71
|
+
|
|
72
|
+
# Create two temporary databases
|
|
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
|
+
# Setup before database
|
|
80
|
+
conn = sqlite3.connect(before_db)
|
|
81
|
+
conn.execute(
|
|
82
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
83
|
+
)
|
|
84
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
85
|
+
conn.commit()
|
|
86
|
+
conn.close()
|
|
87
|
+
|
|
88
|
+
# Setup after database - add a new row
|
|
89
|
+
conn = sqlite3.connect(after_db)
|
|
90
|
+
conn.execute(
|
|
91
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
92
|
+
)
|
|
93
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
94
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
95
|
+
conn.commit()
|
|
96
|
+
conn.close()
|
|
97
|
+
|
|
98
|
+
# Create snapshots
|
|
99
|
+
before = DatabaseSnapshot(before_db)
|
|
100
|
+
after = DatabaseSnapshot(after_db)
|
|
101
|
+
|
|
102
|
+
# Bulk field specs should work for added rows in v2
|
|
103
|
+
before.diff(after).expect_exactly(
|
|
104
|
+
[
|
|
105
|
+
{
|
|
106
|
+
"table": "users",
|
|
107
|
+
"pk": 2,
|
|
108
|
+
"type": "insert",
|
|
109
|
+
"fields": [("id", 2), ("name", "Bob"), ("status", "inactive")],
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
finally:
|
|
115
|
+
os.unlink(before_db)
|
|
116
|
+
os.unlink(after_db)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_field_level_specs_with_wrong_values():
|
|
120
|
+
"""Test that wrong values are detected in expect_exactly"""
|
|
121
|
+
|
|
122
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
123
|
+
before_db = f.name
|
|
124
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
125
|
+
after_db = f.name
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
conn = sqlite3.connect(before_db)
|
|
129
|
+
conn.execute(
|
|
130
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
131
|
+
)
|
|
132
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
133
|
+
conn.commit()
|
|
134
|
+
conn.close()
|
|
135
|
+
|
|
136
|
+
conn = sqlite3.connect(after_db)
|
|
137
|
+
conn.execute(
|
|
138
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
139
|
+
)
|
|
140
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
141
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
142
|
+
conn.commit()
|
|
143
|
+
conn.close()
|
|
144
|
+
|
|
145
|
+
before = DatabaseSnapshot(before_db)
|
|
146
|
+
after = DatabaseSnapshot(after_db)
|
|
147
|
+
|
|
148
|
+
# Should fail because status value is wrong
|
|
149
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
150
|
+
before.diff(after).expect_exactly(
|
|
151
|
+
[
|
|
152
|
+
{
|
|
153
|
+
"table": "users",
|
|
154
|
+
"pk": 2,
|
|
155
|
+
"type": "insert",
|
|
156
|
+
"fields": [
|
|
157
|
+
("id", 2),
|
|
158
|
+
("name", "Bob"),
|
|
159
|
+
("status", "WRONG_VALUE"),
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
finally:
|
|
166
|
+
os.unlink(before_db)
|
|
167
|
+
os.unlink(after_db)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_modification_with_bulk_fields_spec():
|
|
171
|
+
"""Test that bulk field specs work for row modifications in expect_exactly"""
|
|
172
|
+
|
|
173
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
174
|
+
before_db = f.name
|
|
175
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
176
|
+
after_db = f.name
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
conn = sqlite3.connect(before_db)
|
|
180
|
+
conn.execute(
|
|
181
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, role TEXT)"
|
|
182
|
+
)
|
|
183
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', 'user')")
|
|
184
|
+
conn.commit()
|
|
185
|
+
conn.close()
|
|
186
|
+
|
|
187
|
+
conn = sqlite3.connect(after_db)
|
|
188
|
+
conn.execute(
|
|
189
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, role TEXT)"
|
|
190
|
+
)
|
|
191
|
+
# Both name and status changed
|
|
192
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', 'user')")
|
|
193
|
+
conn.commit()
|
|
194
|
+
conn.close()
|
|
195
|
+
|
|
196
|
+
before = DatabaseSnapshot(before_db)
|
|
197
|
+
after = DatabaseSnapshot(after_db)
|
|
198
|
+
|
|
199
|
+
# Bulk field specs for modifications - specify all changed fields
|
|
200
|
+
# no_other_changes=True ensures no other fields changed
|
|
201
|
+
before.diff(after).expect_exactly(
|
|
202
|
+
[
|
|
203
|
+
{
|
|
204
|
+
"table": "users",
|
|
205
|
+
"pk": 1,
|
|
206
|
+
"type": "modify",
|
|
207
|
+
"resulting_fields": [("name", "Alice Updated"), ("status", "inactive")],
|
|
208
|
+
"no_other_changes": True,
|
|
209
|
+
},
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
finally:
|
|
214
|
+
os.unlink(before_db)
|
|
215
|
+
os.unlink(after_db)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_modification_with_bulk_fields_spec_wrong_value():
|
|
219
|
+
"""Test that wrong values in modification bulk field specs are detected"""
|
|
220
|
+
|
|
221
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
222
|
+
before_db = f.name
|
|
223
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
224
|
+
after_db = f.name
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
conn = sqlite3.connect(before_db)
|
|
228
|
+
conn.execute(
|
|
229
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
230
|
+
)
|
|
231
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
232
|
+
conn.commit()
|
|
233
|
+
conn.close()
|
|
234
|
+
|
|
235
|
+
conn = sqlite3.connect(after_db)
|
|
236
|
+
conn.execute(
|
|
237
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
238
|
+
)
|
|
239
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
240
|
+
conn.commit()
|
|
241
|
+
conn.close()
|
|
242
|
+
|
|
243
|
+
before = DatabaseSnapshot(before_db)
|
|
244
|
+
after = DatabaseSnapshot(after_db)
|
|
245
|
+
|
|
246
|
+
# Should fail because status value is wrong
|
|
247
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
248
|
+
before.diff(after).expect_exactly(
|
|
249
|
+
[
|
|
250
|
+
{
|
|
251
|
+
"table": "users",
|
|
252
|
+
"pk": 1,
|
|
253
|
+
"type": "modify",
|
|
254
|
+
"resulting_fields": [
|
|
255
|
+
("name", "Alice Updated"),
|
|
256
|
+
("status", "WRONG_VALUE"), # Wrong!
|
|
257
|
+
],
|
|
258
|
+
"no_other_changes": True,
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
finally:
|
|
264
|
+
os.unlink(before_db)
|
|
265
|
+
os.unlink(after_db)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_modification_with_bulk_fields_spec_missing_field():
|
|
269
|
+
"""Test that missing fields in modification bulk field specs are detected when no_other_changes=True"""
|
|
270
|
+
|
|
271
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
272
|
+
before_db = f.name
|
|
273
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
274
|
+
after_db = f.name
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
conn = sqlite3.connect(before_db)
|
|
278
|
+
conn.execute(
|
|
279
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
280
|
+
)
|
|
281
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
282
|
+
conn.commit()
|
|
283
|
+
conn.close()
|
|
284
|
+
|
|
285
|
+
conn = sqlite3.connect(after_db)
|
|
286
|
+
conn.execute(
|
|
287
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
288
|
+
)
|
|
289
|
+
# Both name and status changed
|
|
290
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
291
|
+
conn.commit()
|
|
292
|
+
conn.close()
|
|
293
|
+
|
|
294
|
+
before = DatabaseSnapshot(before_db)
|
|
295
|
+
after = DatabaseSnapshot(after_db)
|
|
296
|
+
|
|
297
|
+
# Should fail because status change is not in resulting_fields and no_other_changes=True
|
|
298
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
299
|
+
before.diff(after).expect_exactly(
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
"table": "users",
|
|
303
|
+
"pk": 1,
|
|
304
|
+
"type": "modify",
|
|
305
|
+
"resulting_fields": [
|
|
306
|
+
("name", "Alice Updated"),
|
|
307
|
+
# status is missing - should fail with no_other_changes=True
|
|
308
|
+
],
|
|
309
|
+
"no_other_changes": True,
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
assert "status" in str(exc_info.value)
|
|
315
|
+
assert "not specified in resulting_fields" in str(exc_info.value)
|
|
316
|
+
|
|
317
|
+
finally:
|
|
318
|
+
os.unlink(before_db)
|
|
319
|
+
os.unlink(after_db)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_modification_no_other_changes_false_allows_extra_changes():
|
|
323
|
+
"""Test that no_other_changes=False allows other fields to change without checking them"""
|
|
324
|
+
|
|
325
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
326
|
+
before_db = f.name
|
|
327
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
328
|
+
after_db = f.name
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
conn = sqlite3.connect(before_db)
|
|
332
|
+
conn.execute(
|
|
333
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
334
|
+
)
|
|
335
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
336
|
+
conn.commit()
|
|
337
|
+
conn.close()
|
|
338
|
+
|
|
339
|
+
conn = sqlite3.connect(after_db)
|
|
340
|
+
conn.execute(
|
|
341
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
342
|
+
)
|
|
343
|
+
# All three fields changed: name, status, updated_at
|
|
344
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-15')")
|
|
345
|
+
conn.commit()
|
|
346
|
+
conn.close()
|
|
347
|
+
|
|
348
|
+
before = DatabaseSnapshot(before_db)
|
|
349
|
+
after = DatabaseSnapshot(after_db)
|
|
350
|
+
|
|
351
|
+
# With no_other_changes=False, we only need to specify the fields we care about
|
|
352
|
+
# status and updated_at changed but we don't check them
|
|
353
|
+
before.diff(after).expect_exactly(
|
|
354
|
+
[
|
|
355
|
+
{
|
|
356
|
+
"table": "users",
|
|
357
|
+
"pk": 1,
|
|
358
|
+
"type": "modify",
|
|
359
|
+
"resulting_fields": [
|
|
360
|
+
("name", "Alice Updated"),
|
|
361
|
+
# status and updated_at not specified - that's OK with no_other_changes=False
|
|
362
|
+
],
|
|
363
|
+
"no_other_changes": False, # Allows other changes
|
|
364
|
+
},
|
|
365
|
+
]
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
finally:
|
|
369
|
+
os.unlink(before_db)
|
|
370
|
+
os.unlink(after_db)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_modification_no_other_changes_true_with_ellipsis():
|
|
374
|
+
"""
|
|
375
|
+
Test that no_other_changes=True and specifying fields with ... means only the specified
|
|
376
|
+
fields are checked, and all other fields must remain unchanged (even if ... is used as the value).
|
|
377
|
+
"""
|
|
378
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
379
|
+
before_db = f.name
|
|
380
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
381
|
+
after_db = f.name
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
# Initial table and row
|
|
385
|
+
conn = sqlite3.connect(before_db)
|
|
386
|
+
conn.execute(
|
|
387
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
388
|
+
)
|
|
389
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
390
|
+
conn.commit()
|
|
391
|
+
conn.close()
|
|
392
|
+
|
|
393
|
+
# After: only 'name' changes (others should remain exactly the same)
|
|
394
|
+
conn = sqlite3.connect(after_db)
|
|
395
|
+
conn.execute(
|
|
396
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
397
|
+
)
|
|
398
|
+
conn.execute(
|
|
399
|
+
"INSERT INTO users VALUES (1, 'Alice Updated', 'active', '2024-01-01')"
|
|
400
|
+
)
|
|
401
|
+
conn.commit()
|
|
402
|
+
conn.close()
|
|
403
|
+
|
|
404
|
+
before = DatabaseSnapshot(before_db)
|
|
405
|
+
after = DatabaseSnapshot(after_db)
|
|
406
|
+
|
|
407
|
+
# Specify to check only name with ..., others must remain the same (enforced by no_other_changes=True)
|
|
408
|
+
before.diff(after).expect_exactly(
|
|
409
|
+
[
|
|
410
|
+
{
|
|
411
|
+
"table": "users",
|
|
412
|
+
"pk": 1,
|
|
413
|
+
"type": "modify",
|
|
414
|
+
"resulting_fields": [
|
|
415
|
+
("name", ...), # Only check that field changed, but not checking its value
|
|
416
|
+
],
|
|
417
|
+
"no_other_changes": True,
|
|
418
|
+
},
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Now, test that a change to a non-listed field triggers an error
|
|
423
|
+
# We'll modify status, which is not covered by 'resulting_fields'
|
|
424
|
+
conn = sqlite3.connect(after_db)
|
|
425
|
+
conn.execute(
|
|
426
|
+
"DELETE FROM users WHERE id=1"
|
|
427
|
+
)
|
|
428
|
+
conn.execute(
|
|
429
|
+
"INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-01')"
|
|
430
|
+
)
|
|
431
|
+
conn.commit()
|
|
432
|
+
conn.close()
|
|
433
|
+
|
|
434
|
+
with pytest.raises(AssertionError):
|
|
435
|
+
before.diff(after).expect_exactly(
|
|
436
|
+
[
|
|
437
|
+
{
|
|
438
|
+
"table": "users",
|
|
439
|
+
"pk": 1,
|
|
440
|
+
"type": "modify",
|
|
441
|
+
"resulting_fields": [
|
|
442
|
+
("name", ...), # Only allow name to change, not status
|
|
443
|
+
],
|
|
444
|
+
"no_other_changes": True,
|
|
445
|
+
},
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
finally:
|
|
450
|
+
os.unlink(before_db)
|
|
451
|
+
os.unlink(after_db)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_modification_no_other_changes_false_still_validates_specified():
|
|
455
|
+
"""Test that no_other_changes=False still validates the fields that ARE specified"""
|
|
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 name value is wrong, even with no_other_changes=False
|
|
483
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
484
|
+
before.diff(after).expect_exactly(
|
|
485
|
+
[
|
|
486
|
+
{
|
|
487
|
+
"table": "users",
|
|
488
|
+
"pk": 1,
|
|
489
|
+
"type": "modify",
|
|
490
|
+
"resulting_fields": [
|
|
491
|
+
("name", "WRONG VALUE"), # This is wrong
|
|
492
|
+
],
|
|
493
|
+
"no_other_changes": False, # Allows status to change unvalidated
|
|
494
|
+
},
|
|
495
|
+
]
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
finally:
|
|
499
|
+
os.unlink(before_db)
|
|
500
|
+
os.unlink(after_db)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_modification_missing_no_other_changes_raises_error():
|
|
504
|
+
"""Test that missing no_other_changes field raises a ValueError"""
|
|
505
|
+
|
|
506
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
507
|
+
before_db = f.name
|
|
508
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
509
|
+
after_db = f.name
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
conn = sqlite3.connect(before_db)
|
|
513
|
+
conn.execute(
|
|
514
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
515
|
+
)
|
|
516
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
517
|
+
conn.commit()
|
|
518
|
+
conn.close()
|
|
519
|
+
|
|
520
|
+
conn = sqlite3.connect(after_db)
|
|
521
|
+
conn.execute(
|
|
522
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
523
|
+
)
|
|
524
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive')")
|
|
525
|
+
conn.commit()
|
|
526
|
+
conn.close()
|
|
527
|
+
|
|
528
|
+
before = DatabaseSnapshot(before_db)
|
|
529
|
+
after = DatabaseSnapshot(after_db)
|
|
530
|
+
|
|
531
|
+
# Should fail because no_other_changes is missing
|
|
532
|
+
with pytest.raises(ValueError, match="missing required 'no_other_changes'"):
|
|
533
|
+
before.diff(after).expect_exactly(
|
|
534
|
+
[
|
|
535
|
+
{
|
|
536
|
+
"table": "users",
|
|
537
|
+
"pk": 1,
|
|
538
|
+
"type": "modify",
|
|
539
|
+
"resulting_fields": [
|
|
540
|
+
("name", "Alice Updated"),
|
|
541
|
+
("status", "inactive"),
|
|
542
|
+
],
|
|
543
|
+
# no_other_changes is MISSING - should raise ValueError
|
|
544
|
+
},
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
finally:
|
|
549
|
+
os.unlink(before_db)
|
|
550
|
+
os.unlink(after_db)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_modification_with_bulk_fields_spec_ellipsis():
|
|
554
|
+
"""Test that Ellipsis works in modification bulk field specs to skip value check"""
|
|
555
|
+
|
|
556
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
557
|
+
before_db = f.name
|
|
558
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
559
|
+
after_db = f.name
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
conn = sqlite3.connect(before_db)
|
|
563
|
+
conn.execute(
|
|
564
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
565
|
+
)
|
|
566
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
567
|
+
conn.commit()
|
|
568
|
+
conn.close()
|
|
569
|
+
|
|
570
|
+
conn = sqlite3.connect(after_db)
|
|
571
|
+
conn.execute(
|
|
572
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
573
|
+
)
|
|
574
|
+
# All three fields changed
|
|
575
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'inactive', '2024-01-15')")
|
|
576
|
+
conn.commit()
|
|
577
|
+
conn.close()
|
|
578
|
+
|
|
579
|
+
before = DatabaseSnapshot(before_db)
|
|
580
|
+
after = DatabaseSnapshot(after_db)
|
|
581
|
+
|
|
582
|
+
# Using Ellipsis to skip value check for updated_at
|
|
583
|
+
before.diff(after).expect_exactly(
|
|
584
|
+
[
|
|
585
|
+
{
|
|
586
|
+
"table": "users",
|
|
587
|
+
"pk": 1,
|
|
588
|
+
"type": "modify",
|
|
589
|
+
"resulting_fields": [
|
|
590
|
+
("name", "Alice Updated"),
|
|
591
|
+
("status", "inactive"),
|
|
592
|
+
("updated_at", ...), # Don't check value
|
|
593
|
+
],
|
|
594
|
+
"no_other_changes": True,
|
|
595
|
+
},
|
|
596
|
+
]
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
finally:
|
|
600
|
+
os.unlink(before_db)
|
|
601
|
+
os.unlink(after_db)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
605
|
+
def test_multiple_table_changes_with_mixed_specs():
|
|
606
|
+
"""Test complex scenario with multiple tables and mixed bulk field/whole-row specs in expect_exactly"""
|
|
607
|
+
|
|
608
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
609
|
+
before_db = f.name
|
|
610
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
611
|
+
after_db = f.name
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
# Setup before database with multiple tables
|
|
615
|
+
conn = sqlite3.connect(before_db)
|
|
616
|
+
conn.execute(
|
|
617
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
|
|
618
|
+
)
|
|
619
|
+
conn.execute(
|
|
620
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
|
|
621
|
+
)
|
|
622
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
|
|
623
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
|
|
624
|
+
conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'pending')")
|
|
625
|
+
conn.commit()
|
|
626
|
+
conn.close()
|
|
627
|
+
|
|
628
|
+
# Setup after database with complex changes
|
|
629
|
+
conn = sqlite3.connect(after_db)
|
|
630
|
+
conn.execute(
|
|
631
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
|
|
632
|
+
)
|
|
633
|
+
conn.execute(
|
|
634
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
|
|
635
|
+
)
|
|
636
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
|
|
637
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
|
|
638
|
+
conn.execute(
|
|
639
|
+
"INSERT INTO users VALUES (3, 'Charlie', 'charlie@test.com', 'user')"
|
|
640
|
+
)
|
|
641
|
+
conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'completed')")
|
|
642
|
+
conn.execute("INSERT INTO orders VALUES (2, 2, 50.0, 'pending')")
|
|
643
|
+
conn.commit()
|
|
644
|
+
conn.close()
|
|
645
|
+
|
|
646
|
+
before = DatabaseSnapshot(before_db)
|
|
647
|
+
after = DatabaseSnapshot(after_db)
|
|
648
|
+
|
|
649
|
+
# Mixed specs: bulk fields for new user, bulk fields for modification, and whole-row for new order
|
|
650
|
+
before.diff(after).expect_exactly(
|
|
651
|
+
[
|
|
652
|
+
# Bulk field specs for new user
|
|
653
|
+
{
|
|
654
|
+
"table": "users",
|
|
655
|
+
"pk": 3,
|
|
656
|
+
"type": "insert",
|
|
657
|
+
"fields": [
|
|
658
|
+
("id", 3),
|
|
659
|
+
("name", "Charlie"),
|
|
660
|
+
("email", "charlie@test.com"),
|
|
661
|
+
("role", "user"),
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
# Bulk field specs for order status modification (using new format)
|
|
665
|
+
{
|
|
666
|
+
"table": "orders",
|
|
667
|
+
"pk": 1,
|
|
668
|
+
"type": "modify",
|
|
669
|
+
"resulting_fields": [("status", "completed")],
|
|
670
|
+
"no_other_changes": True,
|
|
671
|
+
},
|
|
672
|
+
# Whole-row spec for new order (legacy)
|
|
673
|
+
{"table": "orders", "pk": 2, "fields": None, "after": "__added__"},
|
|
674
|
+
]
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
finally:
|
|
678
|
+
os.unlink(before_db)
|
|
679
|
+
os.unlink(after_db)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# test_partial_field_specs_with_unexpected_changes removed - uses legacy single-field spec format
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_numeric_type_conversion_in_specs():
|
|
686
|
+
"""Test that numeric type conversions work correctly in bulk field specs with expect_exactly"""
|
|
687
|
+
|
|
688
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
689
|
+
before_db = f.name
|
|
690
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
691
|
+
after_db = f.name
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
conn = sqlite3.connect(before_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.commit()
|
|
700
|
+
conn.close()
|
|
701
|
+
|
|
702
|
+
conn = sqlite3.connect(after_db)
|
|
703
|
+
conn.execute(
|
|
704
|
+
"CREATE TABLE metrics (id INTEGER PRIMARY KEY, value REAL, count INTEGER)"
|
|
705
|
+
)
|
|
706
|
+
conn.execute("INSERT INTO metrics VALUES (1, 3.14, 42)")
|
|
707
|
+
conn.execute("INSERT INTO metrics VALUES (2, 2.71, 17)")
|
|
708
|
+
conn.commit()
|
|
709
|
+
conn.close()
|
|
710
|
+
|
|
711
|
+
before = DatabaseSnapshot(before_db)
|
|
712
|
+
after = DatabaseSnapshot(after_db)
|
|
713
|
+
|
|
714
|
+
# Test string vs integer comparison for primary key
|
|
715
|
+
before.diff(after).expect_exactly(
|
|
716
|
+
[
|
|
717
|
+
{
|
|
718
|
+
"table": "metrics",
|
|
719
|
+
"pk": "2",
|
|
720
|
+
"type": "insert",
|
|
721
|
+
"fields": [("id", 2), ("value", 2.71), ("count", 17)],
|
|
722
|
+
},
|
|
723
|
+
]
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
finally:
|
|
727
|
+
os.unlink(before_db)
|
|
728
|
+
os.unlink(after_db)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
732
|
+
def test_deletion_with_field_level_specs():
|
|
733
|
+
"""Test that bulk field specs work for row deletions in expect_exactly"""
|
|
734
|
+
|
|
735
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
736
|
+
before_db = f.name
|
|
737
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
738
|
+
after_db = f.name
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
conn = sqlite3.connect(before_db)
|
|
742
|
+
conn.execute(
|
|
743
|
+
"CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
|
|
744
|
+
)
|
|
745
|
+
conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
|
|
746
|
+
conn.execute("INSERT INTO inventory VALUES (2, 'Widget B', 5, 'Warehouse 2')")
|
|
747
|
+
conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
|
|
748
|
+
conn.commit()
|
|
749
|
+
conn.close()
|
|
750
|
+
|
|
751
|
+
conn = sqlite3.connect(after_db)
|
|
752
|
+
conn.execute(
|
|
753
|
+
"CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
|
|
754
|
+
)
|
|
755
|
+
conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
|
|
756
|
+
conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
|
|
757
|
+
conn.commit()
|
|
758
|
+
conn.close()
|
|
759
|
+
|
|
760
|
+
before = DatabaseSnapshot(before_db)
|
|
761
|
+
after = DatabaseSnapshot(after_db)
|
|
762
|
+
|
|
763
|
+
# Bulk field specs for deleted row (with "type": "delete")
|
|
764
|
+
before.diff(after).expect_exactly(
|
|
765
|
+
[
|
|
766
|
+
{
|
|
767
|
+
"table": "inventory",
|
|
768
|
+
"pk": 2,
|
|
769
|
+
"type": "delete",
|
|
770
|
+
"fields": [
|
|
771
|
+
("id", 2),
|
|
772
|
+
("item", "Widget B"),
|
|
773
|
+
("quantity", 5),
|
|
774
|
+
("location", "Warehouse 2"),
|
|
775
|
+
],
|
|
776
|
+
},
|
|
777
|
+
# also do a whole-row check (legacy)
|
|
778
|
+
{
|
|
779
|
+
"table": "inventory",
|
|
780
|
+
"pk": 2,
|
|
781
|
+
"fields": None,
|
|
782
|
+
"after": "__removed__",
|
|
783
|
+
},
|
|
784
|
+
]
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
finally:
|
|
788
|
+
os.unlink(before_db)
|
|
789
|
+
os.unlink(after_db)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def test_mixed_data_types_and_null_values():
|
|
793
|
+
"""Test bulk field specs with mixed data types and null values in expect_exactly"""
|
|
794
|
+
|
|
795
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
796
|
+
before_db = f.name
|
|
797
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
798
|
+
after_db = f.name
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
conn = sqlite3.connect(before_db)
|
|
802
|
+
conn.execute(
|
|
803
|
+
"CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
|
|
804
|
+
)
|
|
805
|
+
conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
|
|
806
|
+
conn.commit()
|
|
807
|
+
conn.close()
|
|
808
|
+
|
|
809
|
+
conn = sqlite3.connect(after_db)
|
|
810
|
+
conn.execute(
|
|
811
|
+
"CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
|
|
812
|
+
)
|
|
813
|
+
conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
|
|
814
|
+
conn.execute("INSERT INTO mixed_data VALUES (2, NULL, 0.0, 0, 'not_null')")
|
|
815
|
+
conn.commit()
|
|
816
|
+
conn.close()
|
|
817
|
+
|
|
818
|
+
before = DatabaseSnapshot(before_db)
|
|
819
|
+
after = DatabaseSnapshot(after_db)
|
|
820
|
+
|
|
821
|
+
# Test various data types and null handling
|
|
822
|
+
# ("text_val", None) checks that the value is SQL NULL
|
|
823
|
+
# ("field", ...) means don't check the value
|
|
824
|
+
before.diff(after).expect_exactly(
|
|
825
|
+
[
|
|
826
|
+
{
|
|
827
|
+
"table": "mixed_data",
|
|
828
|
+
"pk": 2,
|
|
829
|
+
"type": "insert",
|
|
830
|
+
"fields": [
|
|
831
|
+
("id", 2),
|
|
832
|
+
("text_val", None), # Check that value IS NULL
|
|
833
|
+
("num_val", 0.0),
|
|
834
|
+
("bool_val", 0),
|
|
835
|
+
("null_val", "not_null"),
|
|
836
|
+
],
|
|
837
|
+
},
|
|
838
|
+
]
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
finally:
|
|
842
|
+
os.unlink(before_db)
|
|
843
|
+
os.unlink(after_db)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
847
|
+
def test_whole_row_spec_backward_compat():
|
|
848
|
+
"""Test that whole-row specs still work (backward compatibility)"""
|
|
849
|
+
|
|
850
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
851
|
+
before_db = f.name
|
|
852
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
853
|
+
after_db = f.name
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
conn = sqlite3.connect(before_db)
|
|
857
|
+
conn.execute(
|
|
858
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
859
|
+
)
|
|
860
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
861
|
+
conn.commit()
|
|
862
|
+
conn.close()
|
|
863
|
+
|
|
864
|
+
conn = sqlite3.connect(after_db)
|
|
865
|
+
conn.execute(
|
|
866
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
867
|
+
)
|
|
868
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
869
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
870
|
+
conn.commit()
|
|
871
|
+
conn.close()
|
|
872
|
+
|
|
873
|
+
before = DatabaseSnapshot(before_db)
|
|
874
|
+
after = DatabaseSnapshot(after_db)
|
|
875
|
+
|
|
876
|
+
# Whole-row spec should still work
|
|
877
|
+
before.diff(after).expect_exactly(
|
|
878
|
+
[{"table": "users", "pk": 2, "fields": None, "after": "__added__"}]
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
finally:
|
|
882
|
+
os.unlink(before_db)
|
|
883
|
+
os.unlink(after_db)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_missing_field_specs():
|
|
887
|
+
"""Test that missing fields in bulk field specs are detected in expect_exactly"""
|
|
888
|
+
|
|
889
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
890
|
+
before_db = f.name
|
|
891
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
892
|
+
after_db = f.name
|
|
893
|
+
|
|
894
|
+
try:
|
|
895
|
+
conn = sqlite3.connect(before_db)
|
|
896
|
+
conn.execute(
|
|
897
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
898
|
+
)
|
|
899
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
900
|
+
conn.commit()
|
|
901
|
+
conn.close()
|
|
902
|
+
|
|
903
|
+
conn = sqlite3.connect(after_db)
|
|
904
|
+
conn.execute(
|
|
905
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
906
|
+
)
|
|
907
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
908
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
909
|
+
conn.commit()
|
|
910
|
+
conn.close()
|
|
911
|
+
|
|
912
|
+
before = DatabaseSnapshot(before_db)
|
|
913
|
+
after = DatabaseSnapshot(after_db)
|
|
914
|
+
|
|
915
|
+
# Should fail because status field is missing from the fields spec
|
|
916
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
917
|
+
before.diff(after).expect_exactly(
|
|
918
|
+
[
|
|
919
|
+
{
|
|
920
|
+
"table": "users",
|
|
921
|
+
"pk": 2,
|
|
922
|
+
"type": "insert",
|
|
923
|
+
"fields": [
|
|
924
|
+
("id", 2),
|
|
925
|
+
("name", "Bob"),
|
|
926
|
+
# Missing status field - should fail
|
|
927
|
+
],
|
|
928
|
+
},
|
|
929
|
+
]
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
finally:
|
|
933
|
+
os.unlink(before_db)
|
|
934
|
+
os.unlink(after_db)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# test_modified_row_with_unauthorized_field_change removed - uses legacy single-field spec format
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def test_fields_spec_basic():
|
|
941
|
+
"""Test that bulk fields spec works correctly for added rows in expect_exactly"""
|
|
942
|
+
|
|
943
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
944
|
+
before_db = f.name
|
|
945
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
946
|
+
after_db = f.name
|
|
947
|
+
|
|
948
|
+
try:
|
|
949
|
+
conn = sqlite3.connect(before_db)
|
|
950
|
+
conn.execute(
|
|
951
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
952
|
+
)
|
|
953
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
954
|
+
conn.commit()
|
|
955
|
+
conn.close()
|
|
956
|
+
|
|
957
|
+
conn = sqlite3.connect(after_db)
|
|
958
|
+
conn.execute(
|
|
959
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
960
|
+
)
|
|
961
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
|
|
962
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
963
|
+
conn.commit()
|
|
964
|
+
conn.close()
|
|
965
|
+
|
|
966
|
+
before = DatabaseSnapshot(before_db)
|
|
967
|
+
after = DatabaseSnapshot(after_db)
|
|
968
|
+
|
|
969
|
+
# Test: All fields specified with exact values - should pass
|
|
970
|
+
before.diff(after).expect_exactly(
|
|
971
|
+
[
|
|
972
|
+
{
|
|
973
|
+
"table": "users",
|
|
974
|
+
"pk": 2,
|
|
975
|
+
"type": "insert",
|
|
976
|
+
"fields": [
|
|
977
|
+
("id", 2),
|
|
978
|
+
("name", "Bob"),
|
|
979
|
+
("status", "inactive"),
|
|
980
|
+
("updated_at", "2024-01-02"),
|
|
981
|
+
],
|
|
982
|
+
},
|
|
983
|
+
]
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
finally:
|
|
987
|
+
os.unlink(before_db)
|
|
988
|
+
os.unlink(after_db)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def test_fields_spec_with_ellipsis_means_dont_check():
|
|
992
|
+
"""Test that Ellipsis (...) in a 2-tuple means 'don't check this field's value'"""
|
|
993
|
+
|
|
994
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
995
|
+
before_db = f.name
|
|
996
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
997
|
+
after_db = f.name
|
|
998
|
+
|
|
999
|
+
try:
|
|
1000
|
+
conn = sqlite3.connect(before_db)
|
|
1001
|
+
conn.execute(
|
|
1002
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1003
|
+
)
|
|
1004
|
+
conn.commit()
|
|
1005
|
+
conn.close()
|
|
1006
|
+
|
|
1007
|
+
conn = sqlite3.connect(after_db)
|
|
1008
|
+
conn.execute(
|
|
1009
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1010
|
+
)
|
|
1011
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1012
|
+
conn.commit()
|
|
1013
|
+
conn.close()
|
|
1014
|
+
|
|
1015
|
+
before = DatabaseSnapshot(before_db)
|
|
1016
|
+
after = DatabaseSnapshot(after_db)
|
|
1017
|
+
|
|
1018
|
+
# Test: Using Ellipsis means don't check the value - should pass
|
|
1019
|
+
# even though updated_at is '2024-01-02'
|
|
1020
|
+
before.diff(after).expect_exactly(
|
|
1021
|
+
[
|
|
1022
|
+
{
|
|
1023
|
+
"table": "users",
|
|
1024
|
+
"pk": 2,
|
|
1025
|
+
"type": "insert",
|
|
1026
|
+
"fields": [
|
|
1027
|
+
("id", 2),
|
|
1028
|
+
("name", "Bob"),
|
|
1029
|
+
("status", "inactive"),
|
|
1030
|
+
("updated_at", ...), # Don't check this value
|
|
1031
|
+
],
|
|
1032
|
+
},
|
|
1033
|
+
]
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
finally:
|
|
1037
|
+
os.unlink(before_db)
|
|
1038
|
+
os.unlink(after_db)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def test_fields_spec_with_none_checks_for_null():
|
|
1042
|
+
"""Test that None in a 2-tuple means 'check that field is SQL NULL'"""
|
|
1043
|
+
|
|
1044
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1045
|
+
before_db = f.name
|
|
1046
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1047
|
+
after_db = f.name
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
conn = sqlite3.connect(before_db)
|
|
1051
|
+
conn.execute(
|
|
1052
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1053
|
+
)
|
|
1054
|
+
conn.commit()
|
|
1055
|
+
conn.close()
|
|
1056
|
+
|
|
1057
|
+
conn = sqlite3.connect(after_db)
|
|
1058
|
+
conn.execute(
|
|
1059
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1060
|
+
)
|
|
1061
|
+
# deleted_at is NULL
|
|
1062
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'active', NULL)")
|
|
1063
|
+
conn.commit()
|
|
1064
|
+
conn.close()
|
|
1065
|
+
|
|
1066
|
+
before = DatabaseSnapshot(before_db)
|
|
1067
|
+
after = DatabaseSnapshot(after_db)
|
|
1068
|
+
|
|
1069
|
+
# Test: Using None means check that value is SQL NULL - should pass
|
|
1070
|
+
before.diff(after).expect_exactly(
|
|
1071
|
+
[
|
|
1072
|
+
{
|
|
1073
|
+
"table": "users",
|
|
1074
|
+
"pk": 2,
|
|
1075
|
+
"type": "insert",
|
|
1076
|
+
"fields": [
|
|
1077
|
+
("id", 2),
|
|
1078
|
+
("name", "Bob"),
|
|
1079
|
+
("status", "active"),
|
|
1080
|
+
("deleted_at", None), # Check that this IS NULL
|
|
1081
|
+
],
|
|
1082
|
+
},
|
|
1083
|
+
]
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
finally:
|
|
1087
|
+
os.unlink(before_db)
|
|
1088
|
+
os.unlink(after_db)
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def test_fields_spec_with_none_fails_when_not_null():
|
|
1092
|
+
"""Test that None check fails when field is not actually NULL"""
|
|
1093
|
+
|
|
1094
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1095
|
+
before_db = f.name
|
|
1096
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1097
|
+
after_db = f.name
|
|
1098
|
+
|
|
1099
|
+
try:
|
|
1100
|
+
conn = sqlite3.connect(before_db)
|
|
1101
|
+
conn.execute(
|
|
1102
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1103
|
+
)
|
|
1104
|
+
conn.commit()
|
|
1105
|
+
conn.close()
|
|
1106
|
+
|
|
1107
|
+
conn = sqlite3.connect(after_db)
|
|
1108
|
+
conn.execute(
|
|
1109
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, deleted_at TEXT)"
|
|
1110
|
+
)
|
|
1111
|
+
# deleted_at is NOT NULL - has a value
|
|
1112
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'active', '2024-01-15')")
|
|
1113
|
+
conn.commit()
|
|
1114
|
+
conn.close()
|
|
1115
|
+
|
|
1116
|
+
before = DatabaseSnapshot(before_db)
|
|
1117
|
+
after = DatabaseSnapshot(after_db)
|
|
1118
|
+
|
|
1119
|
+
# Test: Using None to check for NULL, but field is NOT NULL - should fail
|
|
1120
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1121
|
+
before.diff(after).expect_exactly(
|
|
1122
|
+
[
|
|
1123
|
+
{
|
|
1124
|
+
"table": "users",
|
|
1125
|
+
"pk": 2,
|
|
1126
|
+
"type": "insert",
|
|
1127
|
+
"fields": [
|
|
1128
|
+
("id", 2),
|
|
1129
|
+
("name", "Bob"),
|
|
1130
|
+
("status", "active"),
|
|
1131
|
+
("deleted_at", None), # Expect NULL, but actual is '2024-01-15'
|
|
1132
|
+
],
|
|
1133
|
+
},
|
|
1134
|
+
]
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
assert "deleted_at" in str(exc_info.value)
|
|
1138
|
+
assert "None" in str(exc_info.value) # Expected NULL shown in table
|
|
1139
|
+
|
|
1140
|
+
finally:
|
|
1141
|
+
os.unlink(before_db)
|
|
1142
|
+
os.unlink(after_db)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def test_fields_spec_1_tuple_raises_error():
|
|
1146
|
+
"""Test that a 1-tuple raises an error (use Ellipsis instead)"""
|
|
1147
|
+
|
|
1148
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1149
|
+
before_db = f.name
|
|
1150
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1151
|
+
after_db = f.name
|
|
1152
|
+
|
|
1153
|
+
try:
|
|
1154
|
+
conn = sqlite3.connect(before_db)
|
|
1155
|
+
conn.execute(
|
|
1156
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1157
|
+
)
|
|
1158
|
+
conn.commit()
|
|
1159
|
+
conn.close()
|
|
1160
|
+
|
|
1161
|
+
conn = sqlite3.connect(after_db)
|
|
1162
|
+
conn.execute(
|
|
1163
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1164
|
+
)
|
|
1165
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1166
|
+
conn.commit()
|
|
1167
|
+
conn.close()
|
|
1168
|
+
|
|
1169
|
+
before = DatabaseSnapshot(before_db)
|
|
1170
|
+
after = DatabaseSnapshot(after_db)
|
|
1171
|
+
|
|
1172
|
+
# Test: 1-tuple is no longer supported - should raise ValueError
|
|
1173
|
+
with pytest.raises(ValueError, match="Invalid field spec"):
|
|
1174
|
+
before.diff(after).expect_exactly(
|
|
1175
|
+
[
|
|
1176
|
+
{
|
|
1177
|
+
"table": "users",
|
|
1178
|
+
"pk": 2,
|
|
1179
|
+
"type": "insert",
|
|
1180
|
+
"fields": [
|
|
1181
|
+
("id", 2),
|
|
1182
|
+
("name", "Bob"),
|
|
1183
|
+
("status",), # 1-tuple: NOT SUPPORTED - use ("status", ...) instead
|
|
1184
|
+
("updated_at",),
|
|
1185
|
+
],
|
|
1186
|
+
},
|
|
1187
|
+
]
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
finally:
|
|
1191
|
+
os.unlink(before_db)
|
|
1192
|
+
os.unlink(after_db)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def test_fields_spec_missing_field_fails():
|
|
1196
|
+
"""Test that missing a field in the fields spec causes validation to fail"""
|
|
1197
|
+
|
|
1198
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1199
|
+
before_db = f.name
|
|
1200
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1201
|
+
after_db = f.name
|
|
1202
|
+
|
|
1203
|
+
try:
|
|
1204
|
+
conn = sqlite3.connect(before_db)
|
|
1205
|
+
conn.execute(
|
|
1206
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1207
|
+
)
|
|
1208
|
+
conn.commit()
|
|
1209
|
+
conn.close()
|
|
1210
|
+
|
|
1211
|
+
conn = sqlite3.connect(after_db)
|
|
1212
|
+
conn.execute(
|
|
1213
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1214
|
+
)
|
|
1215
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1216
|
+
conn.commit()
|
|
1217
|
+
conn.close()
|
|
1218
|
+
|
|
1219
|
+
before = DatabaseSnapshot(before_db)
|
|
1220
|
+
after = DatabaseSnapshot(after_db)
|
|
1221
|
+
|
|
1222
|
+
# Test: Missing 'status' field should cause validation to fail
|
|
1223
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1224
|
+
before.diff(after).expect_exactly(
|
|
1225
|
+
[
|
|
1226
|
+
{
|
|
1227
|
+
"table": "users",
|
|
1228
|
+
"pk": 2,
|
|
1229
|
+
"type": "insert",
|
|
1230
|
+
"fields": [
|
|
1231
|
+
("id", 2),
|
|
1232
|
+
("name", "Bob"),
|
|
1233
|
+
# status is MISSING - should fail
|
|
1234
|
+
("updated_at", ...), # Don't check this value
|
|
1235
|
+
],
|
|
1236
|
+
},
|
|
1237
|
+
]
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
assert "status" in str(exc_info.value)
|
|
1241
|
+
assert "not specified in expected fields" in str(exc_info.value)
|
|
1242
|
+
|
|
1243
|
+
finally:
|
|
1244
|
+
os.unlink(before_db)
|
|
1245
|
+
os.unlink(after_db)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def test_fields_spec_wrong_value_fails():
|
|
1249
|
+
"""Test that wrong field value in fields spec causes validation to fail"""
|
|
1250
|
+
|
|
1251
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1252
|
+
before_db = f.name
|
|
1253
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1254
|
+
after_db = f.name
|
|
1255
|
+
|
|
1256
|
+
try:
|
|
1257
|
+
conn = sqlite3.connect(before_db)
|
|
1258
|
+
conn.execute(
|
|
1259
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1260
|
+
)
|
|
1261
|
+
conn.commit()
|
|
1262
|
+
conn.close()
|
|
1263
|
+
|
|
1264
|
+
conn = sqlite3.connect(after_db)
|
|
1265
|
+
conn.execute(
|
|
1266
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1267
|
+
)
|
|
1268
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1269
|
+
conn.commit()
|
|
1270
|
+
conn.close()
|
|
1271
|
+
|
|
1272
|
+
before = DatabaseSnapshot(before_db)
|
|
1273
|
+
after = DatabaseSnapshot(after_db)
|
|
1274
|
+
|
|
1275
|
+
# Test: Wrong value for 'status' should fail
|
|
1276
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
1277
|
+
before.diff(after).expect_exactly(
|
|
1278
|
+
[
|
|
1279
|
+
{
|
|
1280
|
+
"table": "users",
|
|
1281
|
+
"pk": 2,
|
|
1282
|
+
"type": "insert",
|
|
1283
|
+
"fields": [
|
|
1284
|
+
("id", 2),
|
|
1285
|
+
("name", "Bob"),
|
|
1286
|
+
("status", "active"), # Wrong value - row has 'inactive'
|
|
1287
|
+
("updated_at", ...), # Don't check this value
|
|
1288
|
+
],
|
|
1289
|
+
},
|
|
1290
|
+
]
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
assert "status" in str(exc_info.value)
|
|
1294
|
+
assert "'active'" in str(exc_info.value) # Expected value shown in table
|
|
1295
|
+
|
|
1296
|
+
finally:
|
|
1297
|
+
os.unlink(before_db)
|
|
1298
|
+
os.unlink(after_db)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def test_fields_spec_with_ignore_config():
|
|
1302
|
+
"""Test that ignore_config works correctly with bulk fields spec"""
|
|
1303
|
+
|
|
1304
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1305
|
+
before_db = f.name
|
|
1306
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1307
|
+
after_db = f.name
|
|
1308
|
+
|
|
1309
|
+
try:
|
|
1310
|
+
conn = sqlite3.connect(before_db)
|
|
1311
|
+
conn.execute(
|
|
1312
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1313
|
+
)
|
|
1314
|
+
conn.commit()
|
|
1315
|
+
conn.close()
|
|
1316
|
+
|
|
1317
|
+
conn = sqlite3.connect(after_db)
|
|
1318
|
+
conn.execute(
|
|
1319
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
|
|
1320
|
+
)
|
|
1321
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
|
|
1322
|
+
conn.commit()
|
|
1323
|
+
conn.close()
|
|
1324
|
+
|
|
1325
|
+
before = DatabaseSnapshot(before_db)
|
|
1326
|
+
after = DatabaseSnapshot(after_db)
|
|
1327
|
+
|
|
1328
|
+
# Ignore the updated_at field globally
|
|
1329
|
+
ignore_config = IgnoreConfig(table_fields={"users": {"updated_at"}})
|
|
1330
|
+
|
|
1331
|
+
# Test: With ignore_config, we don't need to specify updated_at
|
|
1332
|
+
before.diff(after, ignore_config).expect_exactly(
|
|
1333
|
+
[
|
|
1334
|
+
{
|
|
1335
|
+
"table": "users",
|
|
1336
|
+
"pk": 2,
|
|
1337
|
+
"type": "insert",
|
|
1338
|
+
"fields": [
|
|
1339
|
+
("id", 2),
|
|
1340
|
+
("name", "Bob"),
|
|
1341
|
+
("status", "inactive"),
|
|
1342
|
+
# updated_at is ignored, so we don't need to specify it
|
|
1343
|
+
],
|
|
1344
|
+
},
|
|
1345
|
+
]
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
finally:
|
|
1349
|
+
os.unlink(before_db)
|
|
1350
|
+
os.unlink(after_db)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
# ============================================================================
|
|
1354
|
+
# Tests demonstrating expect_only vs expect_exactly behavior
|
|
1355
|
+
# These tests show cases where expect_only (whole-row only) is more permissive
|
|
1356
|
+
# than expect_exactly (field-level specs).
|
|
1357
|
+
# ============================================================================
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1361
|
+
def test_security_whole_row_spec_allows_any_values():
|
|
1362
|
+
"""
|
|
1363
|
+
expect_only with whole-row specs allows ANY field values.
|
|
1364
|
+
|
|
1365
|
+
This demonstrates that expect_only with field=None (whole-row spec)
|
|
1366
|
+
is permissive - it only checks that a row was added, not what values it has.
|
|
1367
|
+
Use expect_exactly with field-level specs for stricter validation.
|
|
1368
|
+
"""
|
|
1369
|
+
|
|
1370
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1371
|
+
before_db = f.name
|
|
1372
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1373
|
+
after_db = f.name
|
|
1374
|
+
|
|
1375
|
+
try:
|
|
1376
|
+
conn = sqlite3.connect(before_db)
|
|
1377
|
+
conn.execute(
|
|
1378
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1379
|
+
)
|
|
1380
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1381
|
+
conn.commit()
|
|
1382
|
+
conn.close()
|
|
1383
|
+
|
|
1384
|
+
conn = sqlite3.connect(after_db)
|
|
1385
|
+
conn.execute(
|
|
1386
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1387
|
+
)
|
|
1388
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1389
|
+
# User added with admin role
|
|
1390
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'admin', 1)")
|
|
1391
|
+
conn.commit()
|
|
1392
|
+
conn.close()
|
|
1393
|
+
|
|
1394
|
+
before = DatabaseSnapshot(before_db)
|
|
1395
|
+
after = DatabaseSnapshot(after_db)
|
|
1396
|
+
|
|
1397
|
+
# expect_only with whole-row spec passes - doesn't check field values
|
|
1398
|
+
before.diff(after).expect_exactly(
|
|
1399
|
+
[{"table": "users", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
finally:
|
|
1403
|
+
os.unlink(before_db)
|
|
1404
|
+
os.unlink(after_db)
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def test_security_field_level_specs_catch_wrong_role():
|
|
1408
|
+
"""
|
|
1409
|
+
expect_exactly with bulk field specs catches unauthorized values.
|
|
1410
|
+
|
|
1411
|
+
If someone tries to add a user with 'admin' role when we expected 'user',
|
|
1412
|
+
expect_exactly will catch it.
|
|
1413
|
+
"""
|
|
1414
|
+
|
|
1415
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1416
|
+
before_db = f.name
|
|
1417
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1418
|
+
after_db = f.name
|
|
1419
|
+
|
|
1420
|
+
try:
|
|
1421
|
+
conn = sqlite3.connect(before_db)
|
|
1422
|
+
conn.execute(
|
|
1423
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1424
|
+
)
|
|
1425
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1426
|
+
conn.commit()
|
|
1427
|
+
conn.close()
|
|
1428
|
+
|
|
1429
|
+
conn = sqlite3.connect(after_db)
|
|
1430
|
+
conn.execute(
|
|
1431
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
|
|
1432
|
+
)
|
|
1433
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
|
|
1434
|
+
# User added with admin role
|
|
1435
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'admin', 1)")
|
|
1436
|
+
conn.commit()
|
|
1437
|
+
conn.close()
|
|
1438
|
+
|
|
1439
|
+
before = DatabaseSnapshot(before_db)
|
|
1440
|
+
after = DatabaseSnapshot(after_db)
|
|
1441
|
+
|
|
1442
|
+
# expect_exactly correctly FAILS because role is 'admin' not 'user'
|
|
1443
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1444
|
+
before.diff(after).expect_exactly(
|
|
1445
|
+
[
|
|
1446
|
+
{
|
|
1447
|
+
"table": "users",
|
|
1448
|
+
"pk": 2,
|
|
1449
|
+
"type": "insert",
|
|
1450
|
+
"fields": [
|
|
1451
|
+
("id", 2),
|
|
1452
|
+
("name", "Bob"),
|
|
1453
|
+
("role", "user"), # Expected 'user', but actual is 'admin'
|
|
1454
|
+
("active", 1),
|
|
1455
|
+
],
|
|
1456
|
+
},
|
|
1457
|
+
]
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
finally:
|
|
1461
|
+
os.unlink(before_db)
|
|
1462
|
+
os.unlink(after_db)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1466
|
+
def test_financial_data_validation():
|
|
1467
|
+
"""
|
|
1468
|
+
Demonstrates difference between expect_only and expect_exactly for financial data.
|
|
1469
|
+
"""
|
|
1470
|
+
|
|
1471
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1472
|
+
before_db = f.name
|
|
1473
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1474
|
+
after_db = f.name
|
|
1475
|
+
|
|
1476
|
+
try:
|
|
1477
|
+
conn = sqlite3.connect(before_db)
|
|
1478
|
+
conn.execute(
|
|
1479
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
|
|
1480
|
+
)
|
|
1481
|
+
conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
|
|
1482
|
+
conn.commit()
|
|
1483
|
+
conn.close()
|
|
1484
|
+
|
|
1485
|
+
conn = sqlite3.connect(after_db)
|
|
1486
|
+
conn.execute(
|
|
1487
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
|
|
1488
|
+
)
|
|
1489
|
+
conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
|
|
1490
|
+
# Order with 100% discount
|
|
1491
|
+
conn.execute("INSERT INTO orders VALUES (2, 200, 1000.00, 1000.00)")
|
|
1492
|
+
conn.commit()
|
|
1493
|
+
conn.close()
|
|
1494
|
+
|
|
1495
|
+
before = DatabaseSnapshot(before_db)
|
|
1496
|
+
after = DatabaseSnapshot(after_db)
|
|
1497
|
+
|
|
1498
|
+
# expect_only with whole-row spec passes - doesn't check discount value
|
|
1499
|
+
before.diff(after).expect_exactly(
|
|
1500
|
+
[{"table": "orders", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
# expect_exactly with bulk field specs catches unexpected discount
|
|
1504
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1505
|
+
before.diff(after).expect_exactly(
|
|
1506
|
+
[
|
|
1507
|
+
{
|
|
1508
|
+
"table": "orders",
|
|
1509
|
+
"pk": 2,
|
|
1510
|
+
"type": "insert",
|
|
1511
|
+
"fields": [
|
|
1512
|
+
("id", 2),
|
|
1513
|
+
("user_id", 200),
|
|
1514
|
+
("amount", 1000.00),
|
|
1515
|
+
("discount", 0.0), # Expected no discount, but actual is 1000.00
|
|
1516
|
+
],
|
|
1517
|
+
},
|
|
1518
|
+
]
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
finally:
|
|
1522
|
+
os.unlink(before_db)
|
|
1523
|
+
os.unlink(after_db)
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1527
|
+
def test_permissions_validation():
|
|
1528
|
+
"""
|
|
1529
|
+
Demonstrates difference between expect_only and expect_exactly for permissions.
|
|
1530
|
+
"""
|
|
1531
|
+
|
|
1532
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1533
|
+
before_db = f.name
|
|
1534
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1535
|
+
after_db = f.name
|
|
1536
|
+
|
|
1537
|
+
try:
|
|
1538
|
+
conn = sqlite3.connect(before_db)
|
|
1539
|
+
conn.execute(
|
|
1540
|
+
"CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
|
|
1541
|
+
)
|
|
1542
|
+
conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
|
|
1543
|
+
conn.commit()
|
|
1544
|
+
conn.close()
|
|
1545
|
+
|
|
1546
|
+
conn = sqlite3.connect(after_db)
|
|
1547
|
+
conn.execute(
|
|
1548
|
+
"CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
|
|
1549
|
+
)
|
|
1550
|
+
conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
|
|
1551
|
+
# Grant full permissions including delete
|
|
1552
|
+
conn.execute("INSERT INTO permissions VALUES (2, 200, 'admin_panel', 1, 1, 1)")
|
|
1553
|
+
conn.commit()
|
|
1554
|
+
conn.close()
|
|
1555
|
+
|
|
1556
|
+
before = DatabaseSnapshot(before_db)
|
|
1557
|
+
after = DatabaseSnapshot(after_db)
|
|
1558
|
+
|
|
1559
|
+
# expect_only with whole-row spec passes - doesn't check permission values
|
|
1560
|
+
before.diff(after).expect_exactly(
|
|
1561
|
+
[{"table": "permissions", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# expect_exactly with bulk field specs catches unexpected delete permission
|
|
1565
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1566
|
+
before.diff(after).expect_exactly(
|
|
1567
|
+
[
|
|
1568
|
+
{
|
|
1569
|
+
"table": "permissions",
|
|
1570
|
+
"pk": 2,
|
|
1571
|
+
"type": "insert",
|
|
1572
|
+
"fields": [
|
|
1573
|
+
("id", 2),
|
|
1574
|
+
("user_id", 200),
|
|
1575
|
+
("resource", "admin_panel"),
|
|
1576
|
+
("can_read", 1),
|
|
1577
|
+
("can_write", 1),
|
|
1578
|
+
("can_delete", 0), # Expected NO delete, but actual is 1
|
|
1579
|
+
],
|
|
1580
|
+
},
|
|
1581
|
+
]
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
finally:
|
|
1585
|
+
os.unlink(before_db)
|
|
1586
|
+
os.unlink(after_db)
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1590
|
+
def test_json_field_validation():
|
|
1591
|
+
"""
|
|
1592
|
+
Demonstrates difference between expect_only and expect_exactly for JSON/text fields.
|
|
1593
|
+
"""
|
|
1594
|
+
|
|
1595
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1596
|
+
before_db = f.name
|
|
1597
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1598
|
+
after_db = f.name
|
|
1599
|
+
|
|
1600
|
+
try:
|
|
1601
|
+
conn = sqlite3.connect(before_db)
|
|
1602
|
+
conn.execute(
|
|
1603
|
+
"CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
|
|
1604
|
+
)
|
|
1605
|
+
conn.execute(
|
|
1606
|
+
"INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
|
|
1607
|
+
)
|
|
1608
|
+
conn.commit()
|
|
1609
|
+
conn.close()
|
|
1610
|
+
|
|
1611
|
+
conn = sqlite3.connect(after_db)
|
|
1612
|
+
conn.execute(
|
|
1613
|
+
"CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
|
|
1614
|
+
)
|
|
1615
|
+
conn.execute(
|
|
1616
|
+
"INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
|
|
1617
|
+
)
|
|
1618
|
+
# Config with different settings
|
|
1619
|
+
conn.execute(
|
|
1620
|
+
'INSERT INTO configs VALUES (2, \'user_config\', \'{"debug": true, "extra": "value"}\')'
|
|
1621
|
+
)
|
|
1622
|
+
conn.commit()
|
|
1623
|
+
conn.close()
|
|
1624
|
+
|
|
1625
|
+
before = DatabaseSnapshot(before_db)
|
|
1626
|
+
after = DatabaseSnapshot(after_db)
|
|
1627
|
+
|
|
1628
|
+
# expect_only with whole-row spec passes - doesn't check settings value
|
|
1629
|
+
before.diff(after).expect_exactly(
|
|
1630
|
+
[{"table": "configs", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
# expect_exactly with bulk field specs catches unexpected settings
|
|
1634
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1635
|
+
before.diff(after).expect_exactly(
|
|
1636
|
+
[
|
|
1637
|
+
{
|
|
1638
|
+
"table": "configs",
|
|
1639
|
+
"pk": 2,
|
|
1640
|
+
"type": "insert",
|
|
1641
|
+
"fields": [
|
|
1642
|
+
("id", 2),
|
|
1643
|
+
("name", "user_config"),
|
|
1644
|
+
("settings", '{"debug": false}'), # Wrong value
|
|
1645
|
+
],
|
|
1646
|
+
},
|
|
1647
|
+
]
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
finally:
|
|
1651
|
+
os.unlink(before_db)
|
|
1652
|
+
os.unlink(after_db)
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
# ============================================================================
|
|
1656
|
+
# Tests showing expect_only vs expect_exactly behavior with conflicting specs
|
|
1657
|
+
# ============================================================================
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1661
|
+
def test_expect_only_ignores_field_specs_with_whole_row():
|
|
1662
|
+
"""
|
|
1663
|
+
expect_only with whole-row spec ignores any additional field specs.
|
|
1664
|
+
expect_exactly with bulk field specs validates field values.
|
|
1665
|
+
"""
|
|
1666
|
+
|
|
1667
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1668
|
+
before_db = f.name
|
|
1669
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1670
|
+
after_db = f.name
|
|
1671
|
+
|
|
1672
|
+
try:
|
|
1673
|
+
conn = sqlite3.connect(before_db)
|
|
1674
|
+
conn.execute(
|
|
1675
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
|
|
1676
|
+
)
|
|
1677
|
+
conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
|
|
1678
|
+
conn.commit()
|
|
1679
|
+
conn.close()
|
|
1680
|
+
|
|
1681
|
+
conn = sqlite3.connect(after_db)
|
|
1682
|
+
conn.execute(
|
|
1683
|
+
"CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
|
|
1684
|
+
)
|
|
1685
|
+
conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
|
|
1686
|
+
# Add product with price=999.99 and stock=1
|
|
1687
|
+
conn.execute("INSERT INTO products VALUES (2, 'Gadget', 999.99, 1)")
|
|
1688
|
+
conn.commit()
|
|
1689
|
+
conn.close()
|
|
1690
|
+
|
|
1691
|
+
before = DatabaseSnapshot(before_db)
|
|
1692
|
+
after = DatabaseSnapshot(after_db)
|
|
1693
|
+
|
|
1694
|
+
# expect_only with whole-row spec passes - ignores field specs
|
|
1695
|
+
before.diff(after).expect_exactly(
|
|
1696
|
+
[{"table": "products", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1697
|
+
)
|
|
1698
|
+
|
|
1699
|
+
# expect_exactly with wrong field values fails
|
|
1700
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1701
|
+
before.diff(after).expect_exactly(
|
|
1702
|
+
[
|
|
1703
|
+
{
|
|
1704
|
+
"table": "products",
|
|
1705
|
+
"pk": 2,
|
|
1706
|
+
"type": "insert",
|
|
1707
|
+
"fields": [
|
|
1708
|
+
("id", 2),
|
|
1709
|
+
("name", "Gadget"),
|
|
1710
|
+
("price", 50.0), # WRONG! Actually 999.99
|
|
1711
|
+
("stock", 500), # WRONG! Actually 1
|
|
1712
|
+
],
|
|
1713
|
+
},
|
|
1714
|
+
]
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
finally:
|
|
1718
|
+
os.unlink(before_db)
|
|
1719
|
+
os.unlink(after_db)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1723
|
+
def test_expect_exactly_validates_field_values():
|
|
1724
|
+
"""
|
|
1725
|
+
expect_exactly validates field values for added rows.
|
|
1726
|
+
"""
|
|
1727
|
+
|
|
1728
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1729
|
+
before_db = f.name
|
|
1730
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1731
|
+
after_db = f.name
|
|
1732
|
+
|
|
1733
|
+
try:
|
|
1734
|
+
conn = sqlite3.connect(before_db)
|
|
1735
|
+
conn.execute(
|
|
1736
|
+
"CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
|
|
1737
|
+
)
|
|
1738
|
+
conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
|
|
1739
|
+
conn.commit()
|
|
1740
|
+
conn.close()
|
|
1741
|
+
|
|
1742
|
+
conn = sqlite3.connect(after_db)
|
|
1743
|
+
conn.execute(
|
|
1744
|
+
"CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
|
|
1745
|
+
)
|
|
1746
|
+
conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
|
|
1747
|
+
# Actual: role=admin, balance=1000000.0
|
|
1748
|
+
conn.execute("INSERT INTO accounts VALUES (2, 'bob', 'admin', 1000000.0)")
|
|
1749
|
+
conn.commit()
|
|
1750
|
+
conn.close()
|
|
1751
|
+
|
|
1752
|
+
before = DatabaseSnapshot(before_db)
|
|
1753
|
+
after = DatabaseSnapshot(after_db)
|
|
1754
|
+
|
|
1755
|
+
# expect_only with whole-row spec passes
|
|
1756
|
+
before.diff(after).expect_exactly(
|
|
1757
|
+
[{"table": "accounts", "pk": 2, "fields": None, "after": "__added__"}]
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
# expect_exactly with wrong field values fails
|
|
1761
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1762
|
+
before.diff(after).expect_exactly(
|
|
1763
|
+
[
|
|
1764
|
+
{
|
|
1765
|
+
"table": "accounts",
|
|
1766
|
+
"pk": 2,
|
|
1767
|
+
"type": "insert",
|
|
1768
|
+
"fields": [
|
|
1769
|
+
("id", 2),
|
|
1770
|
+
("username", "bob"),
|
|
1771
|
+
("role", "user"), # Actually "admin"!
|
|
1772
|
+
("balance", 0.0), # Actually 1000000.0!
|
|
1773
|
+
],
|
|
1774
|
+
},
|
|
1775
|
+
]
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
finally:
|
|
1779
|
+
os.unlink(before_db)
|
|
1780
|
+
os.unlink(after_db)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1784
|
+
def test_expect_exactly_validates_is_public():
|
|
1785
|
+
"""
|
|
1786
|
+
expect_exactly validates field values including boolean-like fields.
|
|
1787
|
+
"""
|
|
1788
|
+
|
|
1789
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1790
|
+
before_db = f.name
|
|
1791
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1792
|
+
after_db = f.name
|
|
1793
|
+
|
|
1794
|
+
try:
|
|
1795
|
+
conn = sqlite3.connect(before_db)
|
|
1796
|
+
conn.execute(
|
|
1797
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
|
|
1798
|
+
)
|
|
1799
|
+
conn.commit()
|
|
1800
|
+
conn.close()
|
|
1801
|
+
|
|
1802
|
+
conn = sqlite3.connect(after_db)
|
|
1803
|
+
conn.execute(
|
|
1804
|
+
"CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
|
|
1805
|
+
)
|
|
1806
|
+
# Add a setting with is_public=1
|
|
1807
|
+
conn.execute(
|
|
1808
|
+
"INSERT INTO settings VALUES (1, 'api_key', 'secret123', 1)"
|
|
1809
|
+
)
|
|
1810
|
+
conn.commit()
|
|
1811
|
+
conn.close()
|
|
1812
|
+
|
|
1813
|
+
before = DatabaseSnapshot(before_db)
|
|
1814
|
+
after = DatabaseSnapshot(after_db)
|
|
1815
|
+
|
|
1816
|
+
# expect_only with whole-row spec passes
|
|
1817
|
+
before.diff(after).expect_exactly(
|
|
1818
|
+
[{"table": "settings", "pk": 1, "fields": None, "after": "__added__"}]
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
# expect_exactly with wrong is_public value fails
|
|
1822
|
+
with pytest.raises(AssertionError, match="VERIFICATION FAILED"):
|
|
1823
|
+
before.diff(after).expect_exactly(
|
|
1824
|
+
[
|
|
1825
|
+
{
|
|
1826
|
+
"table": "settings",
|
|
1827
|
+
"pk": 1,
|
|
1828
|
+
"type": "insert",
|
|
1829
|
+
"fields": [
|
|
1830
|
+
("id", 1),
|
|
1831
|
+
("key", "api_key"),
|
|
1832
|
+
("value", "secret123"),
|
|
1833
|
+
("is_public", 0), # Says private, but actually public!
|
|
1834
|
+
],
|
|
1835
|
+
},
|
|
1836
|
+
]
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
finally:
|
|
1840
|
+
os.unlink(before_db)
|
|
1841
|
+
os.unlink(after_db)
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
@pytest.mark.skip(reason="Uses legacy spec format not supported by expect_exactly")
|
|
1845
|
+
def test_deletion_with_bulk_fields_spec():
|
|
1846
|
+
"""
|
|
1847
|
+
expect_exactly validates field values for deleted rows using bulk field specs with 'type': 'delete',
|
|
1848
|
+
and without fields.
|
|
1849
|
+
"""
|
|
1850
|
+
|
|
1851
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1852
|
+
before_db = f.name
|
|
1853
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1854
|
+
after_db = f.name
|
|
1855
|
+
|
|
1856
|
+
try:
|
|
1857
|
+
conn = sqlite3.connect(before_db)
|
|
1858
|
+
conn.execute(
|
|
1859
|
+
"CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
|
|
1860
|
+
)
|
|
1861
|
+
conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
|
|
1862
|
+
conn.execute("INSERT INTO sessions VALUES (2, 101, 1, 1)") # Admin session!
|
|
1863
|
+
conn.commit()
|
|
1864
|
+
conn.close()
|
|
1865
|
+
|
|
1866
|
+
conn = sqlite3.connect(after_db)
|
|
1867
|
+
conn.execute(
|
|
1868
|
+
"CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
|
|
1869
|
+
)
|
|
1870
|
+
conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
|
|
1871
|
+
# Session 2 (admin session) is deleted
|
|
1872
|
+
conn.commit()
|
|
1873
|
+
conn.close()
|
|
1874
|
+
|
|
1875
|
+
before = DatabaseSnapshot(before_db)
|
|
1876
|
+
after = DatabaseSnapshot(after_db)
|
|
1877
|
+
|
|
1878
|
+
# expect_only with whole-row spec passes
|
|
1879
|
+
before.diff(after).expect_exactly(
|
|
1880
|
+
[{"table": "sessions", "pk": 2, "fields": None, "after": "__removed__"}]
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
before.diff(after).expect_exactly(
|
|
1884
|
+
[
|
|
1885
|
+
{
|
|
1886
|
+
"table": "sessions",
|
|
1887
|
+
"pk": 2,
|
|
1888
|
+
"type": "delete",
|
|
1889
|
+
},
|
|
1890
|
+
]
|
|
1891
|
+
)
|
|
1892
|
+
|
|
1893
|
+
finally:
|
|
1894
|
+
os.unlink(before_db)
|
|
1895
|
+
os.unlink(after_db)
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
# ============================================================================
|
|
1899
|
+
# Tests for targeted query optimization edge cases
|
|
1900
|
+
# These tests verify the _expect_only_targeted_v2 optimization works correctly
|
|
1901
|
+
# ============================================================================
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def test_targeted_empty_allowed_changes_no_changes():
|
|
1905
|
+
"""Test that empty allowed_changes with no actual changes passes (uses _expect_no_changes)."""
|
|
1906
|
+
|
|
1907
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1908
|
+
before_db = f.name
|
|
1909
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1910
|
+
after_db = f.name
|
|
1911
|
+
|
|
1912
|
+
try:
|
|
1913
|
+
conn = sqlite3.connect(before_db)
|
|
1914
|
+
conn.execute(
|
|
1915
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1916
|
+
)
|
|
1917
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1918
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
1919
|
+
conn.commit()
|
|
1920
|
+
conn.close()
|
|
1921
|
+
|
|
1922
|
+
# Same data in after database
|
|
1923
|
+
conn = sqlite3.connect(after_db)
|
|
1924
|
+
conn.execute(
|
|
1925
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1926
|
+
)
|
|
1927
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1928
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
|
|
1929
|
+
conn.commit()
|
|
1930
|
+
conn.close()
|
|
1931
|
+
|
|
1932
|
+
before = DatabaseSnapshot(before_db)
|
|
1933
|
+
after = DatabaseSnapshot(after_db)
|
|
1934
|
+
|
|
1935
|
+
# Empty allowed_changes should pass when there are no changes
|
|
1936
|
+
before.diff(after).expect_exactly([])
|
|
1937
|
+
|
|
1938
|
+
finally:
|
|
1939
|
+
os.unlink(before_db)
|
|
1940
|
+
os.unlink(after_db)
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def test_targeted_empty_allowed_changes_with_changes_fails():
|
|
1944
|
+
"""Test that empty allowed_changes with actual changes fails."""
|
|
1945
|
+
|
|
1946
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1947
|
+
before_db = f.name
|
|
1948
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1949
|
+
after_db = f.name
|
|
1950
|
+
|
|
1951
|
+
try:
|
|
1952
|
+
conn = sqlite3.connect(before_db)
|
|
1953
|
+
conn.execute(
|
|
1954
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1955
|
+
)
|
|
1956
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1957
|
+
conn.commit()
|
|
1958
|
+
conn.close()
|
|
1959
|
+
|
|
1960
|
+
conn = sqlite3.connect(after_db)
|
|
1961
|
+
conn.execute(
|
|
1962
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
|
|
1963
|
+
)
|
|
1964
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
|
|
1965
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')") # New row!
|
|
1966
|
+
conn.commit()
|
|
1967
|
+
conn.close()
|
|
1968
|
+
|
|
1969
|
+
before = DatabaseSnapshot(before_db)
|
|
1970
|
+
after = DatabaseSnapshot(after_db)
|
|
1971
|
+
|
|
1972
|
+
# Empty allowed_changes should fail when there are changes
|
|
1973
|
+
# The error message depends on the optimization path taken
|
|
1974
|
+
with pytest.raises(AssertionError):
|
|
1975
|
+
before.diff(after).expect_exactly([])
|
|
1976
|
+
|
|
1977
|
+
finally:
|
|
1978
|
+
os.unlink(before_db)
|
|
1979
|
+
os.unlink(after_db)
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def test_targeted_unmentioned_table_row_added_fails():
|
|
1983
|
+
"""Test that row added in an unmentioned table is detected by targeted optimization."""
|
|
1984
|
+
|
|
1985
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1986
|
+
before_db = f.name
|
|
1987
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
1988
|
+
after_db = f.name
|
|
1989
|
+
|
|
1990
|
+
try:
|
|
1991
|
+
conn = sqlite3.connect(before_db)
|
|
1992
|
+
conn.execute(
|
|
1993
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
1994
|
+
)
|
|
1995
|
+
conn.execute(
|
|
1996
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER)"
|
|
1997
|
+
)
|
|
1998
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
1999
|
+
conn.execute("INSERT INTO orders VALUES (1, 1)")
|
|
2000
|
+
conn.commit()
|
|
2001
|
+
conn.close()
|
|
2002
|
+
|
|
2003
|
+
conn = sqlite3.connect(after_db)
|
|
2004
|
+
conn.execute(
|
|
2005
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2006
|
+
)
|
|
2007
|
+
conn.execute(
|
|
2008
|
+
"CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER)"
|
|
2009
|
+
)
|
|
2010
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2011
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob')") # Allowed change
|
|
2012
|
+
conn.execute("INSERT INTO orders VALUES (1, 1)")
|
|
2013
|
+
conn.execute("INSERT INTO orders VALUES (2, 2)") # Sneaky unmentioned change!
|
|
2014
|
+
conn.commit()
|
|
2015
|
+
conn.close()
|
|
2016
|
+
|
|
2017
|
+
before = DatabaseSnapshot(before_db)
|
|
2018
|
+
after = DatabaseSnapshot(after_db)
|
|
2019
|
+
|
|
2020
|
+
# Only mention users table - orders change should be detected
|
|
2021
|
+
with pytest.raises(AssertionError, match="orders"):
|
|
2022
|
+
before.diff(after).expect_exactly(
|
|
2023
|
+
[
|
|
2024
|
+
{
|
|
2025
|
+
"table": "users",
|
|
2026
|
+
"pk": 2,
|
|
2027
|
+
"type": "insert",
|
|
2028
|
+
"fields": [("id", 2), ("name", "Bob")],
|
|
2029
|
+
},
|
|
2030
|
+
]
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
finally:
|
|
2034
|
+
os.unlink(before_db)
|
|
2035
|
+
os.unlink(after_db)
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def test_targeted_unmentioned_table_row_deleted_fails():
|
|
2039
|
+
"""Test that row deleted in an unmentioned table is detected."""
|
|
2040
|
+
|
|
2041
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2042
|
+
before_db = f.name
|
|
2043
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2044
|
+
after_db = f.name
|
|
2045
|
+
|
|
2046
|
+
try:
|
|
2047
|
+
conn = sqlite3.connect(before_db)
|
|
2048
|
+
conn.execute(
|
|
2049
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2050
|
+
)
|
|
2051
|
+
conn.execute(
|
|
2052
|
+
"CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT)"
|
|
2053
|
+
)
|
|
2054
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2055
|
+
conn.execute("INSERT INTO logs VALUES (1, 'Log entry 1')")
|
|
2056
|
+
conn.execute("INSERT INTO logs VALUES (2, 'Log entry 2')")
|
|
2057
|
+
conn.commit()
|
|
2058
|
+
conn.close()
|
|
2059
|
+
|
|
2060
|
+
conn = sqlite3.connect(after_db)
|
|
2061
|
+
conn.execute(
|
|
2062
|
+
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2063
|
+
)
|
|
2064
|
+
conn.execute(
|
|
2065
|
+
"CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT)"
|
|
2066
|
+
)
|
|
2067
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice Updated')") # Allowed change
|
|
2068
|
+
conn.execute("INSERT INTO logs VALUES (1, 'Log entry 1')")
|
|
2069
|
+
# logs id=2 deleted - not mentioned!
|
|
2070
|
+
conn.commit()
|
|
2071
|
+
conn.close()
|
|
2072
|
+
|
|
2073
|
+
before = DatabaseSnapshot(before_db)
|
|
2074
|
+
after = DatabaseSnapshot(after_db)
|
|
2075
|
+
|
|
2076
|
+
# Only mention users table - logs deletion should be detected
|
|
2077
|
+
with pytest.raises(AssertionError, match="logs"):
|
|
2078
|
+
before.diff(after).expect_exactly(
|
|
2079
|
+
[
|
|
2080
|
+
{
|
|
2081
|
+
"table": "users",
|
|
2082
|
+
"pk": 1,
|
|
2083
|
+
"type": "modify",
|
|
2084
|
+
"resulting_fields": [("name", "Alice Updated")],
|
|
2085
|
+
"no_other_changes": True,
|
|
2086
|
+
},
|
|
2087
|
+
]
|
|
2088
|
+
)
|
|
2089
|
+
|
|
2090
|
+
finally:
|
|
2091
|
+
os.unlink(before_db)
|
|
2092
|
+
os.unlink(after_db)
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
def test_targeted_multiple_changes_same_table():
|
|
2096
|
+
"""Test targeted optimization with multiple changes to the same table."""
|
|
2097
|
+
|
|
2098
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2099
|
+
before_db = f.name
|
|
2100
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2101
|
+
after_db = f.name
|
|
2102
|
+
|
|
2103
|
+
try:
|
|
2104
|
+
conn = sqlite3.connect(before_db)
|
|
2105
|
+
conn.execute(
|
|
2106
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, quantity INTEGER)"
|
|
2107
|
+
)
|
|
2108
|
+
conn.execute("INSERT INTO items VALUES (1, 'Widget', 10)")
|
|
2109
|
+
conn.execute("INSERT INTO items VALUES (2, 'Gadget', 20)")
|
|
2110
|
+
conn.execute("INSERT INTO items VALUES (3, 'Gizmo', 30)")
|
|
2111
|
+
conn.commit()
|
|
2112
|
+
conn.close()
|
|
2113
|
+
|
|
2114
|
+
conn = sqlite3.connect(after_db)
|
|
2115
|
+
conn.execute(
|
|
2116
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, quantity INTEGER)"
|
|
2117
|
+
)
|
|
2118
|
+
conn.execute("INSERT INTO items VALUES (1, 'Widget Updated', 15)") # Modified
|
|
2119
|
+
conn.execute("INSERT INTO items VALUES (2, 'Gadget', 20)") # Unchanged
|
|
2120
|
+
# Item 3 deleted
|
|
2121
|
+
conn.execute("INSERT INTO items VALUES (4, 'New Item', 40)") # Added
|
|
2122
|
+
conn.commit()
|
|
2123
|
+
conn.close()
|
|
2124
|
+
|
|
2125
|
+
before = DatabaseSnapshot(before_db)
|
|
2126
|
+
after = DatabaseSnapshot(after_db)
|
|
2127
|
+
|
|
2128
|
+
# All changes properly specified
|
|
2129
|
+
before.diff(after).expect_exactly(
|
|
2130
|
+
[
|
|
2131
|
+
{
|
|
2132
|
+
"table": "items",
|
|
2133
|
+
"pk": 1,
|
|
2134
|
+
"type": "modify",
|
|
2135
|
+
"resulting_fields": [("name", "Widget Updated"), ("quantity", 15)],
|
|
2136
|
+
"no_other_changes": True,
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
"table": "items",
|
|
2140
|
+
"pk": 3,
|
|
2141
|
+
"type": "delete",
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
"table": "items",
|
|
2145
|
+
"pk": 4,
|
|
2146
|
+
"type": "insert",
|
|
2147
|
+
"fields": [("id", 4), ("name", "New Item"), ("quantity", 40)],
|
|
2148
|
+
},
|
|
2149
|
+
]
|
|
2150
|
+
)
|
|
2151
|
+
|
|
2152
|
+
finally:
|
|
2153
|
+
os.unlink(before_db)
|
|
2154
|
+
os.unlink(after_db)
|
|
2155
|
+
|
|
2156
|
+
|
|
2157
|
+
# Legacy spec tests removed - expect_exactly requires explicit type field
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def test_targeted_with_ignore_config():
|
|
2161
|
+
"""Test that ignore_config works correctly with targeted optimization."""
|
|
2162
|
+
|
|
2163
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2164
|
+
before_db = f.name
|
|
2165
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2166
|
+
after_db = f.name
|
|
2167
|
+
|
|
2168
|
+
try:
|
|
2169
|
+
conn = sqlite3.connect(before_db)
|
|
2170
|
+
conn.execute(
|
|
2171
|
+
"CREATE TABLE audit (id INTEGER PRIMARY KEY, action TEXT, timestamp TEXT)"
|
|
2172
|
+
)
|
|
2173
|
+
conn.execute(
|
|
2174
|
+
"CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT, updated_at TEXT)"
|
|
2175
|
+
)
|
|
2176
|
+
conn.execute("INSERT INTO audit VALUES (1, 'init', '2024-01-01')")
|
|
2177
|
+
conn.execute("INSERT INTO data VALUES (1, 'original', '2024-01-01')")
|
|
2178
|
+
conn.commit()
|
|
2179
|
+
conn.close()
|
|
2180
|
+
|
|
2181
|
+
conn = sqlite3.connect(after_db)
|
|
2182
|
+
conn.execute(
|
|
2183
|
+
"CREATE TABLE audit (id INTEGER PRIMARY KEY, action TEXT, timestamp TEXT)"
|
|
2184
|
+
)
|
|
2185
|
+
conn.execute(
|
|
2186
|
+
"CREATE TABLE data (id INTEGER PRIMARY KEY, value TEXT, updated_at TEXT)"
|
|
2187
|
+
)
|
|
2188
|
+
conn.execute("INSERT INTO audit VALUES (1, 'init', '2024-01-01')")
|
|
2189
|
+
conn.execute("INSERT INTO audit VALUES (2, 'update', '2024-01-02')") # Ignored table
|
|
2190
|
+
conn.execute("INSERT INTO data VALUES (1, 'updated', '2024-01-02')") # Changed
|
|
2191
|
+
conn.commit()
|
|
2192
|
+
conn.close()
|
|
2193
|
+
|
|
2194
|
+
before = DatabaseSnapshot(before_db)
|
|
2195
|
+
after = DatabaseSnapshot(after_db)
|
|
2196
|
+
|
|
2197
|
+
# Ignore the audit table entirely, and updated_at field in data
|
|
2198
|
+
ignore_config = IgnoreConfig(
|
|
2199
|
+
tables={"audit"},
|
|
2200
|
+
table_fields={"data": {"updated_at"}},
|
|
2201
|
+
)
|
|
2202
|
+
|
|
2203
|
+
# Only need to specify the value change, audit table is ignored
|
|
2204
|
+
before.diff(after, ignore_config).expect_exactly(
|
|
2205
|
+
[
|
|
2206
|
+
{
|
|
2207
|
+
"table": "data",
|
|
2208
|
+
"pk": 1,
|
|
2209
|
+
"type": "modify",
|
|
2210
|
+
"resulting_fields": [("value", "updated")],
|
|
2211
|
+
"no_other_changes": True,
|
|
2212
|
+
},
|
|
2213
|
+
]
|
|
2214
|
+
)
|
|
2215
|
+
|
|
2216
|
+
finally:
|
|
2217
|
+
os.unlink(before_db)
|
|
2218
|
+
os.unlink(after_db)
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
def test_targeted_string_vs_int_pk_coercion():
|
|
2222
|
+
"""Test that PK comparison works with string vs int coercion."""
|
|
2223
|
+
|
|
2224
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2225
|
+
before_db = f.name
|
|
2226
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2227
|
+
after_db = f.name
|
|
2228
|
+
|
|
2229
|
+
try:
|
|
2230
|
+
conn = sqlite3.connect(before_db)
|
|
2231
|
+
conn.execute(
|
|
2232
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2233
|
+
)
|
|
2234
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2235
|
+
conn.commit()
|
|
2236
|
+
conn.close()
|
|
2237
|
+
|
|
2238
|
+
conn = sqlite3.connect(after_db)
|
|
2239
|
+
conn.execute(
|
|
2240
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2241
|
+
)
|
|
2242
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2243
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2')")
|
|
2244
|
+
conn.execute("INSERT INTO items VALUES (3, 'Item 3')")
|
|
2245
|
+
conn.commit()
|
|
2246
|
+
conn.close()
|
|
2247
|
+
|
|
2248
|
+
before = DatabaseSnapshot(before_db)
|
|
2249
|
+
after = DatabaseSnapshot(after_db)
|
|
2250
|
+
|
|
2251
|
+
# Use string PKs - should still work due to coercion
|
|
2252
|
+
before.diff(after).expect_exactly(
|
|
2253
|
+
[
|
|
2254
|
+
{
|
|
2255
|
+
"table": "items",
|
|
2256
|
+
"pk": "2", # String PK
|
|
2257
|
+
"type": "insert",
|
|
2258
|
+
"fields": [("id", 2), ("name", "Item 2")],
|
|
2259
|
+
},
|
|
2260
|
+
{
|
|
2261
|
+
"table": "items",
|
|
2262
|
+
"pk": 3, # Integer PK
|
|
2263
|
+
"type": "insert",
|
|
2264
|
+
"fields": [("id", 3), ("name", "Item 3")],
|
|
2265
|
+
},
|
|
2266
|
+
]
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
finally:
|
|
2270
|
+
os.unlink(before_db)
|
|
2271
|
+
os.unlink(after_db)
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def test_targeted_modify_without_resulting_fields():
|
|
2275
|
+
"""Test that type: 'modify' without resulting_fields allows any modification."""
|
|
2276
|
+
|
|
2277
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2278
|
+
before_db = f.name
|
|
2279
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2280
|
+
after_db = f.name
|
|
2281
|
+
|
|
2282
|
+
try:
|
|
2283
|
+
conn = sqlite3.connect(before_db)
|
|
2284
|
+
conn.execute(
|
|
2285
|
+
"CREATE TABLE records (id INTEGER PRIMARY KEY, field_a TEXT, field_b TEXT, field_c TEXT)"
|
|
2286
|
+
)
|
|
2287
|
+
conn.execute("INSERT INTO records VALUES (1, 'a1', 'b1', 'c1')")
|
|
2288
|
+
conn.commit()
|
|
2289
|
+
conn.close()
|
|
2290
|
+
|
|
2291
|
+
conn = sqlite3.connect(after_db)
|
|
2292
|
+
conn.execute(
|
|
2293
|
+
"CREATE TABLE records (id INTEGER PRIMARY KEY, field_a TEXT, field_b TEXT, field_c TEXT)"
|
|
2294
|
+
)
|
|
2295
|
+
conn.execute("INSERT INTO records VALUES (1, 'a2', 'b2', 'c2')") # All fields changed
|
|
2296
|
+
conn.commit()
|
|
2297
|
+
conn.close()
|
|
2298
|
+
|
|
2299
|
+
before = DatabaseSnapshot(before_db)
|
|
2300
|
+
after = DatabaseSnapshot(after_db)
|
|
2301
|
+
|
|
2302
|
+
# type: "modify" without resulting_fields allows any modification
|
|
2303
|
+
before.diff(after).expect_exactly(
|
|
2304
|
+
[
|
|
2305
|
+
{
|
|
2306
|
+
"table": "records",
|
|
2307
|
+
"pk": 1,
|
|
2308
|
+
"type": "modify",
|
|
2309
|
+
# No resulting_fields - allows any changes
|
|
2310
|
+
},
|
|
2311
|
+
]
|
|
2312
|
+
)
|
|
2313
|
+
|
|
2314
|
+
finally:
|
|
2315
|
+
os.unlink(before_db)
|
|
2316
|
+
os.unlink(after_db)
|
|
2317
|
+
|
|
2318
|
+
|
|
2319
|
+
def test_targeted_insert_without_fields():
|
|
2320
|
+
"""Test that type: 'insert' without fields allows insertion with any values."""
|
|
2321
|
+
|
|
2322
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2323
|
+
before_db = f.name
|
|
2324
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2325
|
+
after_db = f.name
|
|
2326
|
+
|
|
2327
|
+
try:
|
|
2328
|
+
conn = sqlite3.connect(before_db)
|
|
2329
|
+
conn.execute(
|
|
2330
|
+
"CREATE TABLE entities (id INTEGER PRIMARY KEY, data TEXT, secret TEXT)"
|
|
2331
|
+
)
|
|
2332
|
+
conn.commit()
|
|
2333
|
+
conn.close()
|
|
2334
|
+
|
|
2335
|
+
conn = sqlite3.connect(after_db)
|
|
2336
|
+
conn.execute(
|
|
2337
|
+
"CREATE TABLE entities (id INTEGER PRIMARY KEY, data TEXT, secret TEXT)"
|
|
2338
|
+
)
|
|
2339
|
+
conn.execute("INSERT INTO entities VALUES (1, 'any data', 'any secret')")
|
|
2340
|
+
conn.commit()
|
|
2341
|
+
conn.close()
|
|
2342
|
+
|
|
2343
|
+
before = DatabaseSnapshot(before_db)
|
|
2344
|
+
after = DatabaseSnapshot(after_db)
|
|
2345
|
+
|
|
2346
|
+
# type: "insert" without fields allows insertion with any values
|
|
2347
|
+
before.diff(after).expect_exactly(
|
|
2348
|
+
[
|
|
2349
|
+
{
|
|
2350
|
+
"table": "entities",
|
|
2351
|
+
"pk": 1,
|
|
2352
|
+
"type": "insert",
|
|
2353
|
+
# No fields - allows any values
|
|
2354
|
+
},
|
|
2355
|
+
]
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
finally:
|
|
2359
|
+
os.unlink(before_db)
|
|
2360
|
+
os.unlink(after_db)
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def test_targeted_multiple_tables_all_specs():
|
|
2364
|
+
"""Test targeted optimization with multiple tables and all spec types."""
|
|
2365
|
+
|
|
2366
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2367
|
+
before_db = f.name
|
|
2368
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2369
|
+
after_db = f.name
|
|
2370
|
+
|
|
2371
|
+
try:
|
|
2372
|
+
conn = sqlite3.connect(before_db)
|
|
2373
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
2374
|
+
conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
|
|
2375
|
+
conn.execute("CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT)")
|
|
2376
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2377
|
+
conn.execute("INSERT INTO posts VALUES (1, 'Post 1')")
|
|
2378
|
+
conn.execute("INSERT INTO posts VALUES (2, 'Post 2')")
|
|
2379
|
+
conn.execute("INSERT INTO comments VALUES (1, 'Comment 1')")
|
|
2380
|
+
conn.commit()
|
|
2381
|
+
conn.close()
|
|
2382
|
+
|
|
2383
|
+
conn = sqlite3.connect(after_db)
|
|
2384
|
+
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
2385
|
+
conn.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
|
|
2386
|
+
conn.execute("CREATE TABLE comments (id INTEGER PRIMARY KEY, body TEXT)")
|
|
2387
|
+
conn.execute("INSERT INTO users VALUES (1, 'Alice')")
|
|
2388
|
+
conn.execute("INSERT INTO users VALUES (2, 'Bob')") # Added
|
|
2389
|
+
conn.execute("INSERT INTO posts VALUES (1, 'Post 1 Updated')") # Modified
|
|
2390
|
+
# Post 2 deleted
|
|
2391
|
+
conn.execute("INSERT INTO comments VALUES (1, 'Comment 1')") # Unchanged
|
|
2392
|
+
conn.commit()
|
|
2393
|
+
conn.close()
|
|
2394
|
+
|
|
2395
|
+
before = DatabaseSnapshot(before_db)
|
|
2396
|
+
after = DatabaseSnapshot(after_db)
|
|
2397
|
+
|
|
2398
|
+
# Multiple tables with different change types
|
|
2399
|
+
before.diff(after).expect_exactly(
|
|
2400
|
+
[
|
|
2401
|
+
{
|
|
2402
|
+
"table": "users",
|
|
2403
|
+
"pk": 2,
|
|
2404
|
+
"type": "insert",
|
|
2405
|
+
"fields": [("id", 2), ("name", "Bob")],
|
|
2406
|
+
},
|
|
2407
|
+
{
|
|
2408
|
+
"table": "posts",
|
|
2409
|
+
"pk": 1,
|
|
2410
|
+
"type": "modify",
|
|
2411
|
+
"resulting_fields": [("title", "Post 1 Updated")],
|
|
2412
|
+
"no_other_changes": True,
|
|
2413
|
+
},
|
|
2414
|
+
{
|
|
2415
|
+
"table": "posts",
|
|
2416
|
+
"pk": 2,
|
|
2417
|
+
"type": "delete",
|
|
2418
|
+
},
|
|
2419
|
+
]
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
finally:
|
|
2423
|
+
os.unlink(before_db)
|
|
2424
|
+
os.unlink(after_db)
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def test_targeted_row_exists_both_sides_no_change():
|
|
2428
|
+
"""Test that specifying a row that exists but didn't change works correctly."""
|
|
2429
|
+
|
|
2430
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2431
|
+
before_db = f.name
|
|
2432
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
2433
|
+
after_db = f.name
|
|
2434
|
+
|
|
2435
|
+
try:
|
|
2436
|
+
conn = sqlite3.connect(before_db)
|
|
2437
|
+
conn.execute(
|
|
2438
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2439
|
+
)
|
|
2440
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')")
|
|
2441
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2')")
|
|
2442
|
+
conn.commit()
|
|
2443
|
+
conn.close()
|
|
2444
|
+
|
|
2445
|
+
conn = sqlite3.connect(after_db)
|
|
2446
|
+
conn.execute(
|
|
2447
|
+
"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)"
|
|
2448
|
+
)
|
|
2449
|
+
conn.execute("INSERT INTO items VALUES (1, 'Item 1')") # Unchanged
|
|
2450
|
+
conn.execute("INSERT INTO items VALUES (2, 'Item 2 Updated')") # Changed
|
|
2451
|
+
conn.commit()
|
|
2452
|
+
conn.close()
|
|
2453
|
+
|
|
2454
|
+
before = DatabaseSnapshot(before_db)
|
|
2455
|
+
after = DatabaseSnapshot(after_db)
|
|
2456
|
+
|
|
2457
|
+
# Specify the change correctly
|
|
2458
|
+
before.diff(after).expect_exactly(
|
|
2459
|
+
[
|
|
2460
|
+
{
|
|
2461
|
+
"table": "items",
|
|
2462
|
+
"pk": 2,
|
|
2463
|
+
"type": "modify",
|
|
2464
|
+
"resulting_fields": [("name", "Item 2 Updated")],
|
|
2465
|
+
"no_other_changes": True,
|
|
2466
|
+
},
|
|
2467
|
+
]
|
|
2468
|
+
)
|
|
2469
|
+
|
|
2470
|
+
finally:
|
|
2471
|
+
os.unlink(before_db)
|
|
2472
|
+
os.unlink(after_db)
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
# ============================================================================
|
|
2476
|
+
# Mock-based tests for error messaging (from test_error_messaging_v2.py)
|
|
2477
|
+
# These tests use MockSnapshotDiff to test validation/error formatting logic
|
|
2478
|
+
# without needing actual database files.
|
|
2479
|
+
# ============================================================================
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
class TestErrorMessaging:
|
|
2483
|
+
"""Test cases for error message generation."""
|
|
2484
|
+
|
|
2485
|
+
def test_unexpected_insertion_no_spec(self):
|
|
2486
|
+
"""Test error message when a row is inserted but no spec allows it."""
|
|
2487
|
+
diff = {
|
|
2488
|
+
"issues": {
|
|
2489
|
+
"table_name": "issues",
|
|
2490
|
+
"primary_key": ["id"],
|
|
2491
|
+
"added_rows": [
|
|
2492
|
+
{
|
|
2493
|
+
"row_id": 123,
|
|
2494
|
+
"data": {
|
|
2495
|
+
"id": 123,
|
|
2496
|
+
"title": "Bug report",
|
|
2497
|
+
"status": "open",
|
|
2498
|
+
"priority": "high",
|
|
2499
|
+
},
|
|
2500
|
+
}
|
|
2501
|
+
],
|
|
2502
|
+
"removed_rows": [],
|
|
2503
|
+
"modified_rows": [],
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
allowed_changes = [] # No changes allowed
|
|
2507
|
+
|
|
2508
|
+
mock = MockSnapshotDiff(diff)
|
|
2509
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2510
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2511
|
+
|
|
2512
|
+
error_msg = str(exc_info.value)
|
|
2513
|
+
print("\n" + "=" * 80)
|
|
2514
|
+
print("TEST: Unexpected insertion, no spec")
|
|
2515
|
+
print("=" * 80)
|
|
2516
|
+
print(error_msg)
|
|
2517
|
+
print("=" * 80)
|
|
2518
|
+
|
|
2519
|
+
assert "INSERTION" in error_msg
|
|
2520
|
+
assert "issues" in error_msg
|
|
2521
|
+
assert "123" in error_msg
|
|
2522
|
+
assert "No changes were allowed" in error_msg
|
|
2523
|
+
|
|
2524
|
+
def test_insertion_with_field_value_mismatch(self):
|
|
2525
|
+
"""Test error when insertion spec has wrong field value."""
|
|
2526
|
+
diff = {
|
|
2527
|
+
"issues": {
|
|
2528
|
+
"table_name": "issues",
|
|
2529
|
+
"primary_key": ["id"],
|
|
2530
|
+
"added_rows": [
|
|
2531
|
+
{
|
|
2532
|
+
"row_id": 123,
|
|
2533
|
+
"data": {
|
|
2534
|
+
"id": 123,
|
|
2535
|
+
"title": "Bug report",
|
|
2536
|
+
"status": "open", # Actual value
|
|
2537
|
+
"priority": "high",
|
|
2538
|
+
},
|
|
2539
|
+
}
|
|
2540
|
+
],
|
|
2541
|
+
"removed_rows": [],
|
|
2542
|
+
"modified_rows": [],
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
allowed_changes = [
|
|
2546
|
+
{
|
|
2547
|
+
"table": "issues",
|
|
2548
|
+
"pk": 123,
|
|
2549
|
+
"type": "insert",
|
|
2550
|
+
"fields": [
|
|
2551
|
+
("id", 123),
|
|
2552
|
+
("title", "Bug report"),
|
|
2553
|
+
("status", "closed"), # Expected 'closed' but got 'open'
|
|
2554
|
+
("priority", "high"),
|
|
2555
|
+
],
|
|
2556
|
+
}
|
|
2557
|
+
]
|
|
2558
|
+
|
|
2559
|
+
mock = MockSnapshotDiff(diff)
|
|
2560
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2561
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2562
|
+
|
|
2563
|
+
error_msg = str(exc_info.value)
|
|
2564
|
+
print("\n" + "=" * 80)
|
|
2565
|
+
print("TEST: Insertion with field value mismatch")
|
|
2566
|
+
print("=" * 80)
|
|
2567
|
+
print(error_msg)
|
|
2568
|
+
print("=" * 80)
|
|
2569
|
+
|
|
2570
|
+
assert "INSERTION" in error_msg
|
|
2571
|
+
assert "status" in error_msg
|
|
2572
|
+
assert "open" in error_msg
|
|
2573
|
+
assert "closed" in error_msg or "expected" in error_msg
|
|
2574
|
+
|
|
2575
|
+
def test_insertion_with_missing_field_in_spec(self):
|
|
2576
|
+
"""Test error when insertion has field not in spec."""
|
|
2577
|
+
diff = {
|
|
2578
|
+
"issues": {
|
|
2579
|
+
"table_name": "issues",
|
|
2580
|
+
"primary_key": ["id"],
|
|
2581
|
+
"added_rows": [
|
|
2582
|
+
{
|
|
2583
|
+
"row_id": 123,
|
|
2584
|
+
"data": {
|
|
2585
|
+
"id": 123,
|
|
2586
|
+
"title": "Bug report",
|
|
2587
|
+
"status": "open",
|
|
2588
|
+
"priority": "high", # This field is not in spec
|
|
2589
|
+
"created_at": "2024-01-15", # This field is not in spec
|
|
2590
|
+
},
|
|
2591
|
+
}
|
|
2592
|
+
],
|
|
2593
|
+
"removed_rows": [],
|
|
2594
|
+
"modified_rows": [],
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
allowed_changes = [
|
|
2598
|
+
{
|
|
2599
|
+
"table": "issues",
|
|
2600
|
+
"pk": 123,
|
|
2601
|
+
"type": "insert",
|
|
2602
|
+
"fields": [
|
|
2603
|
+
("id", 123),
|
|
2604
|
+
("title", "Bug report"),
|
|
2605
|
+
("status", "open"),
|
|
2606
|
+
# Missing: priority, created_at
|
|
2607
|
+
],
|
|
2608
|
+
}
|
|
2609
|
+
]
|
|
2610
|
+
|
|
2611
|
+
mock = MockSnapshotDiff(diff)
|
|
2612
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2613
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2614
|
+
|
|
2615
|
+
error_msg = str(exc_info.value)
|
|
2616
|
+
print("\n" + "=" * 80)
|
|
2617
|
+
print("TEST: Insertion with missing field in spec")
|
|
2618
|
+
print("=" * 80)
|
|
2619
|
+
print(error_msg)
|
|
2620
|
+
print("=" * 80)
|
|
2621
|
+
|
|
2622
|
+
assert "INSERTION" in error_msg
|
|
2623
|
+
assert "priority" in error_msg
|
|
2624
|
+
assert "NOT_IN_FIELDS_SPEC" in error_msg
|
|
2625
|
+
|
|
2626
|
+
def test_unexpected_modification_no_spec(self):
|
|
2627
|
+
"""Test error when a row is modified but no spec allows it."""
|
|
2628
|
+
diff = {
|
|
2629
|
+
"users": {
|
|
2630
|
+
"table_name": "users",
|
|
2631
|
+
"primary_key": ["id"],
|
|
2632
|
+
"added_rows": [],
|
|
2633
|
+
"removed_rows": [],
|
|
2634
|
+
"modified_rows": [
|
|
2635
|
+
{
|
|
2636
|
+
"row_id": 456,
|
|
2637
|
+
"changes": {
|
|
2638
|
+
"last_login": {
|
|
2639
|
+
"before": "2024-01-01",
|
|
2640
|
+
"after": "2024-01-15",
|
|
2641
|
+
}
|
|
2642
|
+
},
|
|
2643
|
+
"data": {
|
|
2644
|
+
"id": 456,
|
|
2645
|
+
"name": "Alice",
|
|
2646
|
+
"last_login": "2024-01-15",
|
|
2647
|
+
},
|
|
2648
|
+
}
|
|
2649
|
+
],
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
allowed_changes = []
|
|
2653
|
+
|
|
2654
|
+
mock = MockSnapshotDiff(diff)
|
|
2655
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2656
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2657
|
+
|
|
2658
|
+
error_msg = str(exc_info.value)
|
|
2659
|
+
print("\n" + "=" * 80)
|
|
2660
|
+
print("TEST: Unexpected modification, no spec")
|
|
2661
|
+
print("=" * 80)
|
|
2662
|
+
print(error_msg)
|
|
2663
|
+
print("=" * 80)
|
|
2664
|
+
|
|
2665
|
+
assert "MODIFICATION" in error_msg
|
|
2666
|
+
assert "users" in error_msg
|
|
2667
|
+
assert "last_login" in error_msg
|
|
2668
|
+
assert "2024-01-01" in error_msg
|
|
2669
|
+
assert "2024-01-15" in error_msg
|
|
2670
|
+
|
|
2671
|
+
def test_modification_with_wrong_resulting_field_value(self):
|
|
2672
|
+
"""Test error when modification spec has wrong resulting field value."""
|
|
2673
|
+
diff = {
|
|
2674
|
+
"users": {
|
|
2675
|
+
"table_name": "users",
|
|
2676
|
+
"primary_key": ["id"],
|
|
2677
|
+
"added_rows": [],
|
|
2678
|
+
"removed_rows": [],
|
|
2679
|
+
"modified_rows": [
|
|
2680
|
+
{
|
|
2681
|
+
"row_id": 456,
|
|
2682
|
+
"changes": {
|
|
2683
|
+
"status": {
|
|
2684
|
+
"before": "active",
|
|
2685
|
+
"after": "inactive", # Actual
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
"data": {"id": 456, "name": "Alice", "status": "inactive"},
|
|
2689
|
+
}
|
|
2690
|
+
],
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
allowed_changes = [
|
|
2694
|
+
{
|
|
2695
|
+
"table": "users",
|
|
2696
|
+
"pk": 456,
|
|
2697
|
+
"type": "modify",
|
|
2698
|
+
"resulting_fields": [("status", "suspended")], # Expected 'suspended'
|
|
2699
|
+
"no_other_changes": True,
|
|
2700
|
+
}
|
|
2701
|
+
]
|
|
2702
|
+
|
|
2703
|
+
mock = MockSnapshotDiff(diff)
|
|
2704
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2705
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2706
|
+
|
|
2707
|
+
error_msg = str(exc_info.value)
|
|
2708
|
+
print("\n" + "=" * 80)
|
|
2709
|
+
print("TEST: Modification with wrong resulting field value")
|
|
2710
|
+
print("=" * 80)
|
|
2711
|
+
print(error_msg)
|
|
2712
|
+
print("=" * 80)
|
|
2713
|
+
|
|
2714
|
+
assert "MODIFICATION" in error_msg
|
|
2715
|
+
assert "status" in error_msg
|
|
2716
|
+
assert "suspended" in error_msg or "expected" in error_msg
|
|
2717
|
+
|
|
2718
|
+
def test_modification_with_extra_changes_strict_mode(self):
|
|
2719
|
+
"""Test error when modification has extra changes and no_other_changes=True."""
|
|
2720
|
+
diff = {
|
|
2721
|
+
"users": {
|
|
2722
|
+
"table_name": "users",
|
|
2723
|
+
"primary_key": ["id"],
|
|
2724
|
+
"added_rows": [],
|
|
2725
|
+
"removed_rows": [],
|
|
2726
|
+
"modified_rows": [
|
|
2727
|
+
{
|
|
2728
|
+
"row_id": 456,
|
|
2729
|
+
"changes": {
|
|
2730
|
+
"status": {"before": "active", "after": "inactive"},
|
|
2731
|
+
"updated_at": {"before": "2024-01-01", "after": "2024-01-15"}, # Extra change!
|
|
2732
|
+
},
|
|
2733
|
+
"data": {"id": 456, "status": "inactive", "updated_at": "2024-01-15"},
|
|
2734
|
+
}
|
|
2735
|
+
],
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
allowed_changes = [
|
|
2739
|
+
{
|
|
2740
|
+
"table": "users",
|
|
2741
|
+
"pk": 456,
|
|
2742
|
+
"type": "modify",
|
|
2743
|
+
"resulting_fields": [("status", "inactive")], # Only status
|
|
2744
|
+
"no_other_changes": True, # Strict mode - no other changes allowed
|
|
2745
|
+
}
|
|
2746
|
+
]
|
|
2747
|
+
|
|
2748
|
+
mock = MockSnapshotDiff(diff)
|
|
2749
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2750
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2751
|
+
|
|
2752
|
+
error_msg = str(exc_info.value)
|
|
2753
|
+
print("\n" + "=" * 80)
|
|
2754
|
+
print("TEST: Modification with extra changes (strict mode)")
|
|
2755
|
+
print("=" * 80)
|
|
2756
|
+
print(error_msg)
|
|
2757
|
+
print("=" * 80)
|
|
2758
|
+
|
|
2759
|
+
assert "MODIFICATION" in error_msg
|
|
2760
|
+
assert "updated_at" in error_msg
|
|
2761
|
+
assert "NOT_IN_RESULTING_FIELDS" in error_msg
|
|
2762
|
+
|
|
2763
|
+
def test_unexpected_deletion_no_spec(self):
|
|
2764
|
+
"""Test error when a row is deleted but no spec allows it."""
|
|
2765
|
+
diff = {
|
|
2766
|
+
"logs": {
|
|
2767
|
+
"table_name": "logs",
|
|
2768
|
+
"primary_key": ["id"],
|
|
2769
|
+
"added_rows": [],
|
|
2770
|
+
"removed_rows": [
|
|
2771
|
+
{
|
|
2772
|
+
"row_id": 789,
|
|
2773
|
+
"data": {
|
|
2774
|
+
"id": 789,
|
|
2775
|
+
"message": "Old log entry",
|
|
2776
|
+
"level": "info",
|
|
2777
|
+
},
|
|
2778
|
+
}
|
|
2779
|
+
],
|
|
2780
|
+
"modified_rows": [],
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
allowed_changes = []
|
|
2784
|
+
|
|
2785
|
+
mock = MockSnapshotDiff(diff)
|
|
2786
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2787
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2788
|
+
|
|
2789
|
+
error_msg = str(exc_info.value)
|
|
2790
|
+
print("\n" + "=" * 80)
|
|
2791
|
+
print("TEST: Unexpected deletion, no spec")
|
|
2792
|
+
print("=" * 80)
|
|
2793
|
+
print(error_msg)
|
|
2794
|
+
print("=" * 80)
|
|
2795
|
+
|
|
2796
|
+
assert "DELETION" in error_msg
|
|
2797
|
+
assert "logs" in error_msg
|
|
2798
|
+
assert "789" in error_msg
|
|
2799
|
+
|
|
2800
|
+
def test_multiple_unexpected_changes(self):
|
|
2801
|
+
"""Test error message with multiple unexpected changes."""
|
|
2802
|
+
diff = {
|
|
2803
|
+
"issues": {
|
|
2804
|
+
"table_name": "issues",
|
|
2805
|
+
"primary_key": ["id"],
|
|
2806
|
+
"added_rows": [
|
|
2807
|
+
{"row_id": 1, "data": {"id": 1, "title": "Issue 1"}},
|
|
2808
|
+
{"row_id": 2, "data": {"id": 2, "title": "Issue 2"}},
|
|
2809
|
+
],
|
|
2810
|
+
"removed_rows": [
|
|
2811
|
+
{"row_id": 3, "data": {"id": 3, "title": "Issue 3"}},
|
|
2812
|
+
],
|
|
2813
|
+
"modified_rows": [
|
|
2814
|
+
{
|
|
2815
|
+
"row_id": 4,
|
|
2816
|
+
"changes": {"status": {"before": "open", "after": "closed"}},
|
|
2817
|
+
"data": {"id": 4, "title": "Issue 4", "status": "closed"},
|
|
2818
|
+
},
|
|
2819
|
+
],
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
allowed_changes = []
|
|
2823
|
+
|
|
2824
|
+
mock = MockSnapshotDiff(diff)
|
|
2825
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2826
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2827
|
+
|
|
2828
|
+
error_msg = str(exc_info.value)
|
|
2829
|
+
print("\n" + "=" * 80)
|
|
2830
|
+
print("TEST: Multiple unexpected changes")
|
|
2831
|
+
print("=" * 80)
|
|
2832
|
+
print(error_msg)
|
|
2833
|
+
print("=" * 80)
|
|
2834
|
+
|
|
2835
|
+
# Should show multiple changes
|
|
2836
|
+
assert "1." in error_msg
|
|
2837
|
+
assert "2." in error_msg
|
|
2838
|
+
|
|
2839
|
+
def test_many_changes_truncation(self):
|
|
2840
|
+
"""Test that error message truncates when there are many changes."""
|
|
2841
|
+
# Create 10 unexpected insertions
|
|
2842
|
+
added_rows = [
|
|
2843
|
+
{"row_id": i, "data": {"id": i, "title": f"Issue {i}"}}
|
|
2844
|
+
for i in range(10)
|
|
2845
|
+
]
|
|
2846
|
+
diff = {
|
|
2847
|
+
"issues": {
|
|
2848
|
+
"table_name": "issues",
|
|
2849
|
+
"primary_key": ["id"],
|
|
2850
|
+
"added_rows": added_rows,
|
|
2851
|
+
"removed_rows": [],
|
|
2852
|
+
"modified_rows": [],
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
allowed_changes = []
|
|
2856
|
+
|
|
2857
|
+
mock = MockSnapshotDiff(diff)
|
|
2858
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2859
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2860
|
+
|
|
2861
|
+
error_msg = str(exc_info.value)
|
|
2862
|
+
print("\n" + "=" * 80)
|
|
2863
|
+
print("TEST: Many changes truncation")
|
|
2864
|
+
print("=" * 80)
|
|
2865
|
+
print(error_msg)
|
|
2866
|
+
print("=" * 80)
|
|
2867
|
+
|
|
2868
|
+
# Should show truncation message
|
|
2869
|
+
assert "... and" in error_msg
|
|
2870
|
+
assert "more unexpected changes" in error_msg
|
|
2871
|
+
|
|
2872
|
+
def test_allowed_changes_display(self):
|
|
2873
|
+
"""Test that allowed changes are displayed correctly in error."""
|
|
2874
|
+
diff = {
|
|
2875
|
+
"issues": {
|
|
2876
|
+
"table_name": "issues",
|
|
2877
|
+
"primary_key": ["id"],
|
|
2878
|
+
"added_rows": [
|
|
2879
|
+
{"row_id": 999, "data": {"id": 999, "title": "Unexpected"}}
|
|
2880
|
+
],
|
|
2881
|
+
"removed_rows": [],
|
|
2882
|
+
"modified_rows": [],
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
allowed_changes = [
|
|
2886
|
+
{
|
|
2887
|
+
"table": "issues",
|
|
2888
|
+
"pk": 123,
|
|
2889
|
+
"type": "insert",
|
|
2890
|
+
"fields": [("id", 123), ("title", "Expected"), ("status", "open")],
|
|
2891
|
+
},
|
|
2892
|
+
{
|
|
2893
|
+
"table": "users",
|
|
2894
|
+
"pk": 456,
|
|
2895
|
+
"type": "modify",
|
|
2896
|
+
"resulting_fields": [("status", "active")],
|
|
2897
|
+
"no_other_changes": True,
|
|
2898
|
+
},
|
|
2899
|
+
]
|
|
2900
|
+
|
|
2901
|
+
mock = MockSnapshotDiff(diff)
|
|
2902
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
2903
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2904
|
+
|
|
2905
|
+
error_msg = str(exc_info.value)
|
|
2906
|
+
print("\n" + "=" * 80)
|
|
2907
|
+
print("TEST: Allowed changes display")
|
|
2908
|
+
print("=" * 80)
|
|
2909
|
+
print(error_msg)
|
|
2910
|
+
print("=" * 80)
|
|
2911
|
+
|
|
2912
|
+
assert "Allowed changes were:" in error_msg
|
|
2913
|
+
assert "issues" in error_msg
|
|
2914
|
+
assert "123" in error_msg
|
|
2915
|
+
|
|
2916
|
+
def test_successful_validation_no_error(self):
|
|
2917
|
+
"""Test that validation passes when changes match spec."""
|
|
2918
|
+
diff = {
|
|
2919
|
+
"issues": {
|
|
2920
|
+
"table_name": "issues",
|
|
2921
|
+
"primary_key": ["id"],
|
|
2922
|
+
"added_rows": [
|
|
2923
|
+
{
|
|
2924
|
+
"row_id": 123,
|
|
2925
|
+
"data": {"id": 123, "title": "Bug", "status": "open"},
|
|
2926
|
+
}
|
|
2927
|
+
],
|
|
2928
|
+
"removed_rows": [],
|
|
2929
|
+
"modified_rows": [],
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
allowed_changes = [
|
|
2933
|
+
{
|
|
2934
|
+
"table": "issues",
|
|
2935
|
+
"pk": 123,
|
|
2936
|
+
"type": "insert",
|
|
2937
|
+
"fields": [("id", 123), ("title", "Bug"), ("status", "open")],
|
|
2938
|
+
}
|
|
2939
|
+
]
|
|
2940
|
+
|
|
2941
|
+
mock = MockSnapshotDiff(diff)
|
|
2942
|
+
# Should not raise
|
|
2943
|
+
result = mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2944
|
+
assert result is mock
|
|
2945
|
+
|
|
2946
|
+
print("\n" + "=" * 80)
|
|
2947
|
+
print("TEST: Successful validation (no error)")
|
|
2948
|
+
print("=" * 80)
|
|
2949
|
+
print("Validation passed - no AssertionError raised")
|
|
2950
|
+
print("=" * 80)
|
|
2951
|
+
|
|
2952
|
+
def test_ellipsis_wildcard_in_spec(self):
|
|
2953
|
+
"""Test that ... (ellipsis) wildcard accepts any value."""
|
|
2954
|
+
diff = {
|
|
2955
|
+
"issues": {
|
|
2956
|
+
"table_name": "issues",
|
|
2957
|
+
"primary_key": ["id"],
|
|
2958
|
+
"added_rows": [
|
|
2959
|
+
{
|
|
2960
|
+
"row_id": 123,
|
|
2961
|
+
"data": {
|
|
2962
|
+
"id": 123,
|
|
2963
|
+
"title": "Any title here",
|
|
2964
|
+
"created_at": "2024-01-15T10:30:00Z",
|
|
2965
|
+
},
|
|
2966
|
+
}
|
|
2967
|
+
],
|
|
2968
|
+
"removed_rows": [],
|
|
2969
|
+
"modified_rows": [],
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
allowed_changes = [
|
|
2973
|
+
{
|
|
2974
|
+
"table": "issues",
|
|
2975
|
+
"pk": 123,
|
|
2976
|
+
"type": "insert",
|
|
2977
|
+
"fields": [
|
|
2978
|
+
("id", 123),
|
|
2979
|
+
("title", ...), # Accept any value
|
|
2980
|
+
("created_at", ...), # Accept any value
|
|
2981
|
+
],
|
|
2982
|
+
}
|
|
2983
|
+
]
|
|
2984
|
+
|
|
2985
|
+
mock = MockSnapshotDiff(diff)
|
|
2986
|
+
# Should not raise - ellipsis accepts any value
|
|
2987
|
+
result = mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
2988
|
+
assert result is mock
|
|
2989
|
+
|
|
2990
|
+
print("\n" + "=" * 80)
|
|
2991
|
+
print("TEST: Ellipsis wildcard in spec")
|
|
2992
|
+
print("=" * 80)
|
|
2993
|
+
print("Validation passed with ... wildcards")
|
|
2994
|
+
print("=" * 80)
|
|
2995
|
+
|
|
2996
|
+
|
|
2997
|
+
class TestComprehensiveErrorScenarios:
|
|
2998
|
+
"""
|
|
2999
|
+
Comprehensive test covering all error scenarios:
|
|
3000
|
+
|
|
3001
|
+
| Type | (a) Correct | (b) Wrong Fields (multiple) | (c) Missing | (d) Unexpected |
|
|
3002
|
+
|--------------|--------------------|-----------------------------|---------------------|---------------------|
|
|
3003
|
+
| INSERTION | Row 100 matches | Row 101 has 3 wrong fields | Row 102 not added | Row 999 unexpected |
|
|
3004
|
+
| MODIFICATION | Row 300 matches | Row 301 has 2 wrong fields | Row 302 not modified| - |
|
|
3005
|
+
| DELETION | Row 200 matches | - | Row 202 not deleted | Row 201 unexpected |
|
|
3006
|
+
"""
|
|
3007
|
+
|
|
3008
|
+
def test_all_scenarios(self):
|
|
3009
|
+
"""Test comprehensive scenarios: 3 correct + 7 errors."""
|
|
3010
|
+
diff = {
|
|
3011
|
+
"issues": {
|
|
3012
|
+
"table_name": "issues",
|
|
3013
|
+
"primary_key": ["id"],
|
|
3014
|
+
"added_rows": [
|
|
3015
|
+
# (a) CORRECT INSERTION - matches spec exactly
|
|
3016
|
+
{
|
|
3017
|
+
"row_id": 100,
|
|
3018
|
+
"data": {
|
|
3019
|
+
"id": 100,
|
|
3020
|
+
"title": "Correct new issue",
|
|
3021
|
+
"status": "open",
|
|
3022
|
+
"priority": "medium",
|
|
3023
|
+
},
|
|
3024
|
+
},
|
|
3025
|
+
# (b) WRONG FIELDS INSERTION - multiple fields wrong
|
|
3026
|
+
{
|
|
3027
|
+
"row_id": 101,
|
|
3028
|
+
"data": {
|
|
3029
|
+
"id": 101,
|
|
3030
|
+
"title": "Wrong title here", # Spec expects 'Expected title'
|
|
3031
|
+
"status": "closed", # Spec expects 'open'
|
|
3032
|
+
"priority": "low", # Spec expects 'high'
|
|
3033
|
+
},
|
|
3034
|
+
},
|
|
3035
|
+
# (c) MISSING INSERTION - row 102 NOT here (spec expects it)
|
|
3036
|
+
# (d) UNEXPECTED INSERTION - no spec for this
|
|
3037
|
+
{
|
|
3038
|
+
"row_id": 999,
|
|
3039
|
+
"data": {
|
|
3040
|
+
"id": 999,
|
|
3041
|
+
"title": "Surprise insert",
|
|
3042
|
+
"status": "new",
|
|
3043
|
+
"priority": "high",
|
|
3044
|
+
},
|
|
3045
|
+
},
|
|
3046
|
+
],
|
|
3047
|
+
"removed_rows": [
|
|
3048
|
+
# (a) CORRECT DELETION - matches spec
|
|
3049
|
+
{
|
|
3050
|
+
"row_id": 200,
|
|
3051
|
+
"data": {
|
|
3052
|
+
"id": 200,
|
|
3053
|
+
"title": "Correctly deleted issue",
|
|
3054
|
+
"status": "resolved",
|
|
3055
|
+
},
|
|
3056
|
+
},
|
|
3057
|
+
# (b) UNEXPECTED DELETION - deleted but no spec
|
|
3058
|
+
{
|
|
3059
|
+
"row_id": 201,
|
|
3060
|
+
"data": {
|
|
3061
|
+
"id": 201,
|
|
3062
|
+
"title": "Should not be deleted",
|
|
3063
|
+
"status": "active",
|
|
3064
|
+
},
|
|
3065
|
+
},
|
|
3066
|
+
# (c) MISSING DELETION - row 202 NOT here (spec expects delete)
|
|
3067
|
+
],
|
|
3068
|
+
"modified_rows": [
|
|
3069
|
+
# (a) CORRECT MODIFICATION - matches spec
|
|
3070
|
+
{
|
|
3071
|
+
"row_id": 300,
|
|
3072
|
+
"changes": {
|
|
3073
|
+
"status": {"before": "open", "after": "in_progress"},
|
|
3074
|
+
},
|
|
3075
|
+
"data": {
|
|
3076
|
+
"id": 300,
|
|
3077
|
+
"title": "Correctly modified issue",
|
|
3078
|
+
"status": "in_progress",
|
|
3079
|
+
},
|
|
3080
|
+
},
|
|
3081
|
+
# (b) WRONG FIELDS MODIFICATION - multiple fields wrong
|
|
3082
|
+
{
|
|
3083
|
+
"row_id": 301,
|
|
3084
|
+
"changes": {
|
|
3085
|
+
"status": {"before": "open", "after": "closed"},
|
|
3086
|
+
"priority": {"before": "low", "after": "high"},
|
|
3087
|
+
},
|
|
3088
|
+
"data": {
|
|
3089
|
+
"id": 301,
|
|
3090
|
+
"title": "Wrong value modification",
|
|
3091
|
+
"status": "closed", # Spec expects 'resolved'
|
|
3092
|
+
"priority": "high", # Spec expects 'low'
|
|
3093
|
+
},
|
|
3094
|
+
},
|
|
3095
|
+
# (c) MISSING MODIFICATION - row 302 NOT here (spec expects modify)
|
|
3096
|
+
],
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
allowed_changes = [
|
|
3101
|
+
# === INSERTIONS ===
|
|
3102
|
+
# (a) CORRECT - row 100 matches
|
|
3103
|
+
{
|
|
3104
|
+
"table": "issues",
|
|
3105
|
+
"pk": 100,
|
|
3106
|
+
"type": "insert",
|
|
3107
|
+
"fields": [
|
|
3108
|
+
("id", 100),
|
|
3109
|
+
("title", "Correct new issue"),
|
|
3110
|
+
("status", "open"),
|
|
3111
|
+
("priority", "medium"),
|
|
3112
|
+
],
|
|
3113
|
+
},
|
|
3114
|
+
# (b) WRONG FIELDS - 3 fields mismatch
|
|
3115
|
+
{
|
|
3116
|
+
"table": "issues",
|
|
3117
|
+
"pk": 101,
|
|
3118
|
+
"type": "insert",
|
|
3119
|
+
"fields": [
|
|
3120
|
+
("id", 101),
|
|
3121
|
+
("title", "Expected title"), # MISMATCH: actual is 'Wrong title here'
|
|
3122
|
+
("status", "open"), # MISMATCH: actual is 'closed'
|
|
3123
|
+
("priority", "high"), # MISMATCH: actual is 'low'
|
|
3124
|
+
],
|
|
3125
|
+
},
|
|
3126
|
+
# (c) MISSING - expects row 102, not added
|
|
3127
|
+
{
|
|
3128
|
+
"table": "issues",
|
|
3129
|
+
"pk": 102,
|
|
3130
|
+
"type": "insert",
|
|
3131
|
+
"fields": [
|
|
3132
|
+
("id", 102),
|
|
3133
|
+
("title", "Expected but missing"),
|
|
3134
|
+
("status", "new"),
|
|
3135
|
+
("priority", "low"),
|
|
3136
|
+
],
|
|
3137
|
+
},
|
|
3138
|
+
# (d) NO SPEC for row 999 - it's unexpected
|
|
3139
|
+
|
|
3140
|
+
# === DELETIONS ===
|
|
3141
|
+
# (a) CORRECT - row 200 matches
|
|
3142
|
+
{"table": "issues", "pk": 200, "type": "delete"},
|
|
3143
|
+
# (b) NO SPEC for row 201 - it's unexpected
|
|
3144
|
+
# (c) MISSING - expects row 202 deleted
|
|
3145
|
+
{"table": "issues", "pk": 202, "type": "delete"},
|
|
3146
|
+
|
|
3147
|
+
# === MODIFICATIONS ===
|
|
3148
|
+
# (a) CORRECT - row 300 matches
|
|
3149
|
+
{
|
|
3150
|
+
"table": "issues",
|
|
3151
|
+
"pk": 300,
|
|
3152
|
+
"type": "modify",
|
|
3153
|
+
"resulting_fields": [("status", "in_progress")],
|
|
3154
|
+
"no_other_changes": True,
|
|
3155
|
+
},
|
|
3156
|
+
# (b) WRONG FIELDS - 2 fields mismatch
|
|
3157
|
+
{
|
|
3158
|
+
"table": "issues",
|
|
3159
|
+
"pk": 301,
|
|
3160
|
+
"type": "modify",
|
|
3161
|
+
"resulting_fields": [
|
|
3162
|
+
("status", "resolved"), # MISMATCH: actual is 'closed'
|
|
3163
|
+
("priority", "low"), # MISMATCH: actual is 'high'
|
|
3164
|
+
],
|
|
3165
|
+
"no_other_changes": True,
|
|
3166
|
+
},
|
|
3167
|
+
# (c) MISSING - expects row 302 modified
|
|
3168
|
+
{
|
|
3169
|
+
"table": "issues",
|
|
3170
|
+
"pk": 302,
|
|
3171
|
+
"type": "modify",
|
|
3172
|
+
"resulting_fields": [("status", "done")],
|
|
3173
|
+
"no_other_changes": True,
|
|
3174
|
+
},
|
|
3175
|
+
]
|
|
3176
|
+
|
|
3177
|
+
mock = MockSnapshotDiff(diff)
|
|
3178
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3179
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
3180
|
+
|
|
3181
|
+
error_msg = str(exc_info.value)
|
|
3182
|
+
print("\n" + "=" * 80)
|
|
3183
|
+
print("TEST: Comprehensive Error Scenarios (expect_only_v2)")
|
|
3184
|
+
print("=" * 80)
|
|
3185
|
+
print(error_msg)
|
|
3186
|
+
print("=" * 80)
|
|
3187
|
+
|
|
3188
|
+
# Verify CORRECT changes (100, 200, 300) are NOT errors
|
|
3189
|
+
unexpected_section = error_msg.split("Allowed changes were:")[0]
|
|
3190
|
+
assert "Row ID: 100" not in unexpected_section, "Row 100 (correct insert) should not be error"
|
|
3191
|
+
assert "Row ID: 200" not in unexpected_section, "Row 200 (correct delete) should not be error"
|
|
3192
|
+
assert "Row ID: 300" not in unexpected_section, "Row 300 (correct modify) should not be error"
|
|
3193
|
+
|
|
3194
|
+
# Verify WRONG FIELD errors (101, 301) are present
|
|
3195
|
+
assert "101" in error_msg, "Row 101 (wrong field insert) should be error"
|
|
3196
|
+
assert "301" in error_msg, "Row 301 (wrong field modify) should be error"
|
|
3197
|
+
|
|
3198
|
+
# Verify UNEXPECTED changes (201, 999) are present
|
|
3199
|
+
assert "201" in error_msg, "Row 201 (unexpected delete) should be error"
|
|
3200
|
+
assert "999" in error_msg, "Row 999 (unexpected insert) should be error"
|
|
3201
|
+
|
|
3202
|
+
def test_with_expect_exactly(self):
|
|
3203
|
+
"""Test the same scenarios with expect_exactly - should catch all 7 errors."""
|
|
3204
|
+
diff = {
|
|
3205
|
+
"issues": {
|
|
3206
|
+
"table_name": "issues",
|
|
3207
|
+
"primary_key": ["id"],
|
|
3208
|
+
"added_rows": [
|
|
3209
|
+
{"row_id": 100, "data": {"id": 100, "title": "Correct new issue", "status": "open", "priority": "medium"}},
|
|
3210
|
+
{"row_id": 101, "data": {"id": 101, "title": "Wrong title here", "status": "closed", "priority": "low"}},
|
|
3211
|
+
{"row_id": 999, "data": {"id": 999, "title": "Surprise insert", "status": "new", "priority": "high"}},
|
|
3212
|
+
],
|
|
3213
|
+
"removed_rows": [
|
|
3214
|
+
{"row_id": 200, "data": {"id": 200, "title": "Correctly deleted issue", "status": "resolved"}},
|
|
3215
|
+
{"row_id": 201, "data": {"id": 201, "title": "Should not be deleted", "status": "active"}},
|
|
3216
|
+
],
|
|
3217
|
+
"modified_rows": [
|
|
3218
|
+
{"row_id": 300, "changes": {"status": {"before": "open", "after": "in_progress"}},
|
|
3219
|
+
"data": {"id": 300, "status": "in_progress"}},
|
|
3220
|
+
{"row_id": 301, "changes": {"status": {"before": "open", "after": "closed"}, "priority": {"before": "low", "after": "high"}},
|
|
3221
|
+
"data": {"id": 301, "status": "closed", "priority": "high"}},
|
|
3222
|
+
],
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
expected_changes = [
|
|
3227
|
+
{"table": "issues", "pk": 100, "type": "insert",
|
|
3228
|
+
"fields": [("id", 100), ("title", "Correct new issue"), ("status", "open"), ("priority", "medium")]},
|
|
3229
|
+
{"table": "issues", "pk": 101, "type": "insert",
|
|
3230
|
+
"fields": [("id", 101), ("title", "Expected title"), ("status", "open"), ("priority", "high")]},
|
|
3231
|
+
{"table": "issues", "pk": 102, "type": "insert",
|
|
3232
|
+
"fields": [("id", 102), ("title", "Expected but missing"), ("status", "new"), ("priority", "low")]},
|
|
3233
|
+
{"table": "issues", "pk": 200, "type": "delete"},
|
|
3234
|
+
{"table": "issues", "pk": 202, "type": "delete"},
|
|
3235
|
+
{"table": "issues", "pk": 300, "type": "modify",
|
|
3236
|
+
"resulting_fields": [("status", "in_progress")], "no_other_changes": True},
|
|
3237
|
+
{"table": "issues", "pk": 301, "type": "modify",
|
|
3238
|
+
"resulting_fields": [("status", "resolved"), ("priority", "low")], "no_other_changes": True},
|
|
3239
|
+
{"table": "issues", "pk": 302, "type": "modify",
|
|
3240
|
+
"resulting_fields": [("status", "done")], "no_other_changes": True},
|
|
3241
|
+
]
|
|
3242
|
+
|
|
3243
|
+
mock = MockSnapshotDiff(diff)
|
|
3244
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3245
|
+
mock.expect_exactly(expected_changes)
|
|
3246
|
+
|
|
3247
|
+
error_msg = str(exc_info.value)
|
|
3248
|
+
print("\n" + "=" * 80)
|
|
3249
|
+
print("TEST: Comprehensive Error Scenarios with expect_exactly")
|
|
3250
|
+
print("=" * 80)
|
|
3251
|
+
print(error_msg)
|
|
3252
|
+
print("=" * 80)
|
|
3253
|
+
|
|
3254
|
+
# Verify error count
|
|
3255
|
+
assert "7 error(s) detected" in error_msg
|
|
3256
|
+
|
|
3257
|
+
# Verify all error categories are present
|
|
3258
|
+
assert "FIELD MISMATCHES" in error_msg
|
|
3259
|
+
assert "UNEXPECTED CHANGES" in error_msg
|
|
3260
|
+
assert "MISSING EXPECTED CHANGES" in error_msg
|
|
3261
|
+
|
|
3262
|
+
# Verify field mismatches show multiple fields
|
|
3263
|
+
assert "title" in error_msg # INSERT 101 has wrong title
|
|
3264
|
+
assert "status" in error_msg # INSERT 101 and MODIFY 301 have wrong status
|
|
3265
|
+
assert "priority" in error_msg # INSERT 101 and MODIFY 301 have wrong priority
|
|
3266
|
+
|
|
3267
|
+
# Verify hints section exists
|
|
3268
|
+
assert "HINTS" in error_msg or "near-match" in error_msg.lower()
|
|
3269
|
+
|
|
3270
|
+
def test_special_patterns(self):
|
|
3271
|
+
"""
|
|
3272
|
+
Test special spec patterns:
|
|
3273
|
+
- Ellipsis (...): Accept any value for a field
|
|
3274
|
+
- None: Check for SQL NULL
|
|
3275
|
+
- no_other_changes=False: Lenient mode for modifications
|
|
3276
|
+
|
|
3277
|
+
Scenarios:
|
|
3278
|
+
| Row | Type | Pattern Being Tested | Should Pass? |
|
|
3279
|
+
|------|--------|-----------------------------------------|--------------|
|
|
3280
|
+
| 400 | INSERT | Ellipsis for title field | YES |
|
|
3281
|
+
| 401 | INSERT | Ellipsis works, but other field wrong | NO (status) |
|
|
3282
|
+
| 402 | INSERT | None check - field is NULL | YES |
|
|
3283
|
+
| 403 | INSERT | None check - field is NOT NULL | NO |
|
|
3284
|
+
| 500 | MODIFY | no_other_changes=False (lenient) | YES |
|
|
3285
|
+
| 501 | MODIFY | no_other_changes=True with extra change| NO |
|
|
3286
|
+
"""
|
|
3287
|
+
diff = {
|
|
3288
|
+
"issues": {
|
|
3289
|
+
"table_name": "issues",
|
|
3290
|
+
"primary_key": ["id"],
|
|
3291
|
+
"added_rows": [
|
|
3292
|
+
# Row 400: Ellipsis should accept any title
|
|
3293
|
+
{
|
|
3294
|
+
"row_id": 400,
|
|
3295
|
+
"data": {
|
|
3296
|
+
"id": 400,
|
|
3297
|
+
"title": "Any title works here", # Spec uses ...
|
|
3298
|
+
"status": "open",
|
|
3299
|
+
},
|
|
3300
|
+
},
|
|
3301
|
+
# Row 401: Ellipsis for title, but status is wrong
|
|
3302
|
+
{
|
|
3303
|
+
"row_id": 401,
|
|
3304
|
+
"data": {
|
|
3305
|
+
"id": 401,
|
|
3306
|
+
"title": "This title is fine", # Spec uses ...
|
|
3307
|
+
"status": "closed", # WRONG: spec expects 'open'
|
|
3308
|
+
},
|
|
3309
|
+
},
|
|
3310
|
+
# Row 402: None check - field IS NULL (matches)
|
|
3311
|
+
{
|
|
3312
|
+
"row_id": 402,
|
|
3313
|
+
"data": {
|
|
3314
|
+
"id": 402,
|
|
3315
|
+
"title": "Has null field",
|
|
3316
|
+
"assignee": None, # Spec expects None
|
|
3317
|
+
},
|
|
3318
|
+
},
|
|
3319
|
+
# Row 403: None check - field is NOT NULL (mismatch)
|
|
3320
|
+
{
|
|
3321
|
+
"row_id": 403,
|
|
3322
|
+
"data": {
|
|
3323
|
+
"id": 403,
|
|
3324
|
+
"title": "Should have null",
|
|
3325
|
+
"assignee": "john", # WRONG: spec expects None
|
|
3326
|
+
},
|
|
3327
|
+
},
|
|
3328
|
+
],
|
|
3329
|
+
"removed_rows": [],
|
|
3330
|
+
"modified_rows": [
|
|
3331
|
+
# Row 500: no_other_changes=False - extra change is OK
|
|
3332
|
+
{
|
|
3333
|
+
"row_id": 500,
|
|
3334
|
+
"changes": {
|
|
3335
|
+
"status": {"before": "open", "after": "closed"},
|
|
3336
|
+
"updated_at": {"before": "2024-01-01", "after": "2024-01-15"}, # Extra change
|
|
3337
|
+
},
|
|
3338
|
+
"data": {"id": 500, "status": "closed", "updated_at": "2024-01-15"},
|
|
3339
|
+
},
|
|
3340
|
+
# Row 501: no_other_changes=True - extra change is NOT OK
|
|
3341
|
+
{
|
|
3342
|
+
"row_id": 501,
|
|
3343
|
+
"changes": {
|
|
3344
|
+
"status": {"before": "open", "after": "closed"},
|
|
3345
|
+
"priority": {"before": "low", "after": "high"}, # NOT allowed
|
|
3346
|
+
},
|
|
3347
|
+
"data": {"id": 501, "status": "closed", "priority": "high"},
|
|
3348
|
+
},
|
|
3349
|
+
],
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
expected_changes = [
|
|
3354
|
+
# Row 400: Ellipsis for title - should PASS
|
|
3355
|
+
{
|
|
3356
|
+
"table": "issues",
|
|
3357
|
+
"pk": 400,
|
|
3358
|
+
"type": "insert",
|
|
3359
|
+
"fields": [
|
|
3360
|
+
("id", 400),
|
|
3361
|
+
("title", ...), # Accept any value
|
|
3362
|
+
("status", "open"),
|
|
3363
|
+
],
|
|
3364
|
+
},
|
|
3365
|
+
# Row 401: Ellipsis for title, but wrong status - should FAIL on status
|
|
3366
|
+
{
|
|
3367
|
+
"table": "issues",
|
|
3368
|
+
"pk": 401,
|
|
3369
|
+
"type": "insert",
|
|
3370
|
+
"fields": [
|
|
3371
|
+
("id", 401),
|
|
3372
|
+
("title", ...), # Accept any value
|
|
3373
|
+
("status", "open"), # MISMATCH: actual is 'closed'
|
|
3374
|
+
],
|
|
3375
|
+
},
|
|
3376
|
+
# Row 402: None check - field IS NULL - should PASS
|
|
3377
|
+
{
|
|
3378
|
+
"table": "issues",
|
|
3379
|
+
"pk": 402,
|
|
3380
|
+
"type": "insert",
|
|
3381
|
+
"fields": [
|
|
3382
|
+
("id", 402),
|
|
3383
|
+
("title", "Has null field"),
|
|
3384
|
+
("assignee", None), # Expect NULL
|
|
3385
|
+
],
|
|
3386
|
+
},
|
|
3387
|
+
# Row 403: None check - field is NOT NULL - should FAIL
|
|
3388
|
+
{
|
|
3389
|
+
"table": "issues",
|
|
3390
|
+
"pk": 403,
|
|
3391
|
+
"type": "insert",
|
|
3392
|
+
"fields": [
|
|
3393
|
+
("id", 403),
|
|
3394
|
+
("title", "Should have null"),
|
|
3395
|
+
("assignee", None), # Expect NULL, actual is 'john'
|
|
3396
|
+
],
|
|
3397
|
+
},
|
|
3398
|
+
# Row 500: no_other_changes=False (lenient) - should PASS
|
|
3399
|
+
{
|
|
3400
|
+
"table": "issues",
|
|
3401
|
+
"pk": 500,
|
|
3402
|
+
"type": "modify",
|
|
3403
|
+
"resulting_fields": [("status", "closed")],
|
|
3404
|
+
"no_other_changes": False, # Lenient: ignore updated_at change
|
|
3405
|
+
},
|
|
3406
|
+
# Row 501: no_other_changes=True (strict) with extra change - should FAIL
|
|
3407
|
+
{
|
|
3408
|
+
"table": "issues",
|
|
3409
|
+
"pk": 501,
|
|
3410
|
+
"type": "modify",
|
|
3411
|
+
"resulting_fields": [("status", "closed")],
|
|
3412
|
+
"no_other_changes": True, # Strict: priority change not allowed
|
|
3413
|
+
},
|
|
3414
|
+
]
|
|
3415
|
+
|
|
3416
|
+
mock = MockSnapshotDiff(diff)
|
|
3417
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3418
|
+
mock.expect_exactly(expected_changes)
|
|
3419
|
+
|
|
3420
|
+
error_msg = str(exc_info.value)
|
|
3421
|
+
print("\n" + "=" * 80)
|
|
3422
|
+
print("TEST: Special Patterns (ellipsis, None, no_other_changes)")
|
|
3423
|
+
print("=" * 80)
|
|
3424
|
+
print(error_msg)
|
|
3425
|
+
print("=" * 80)
|
|
3426
|
+
|
|
3427
|
+
# Should have 3 errors: 401 (wrong status), 403 (not NULL), 501 (extra change)
|
|
3428
|
+
assert "3 error(s) detected" in error_msg, f"Expected 3 errors, got: {error_msg}"
|
|
3429
|
+
|
|
3430
|
+
# Rows 400, 402, 500 should pass (not in errors)
|
|
3431
|
+
assert "pk=400" not in error_msg, "Row 400 (ellipsis) should pass"
|
|
3432
|
+
assert "pk=402" not in error_msg, "Row 402 (None match) should pass"
|
|
3433
|
+
assert "pk=500" not in error_msg, "Row 500 (lenient modify) should pass"
|
|
3434
|
+
|
|
3435
|
+
# Rows 401, 403, 501 should fail
|
|
3436
|
+
assert "pk=401" in error_msg, "Row 401 (ellipsis but wrong status) should fail"
|
|
3437
|
+
assert "pk=403" in error_msg, "Row 403 (None mismatch) should fail"
|
|
3438
|
+
assert "pk=501" in error_msg, "Row 501 (strict modify with extra) should fail"
|
|
3439
|
+
|
|
3440
|
+
# Verify specific error reasons
|
|
3441
|
+
# Row 401: status mismatch (ellipsis for title should work, but status wrong)
|
|
3442
|
+
assert "status" in error_msg and ("closed" in error_msg or "open" in error_msg)
|
|
3443
|
+
|
|
3444
|
+
# Row 403: assignee should show None vs 'john'
|
|
3445
|
+
assert "assignee" in error_msg
|
|
3446
|
+
|
|
3447
|
+
# Row 501: priority change not in resulting_fields
|
|
3448
|
+
assert "priority" in error_msg
|
|
3449
|
+
|
|
3450
|
+
|
|
3451
|
+
class TestMixedCorrectAndIncorrect:
|
|
3452
|
+
"""
|
|
3453
|
+
Test cases with mixed correct and incorrect changes to verify that:
|
|
3454
|
+
1. Correct specs are matched and don't appear as errors
|
|
3455
|
+
2. Incorrect specs are flagged with clear error messages
|
|
3456
|
+
3. The error message clearly distinguishes what matched vs what didn't
|
|
3457
|
+
"""
|
|
3458
|
+
|
|
3459
|
+
def test_mixed_all_change_types(self):
|
|
3460
|
+
"""
|
|
3461
|
+
Test with 1 correct and 1 incorrect of each type:
|
|
3462
|
+
- ADDITION: 1 correct (matches spec), 1 incorrect (wrong field value)
|
|
3463
|
+
- MODIFICATION: 1 correct (matches spec), 1 incorrect (extra field changed)
|
|
3464
|
+
- DELETION: 1 correct (matches spec), 1 incorrect (not in spec at all)
|
|
3465
|
+
"""
|
|
3466
|
+
diff = {
|
|
3467
|
+
"issues": {
|
|
3468
|
+
"table_name": "issues",
|
|
3469
|
+
"primary_key": ["id"],
|
|
3470
|
+
"added_rows": [
|
|
3471
|
+
# CORRECT ADDITION - matches spec exactly
|
|
3472
|
+
{
|
|
3473
|
+
"row_id": 100,
|
|
3474
|
+
"data": {
|
|
3475
|
+
"id": 100,
|
|
3476
|
+
"title": "Correct new issue",
|
|
3477
|
+
"status": "open",
|
|
3478
|
+
"priority": "medium",
|
|
3479
|
+
},
|
|
3480
|
+
},
|
|
3481
|
+
# INCORRECT ADDITION - status is wrong
|
|
3482
|
+
{
|
|
3483
|
+
"row_id": 101,
|
|
3484
|
+
"data": {
|
|
3485
|
+
"id": 101,
|
|
3486
|
+
"title": "Incorrect new issue",
|
|
3487
|
+
"status": "closed", # Spec expects 'open'
|
|
3488
|
+
"priority": "high",
|
|
3489
|
+
},
|
|
3490
|
+
},
|
|
3491
|
+
],
|
|
3492
|
+
"removed_rows": [
|
|
3493
|
+
# CORRECT DELETION - matches spec
|
|
3494
|
+
{
|
|
3495
|
+
"row_id": 200,
|
|
3496
|
+
"data": {
|
|
3497
|
+
"id": 200,
|
|
3498
|
+
"title": "Old issue to delete",
|
|
3499
|
+
"status": "resolved",
|
|
3500
|
+
},
|
|
3501
|
+
},
|
|
3502
|
+
# INCORRECT DELETION - not allowed at all
|
|
3503
|
+
{
|
|
3504
|
+
"row_id": 201,
|
|
3505
|
+
"data": {
|
|
3506
|
+
"id": 201,
|
|
3507
|
+
"title": "Should not be deleted",
|
|
3508
|
+
"status": "active",
|
|
3509
|
+
},
|
|
3510
|
+
},
|
|
3511
|
+
],
|
|
3512
|
+
"modified_rows": [
|
|
3513
|
+
# CORRECT MODIFICATION - matches spec
|
|
3514
|
+
{
|
|
3515
|
+
"row_id": 300,
|
|
3516
|
+
"changes": {
|
|
3517
|
+
"status": {"before": "open", "after": "in_progress"},
|
|
3518
|
+
},
|
|
3519
|
+
"data": {
|
|
3520
|
+
"id": 300,
|
|
3521
|
+
"title": "Issue being worked on",
|
|
3522
|
+
"status": "in_progress",
|
|
3523
|
+
},
|
|
3524
|
+
},
|
|
3525
|
+
# INCORRECT MODIFICATION - has extra field change not in spec
|
|
3526
|
+
{
|
|
3527
|
+
"row_id": 301,
|
|
3528
|
+
"changes": {
|
|
3529
|
+
"status": {"before": "open", "after": "closed"},
|
|
3530
|
+
"updated_at": {"before": "2024-01-01", "after": "2024-01-15"}, # Not in spec!
|
|
3531
|
+
},
|
|
3532
|
+
"data": {
|
|
3533
|
+
"id": 301,
|
|
3534
|
+
"title": "Issue with extra change",
|
|
3535
|
+
"status": "closed",
|
|
3536
|
+
"updated_at": "2024-01-15",
|
|
3537
|
+
},
|
|
3538
|
+
},
|
|
3539
|
+
],
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
allowed_changes = [
|
|
3544
|
+
# CORRECT ADDITION spec
|
|
3545
|
+
{
|
|
3546
|
+
"table": "issues",
|
|
3547
|
+
"pk": 100,
|
|
3548
|
+
"type": "insert",
|
|
3549
|
+
"fields": [
|
|
3550
|
+
("id", 100),
|
|
3551
|
+
("title", "Correct new issue"),
|
|
3552
|
+
("status", "open"),
|
|
3553
|
+
("priority", "medium"),
|
|
3554
|
+
],
|
|
3555
|
+
},
|
|
3556
|
+
# INCORRECT ADDITION spec - expects status='open' but row has 'closed'
|
|
3557
|
+
{
|
|
3558
|
+
"table": "issues",
|
|
3559
|
+
"pk": 101,
|
|
3560
|
+
"type": "insert",
|
|
3561
|
+
"fields": [
|
|
3562
|
+
("id", 101),
|
|
3563
|
+
("title", "Incorrect new issue"),
|
|
3564
|
+
("status", "open"), # WRONG - actual is 'closed'
|
|
3565
|
+
("priority", "high"),
|
|
3566
|
+
],
|
|
3567
|
+
},
|
|
3568
|
+
# CORRECT DELETION spec
|
|
3569
|
+
{
|
|
3570
|
+
"table": "issues",
|
|
3571
|
+
"pk": 200,
|
|
3572
|
+
"type": "delete",
|
|
3573
|
+
},
|
|
3574
|
+
# No spec for row 201 deletion - it's unexpected
|
|
3575
|
+
# CORRECT MODIFICATION spec
|
|
3576
|
+
{
|
|
3577
|
+
"table": "issues",
|
|
3578
|
+
"pk": 300,
|
|
3579
|
+
"type": "modify",
|
|
3580
|
+
"resulting_fields": [("status", "in_progress")],
|
|
3581
|
+
"no_other_changes": True,
|
|
3582
|
+
},
|
|
3583
|
+
# INCORRECT MODIFICATION spec - doesn't include updated_at
|
|
3584
|
+
{
|
|
3585
|
+
"table": "issues",
|
|
3586
|
+
"pk": 301,
|
|
3587
|
+
"type": "modify",
|
|
3588
|
+
"resulting_fields": [("status", "closed")],
|
|
3589
|
+
"no_other_changes": True, # Strict mode - will fail due to updated_at
|
|
3590
|
+
},
|
|
3591
|
+
]
|
|
3592
|
+
|
|
3593
|
+
mock = MockSnapshotDiff(diff)
|
|
3594
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3595
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
3596
|
+
|
|
3597
|
+
error_msg = str(exc_info.value)
|
|
3598
|
+
print("\n" + "=" * 80)
|
|
3599
|
+
print("TEST: Mixed correct and incorrect - all change types")
|
|
3600
|
+
print("=" * 80)
|
|
3601
|
+
print(error_msg)
|
|
3602
|
+
print("=" * 80)
|
|
3603
|
+
|
|
3604
|
+
# Verify CORRECT changes are NOT in error message (they should be matched)
|
|
3605
|
+
# Row 100 (correct addition) should NOT appear as an error
|
|
3606
|
+
# Row 200 (correct deletion) should NOT appear as an error
|
|
3607
|
+
# Row 300 (correct modification) should NOT appear as an error
|
|
3608
|
+
|
|
3609
|
+
# Verify INCORRECT changes ARE in error message
|
|
3610
|
+
# Row 101 (wrong status value)
|
|
3611
|
+
assert "101" in error_msg, "Row 101 (incorrect addition) should be in error"
|
|
3612
|
+
assert "status" in error_msg, "status field mismatch should be mentioned"
|
|
3613
|
+
|
|
3614
|
+
# Row 201 (unexpected deletion)
|
|
3615
|
+
assert "201" in error_msg, "Row 201 (unexpected deletion) should be in error"
|
|
3616
|
+
assert "DELETION" in error_msg, "Deletion type should be mentioned"
|
|
3617
|
+
|
|
3618
|
+
# Row 301 (extra field change)
|
|
3619
|
+
assert "301" in error_msg, "Row 301 (incorrect modification) should be in error"
|
|
3620
|
+
assert "updated_at" in error_msg, "updated_at field should be mentioned"
|
|
3621
|
+
|
|
3622
|
+
# Count the number of errors - should be exactly 3
|
|
3623
|
+
# (101 insertion mismatch, 201 unexpected deletion, 301 modification mismatch)
|
|
3624
|
+
lines_with_row_id = [l for l in error_msg.split('\n') if 'Row ID:' in l]
|
|
3625
|
+
print(f"\nRows with errors: {len(lines_with_row_id)}")
|
|
3626
|
+
print("Row IDs in error:", [l.strip() for l in lines_with_row_id])
|
|
3627
|
+
|
|
3628
|
+
def test_mixed_with_detailed_output(self):
|
|
3629
|
+
"""
|
|
3630
|
+
Same as above but with more detailed assertions about what the
|
|
3631
|
+
error message should contain for each incorrect change.
|
|
3632
|
+
"""
|
|
3633
|
+
diff = {
|
|
3634
|
+
"tasks": {
|
|
3635
|
+
"table_name": "tasks",
|
|
3636
|
+
"primary_key": ["id"],
|
|
3637
|
+
"added_rows": [
|
|
3638
|
+
# CORRECT - fully matches
|
|
3639
|
+
{
|
|
3640
|
+
"row_id": "task-001",
|
|
3641
|
+
"data": {"id": "task-001", "name": "Setup", "done": False},
|
|
3642
|
+
},
|
|
3643
|
+
# INCORRECT - 'done' should be False per spec
|
|
3644
|
+
{
|
|
3645
|
+
"row_id": "task-002",
|
|
3646
|
+
"data": {"id": "task-002", "name": "Deploy", "done": True},
|
|
3647
|
+
},
|
|
3648
|
+
],
|
|
3649
|
+
"removed_rows": [],
|
|
3650
|
+
"modified_rows": [
|
|
3651
|
+
# CORRECT - status change matches
|
|
3652
|
+
{
|
|
3653
|
+
"row_id": "task-003",
|
|
3654
|
+
"changes": {"done": {"before": False, "after": True}},
|
|
3655
|
+
"data": {"id": "task-003", "name": "Test", "done": True},
|
|
3656
|
+
},
|
|
3657
|
+
# INCORRECT - 'done' value is wrong
|
|
3658
|
+
{
|
|
3659
|
+
"row_id": "task-004",
|
|
3660
|
+
"changes": {"done": {"before": True, "after": False}},
|
|
3661
|
+
"data": {"id": "task-004", "name": "Review", "done": False},
|
|
3662
|
+
},
|
|
3663
|
+
],
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
allowed_changes = [
|
|
3668
|
+
# CORRECT insertion
|
|
3669
|
+
{
|
|
3670
|
+
"table": "tasks",
|
|
3671
|
+
"pk": "task-001",
|
|
3672
|
+
"type": "insert",
|
|
3673
|
+
"fields": [("id", "task-001"), ("name", "Setup"), ("done", False)],
|
|
3674
|
+
},
|
|
3675
|
+
# INCORRECT insertion - expects done=False but got True
|
|
3676
|
+
{
|
|
3677
|
+
"table": "tasks",
|
|
3678
|
+
"pk": "task-002",
|
|
3679
|
+
"type": "insert",
|
|
3680
|
+
"fields": [("id", "task-002"), ("name", "Deploy"), ("done", False)],
|
|
3681
|
+
},
|
|
3682
|
+
# CORRECT modification
|
|
3683
|
+
{
|
|
3684
|
+
"table": "tasks",
|
|
3685
|
+
"pk": "task-003",
|
|
3686
|
+
"type": "modify",
|
|
3687
|
+
"resulting_fields": [("done", True)],
|
|
3688
|
+
"no_other_changes": True,
|
|
3689
|
+
},
|
|
3690
|
+
# INCORRECT modification - expects done=True but got False
|
|
3691
|
+
{
|
|
3692
|
+
"table": "tasks",
|
|
3693
|
+
"pk": "task-004",
|
|
3694
|
+
"type": "modify",
|
|
3695
|
+
"resulting_fields": [("done", True)],
|
|
3696
|
+
"no_other_changes": True,
|
|
3697
|
+
},
|
|
3698
|
+
]
|
|
3699
|
+
|
|
3700
|
+
mock = MockSnapshotDiff(diff)
|
|
3701
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3702
|
+
mock._validate_diff_against_allowed_changes_v2(diff, allowed_changes)
|
|
3703
|
+
|
|
3704
|
+
error_msg = str(exc_info.value)
|
|
3705
|
+
print("\n" + "=" * 80)
|
|
3706
|
+
print("TEST: Mixed with detailed output")
|
|
3707
|
+
print("=" * 80)
|
|
3708
|
+
print(error_msg)
|
|
3709
|
+
print("=" * 80)
|
|
3710
|
+
|
|
3711
|
+
# Correct ones should NOT appear in the "unexpected changes" section
|
|
3712
|
+
# (They may appear in "Allowed changes were:" section which is OK)
|
|
3713
|
+
unexpected_section = error_msg.split("Allowed changes were:")[0]
|
|
3714
|
+
assert "task-001" not in unexpected_section, "task-001 (correct insert) should not be in unexpected section"
|
|
3715
|
+
assert "task-003" not in unexpected_section, "task-003 (correct modify) should not be in unexpected section"
|
|
3716
|
+
|
|
3717
|
+
# Incorrect ones SHOULD be errors
|
|
3718
|
+
assert "task-002" in error_msg, "task-002 (wrong insert) should be error"
|
|
3719
|
+
assert "task-004" in error_msg, "task-004 (wrong modify) should be error"
|
|
3720
|
+
|
|
3721
|
+
# Should mention the 'done' field issue
|
|
3722
|
+
assert "done" in error_msg
|
|
3723
|
+
|
|
3724
|
+
print("\n--- Analysis ---")
|
|
3725
|
+
print(f"task-001 in error: {'task-001' in error_msg} (should be False)")
|
|
3726
|
+
print(f"task-002 in error: {'task-002' in error_msg} (should be True)")
|
|
3727
|
+
print(f"task-003 in error: {'task-003' in error_msg} (should be False)")
|
|
3728
|
+
print(f"task-004 in error: {'task-004' in error_msg} (should be True)")
|
|
3729
|
+
|
|
3730
|
+
|
|
3731
|
+
# ============================================================================
|
|
3732
|
+
# Mock-based tests for expect_exactly function
|
|
3733
|
+
# ============================================================================
|
|
3734
|
+
|
|
3735
|
+
|
|
3736
|
+
class TestExpectExactlyMock:
|
|
3737
|
+
"""
|
|
3738
|
+
Test cases for the expect_exactly function using mock diffs.
|
|
3739
|
+
|
|
3740
|
+
expect_exactly should catch:
|
|
3741
|
+
1. Unexpected changes (like expect_only_v2)
|
|
3742
|
+
2. Missing expected changes (NEW - not caught by expect_only_v2)
|
|
3743
|
+
"""
|
|
3744
|
+
|
|
3745
|
+
def test_all_specs_satisfied_passes(self):
|
|
3746
|
+
"""When all specs are satisfied exactly, should pass."""
|
|
3747
|
+
diff = {
|
|
3748
|
+
"users": {
|
|
3749
|
+
"table_name": "users",
|
|
3750
|
+
"primary_key": ["id"],
|
|
3751
|
+
"added_rows": [
|
|
3752
|
+
{"row_id": 1, "data": {"id": 1, "name": "Alice"}},
|
|
3753
|
+
],
|
|
3754
|
+
"removed_rows": [],
|
|
3755
|
+
"modified_rows": [],
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
expected_changes = [
|
|
3759
|
+
{
|
|
3760
|
+
"table": "users",
|
|
3761
|
+
"pk": 1,
|
|
3762
|
+
"type": "insert",
|
|
3763
|
+
"fields": [("id", 1), ("name", "Alice")],
|
|
3764
|
+
}
|
|
3765
|
+
]
|
|
3766
|
+
|
|
3767
|
+
mock = MockSnapshotDiff(diff)
|
|
3768
|
+
# Should pass - spec matches exactly
|
|
3769
|
+
result = mock.expect_exactly(expected_changes)
|
|
3770
|
+
assert result is mock
|
|
3771
|
+
|
|
3772
|
+
print("\n" + "=" * 80)
|
|
3773
|
+
print("TEST: All specs satisfied - PASSED")
|
|
3774
|
+
print("=" * 80)
|
|
3775
|
+
|
|
3776
|
+
def test_missing_insert_fails(self):
|
|
3777
|
+
"""When spec expects insert but row wasn't added, should fail."""
|
|
3778
|
+
diff = {
|
|
3779
|
+
"users": {
|
|
3780
|
+
"table_name": "users",
|
|
3781
|
+
"primary_key": ["id"],
|
|
3782
|
+
"added_rows": [], # No rows added
|
|
3783
|
+
"removed_rows": [],
|
|
3784
|
+
"modified_rows": [],
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
expected_changes = [
|
|
3788
|
+
{
|
|
3789
|
+
"table": "users",
|
|
3790
|
+
"pk": 100,
|
|
3791
|
+
"type": "insert",
|
|
3792
|
+
"fields": [("id", 100), ("name", "Expected but missing")],
|
|
3793
|
+
}
|
|
3794
|
+
]
|
|
3795
|
+
|
|
3796
|
+
mock = MockSnapshotDiff(diff)
|
|
3797
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3798
|
+
mock.expect_exactly(expected_changes)
|
|
3799
|
+
|
|
3800
|
+
error_msg = str(exc_info.value)
|
|
3801
|
+
print("\n" + "=" * 80)
|
|
3802
|
+
print("TEST: Missing insert")
|
|
3803
|
+
print("=" * 80)
|
|
3804
|
+
print(error_msg)
|
|
3805
|
+
print("=" * 80)
|
|
3806
|
+
|
|
3807
|
+
assert "MISSING" in error_msg
|
|
3808
|
+
assert "insert" in error_msg.lower()
|
|
3809
|
+
assert "100" in error_msg
|
|
3810
|
+
|
|
3811
|
+
def test_missing_delete_fails(self):
|
|
3812
|
+
"""When spec expects delete but row still exists, should fail."""
|
|
3813
|
+
diff = {
|
|
3814
|
+
"users": {
|
|
3815
|
+
"table_name": "users",
|
|
3816
|
+
"primary_key": ["id"],
|
|
3817
|
+
"added_rows": [],
|
|
3818
|
+
"removed_rows": [], # No rows deleted
|
|
3819
|
+
"modified_rows": [],
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
expected_changes = [
|
|
3823
|
+
{
|
|
3824
|
+
"table": "users",
|
|
3825
|
+
"pk": 200,
|
|
3826
|
+
"type": "delete",
|
|
3827
|
+
}
|
|
3828
|
+
]
|
|
3829
|
+
|
|
3830
|
+
mock = MockSnapshotDiff(diff)
|
|
3831
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3832
|
+
mock.expect_exactly(expected_changes)
|
|
3833
|
+
|
|
3834
|
+
error_msg = str(exc_info.value)
|
|
3835
|
+
print("\n" + "=" * 80)
|
|
3836
|
+
print("TEST: Missing delete")
|
|
3837
|
+
print("=" * 80)
|
|
3838
|
+
print(error_msg)
|
|
3839
|
+
print("=" * 80)
|
|
3840
|
+
|
|
3841
|
+
assert "MISSING" in error_msg
|
|
3842
|
+
assert "delete" in error_msg.lower()
|
|
3843
|
+
assert "200" in error_msg
|
|
3844
|
+
|
|
3845
|
+
def test_missing_modify_fails(self):
|
|
3846
|
+
"""When spec expects modify but row wasn't changed, should fail."""
|
|
3847
|
+
diff = {
|
|
3848
|
+
"users": {
|
|
3849
|
+
"table_name": "users",
|
|
3850
|
+
"primary_key": ["id"],
|
|
3851
|
+
"added_rows": [],
|
|
3852
|
+
"removed_rows": [],
|
|
3853
|
+
"modified_rows": [], # No rows modified
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
expected_changes = [
|
|
3857
|
+
{
|
|
3858
|
+
"table": "users",
|
|
3859
|
+
"pk": 300,
|
|
3860
|
+
"type": "modify",
|
|
3861
|
+
"resulting_fields": [("status", "active")],
|
|
3862
|
+
"no_other_changes": True,
|
|
3863
|
+
}
|
|
3864
|
+
]
|
|
3865
|
+
|
|
3866
|
+
mock = MockSnapshotDiff(diff)
|
|
3867
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3868
|
+
mock.expect_exactly(expected_changes)
|
|
3869
|
+
|
|
3870
|
+
error_msg = str(exc_info.value)
|
|
3871
|
+
print("\n" + "=" * 80)
|
|
3872
|
+
print("TEST: Missing modify")
|
|
3873
|
+
print("=" * 80)
|
|
3874
|
+
print(error_msg)
|
|
3875
|
+
print("=" * 80)
|
|
3876
|
+
|
|
3877
|
+
assert "MISSING" in error_msg
|
|
3878
|
+
assert "modify" in error_msg.lower()
|
|
3879
|
+
assert "300" in error_msg
|
|
3880
|
+
|
|
3881
|
+
def test_unexpected_change_still_fails(self):
|
|
3882
|
+
"""Unexpected changes should still be caught (like expect_only_v2)."""
|
|
3883
|
+
diff = {
|
|
3884
|
+
"users": {
|
|
3885
|
+
"table_name": "users",
|
|
3886
|
+
"primary_key": ["id"],
|
|
3887
|
+
"added_rows": [
|
|
3888
|
+
{"row_id": 999, "data": {"id": 999, "name": "Unexpected"}},
|
|
3889
|
+
],
|
|
3890
|
+
"removed_rows": [],
|
|
3891
|
+
"modified_rows": [],
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
expected_changes = [] # No changes expected
|
|
3895
|
+
|
|
3896
|
+
mock = MockSnapshotDiff(diff)
|
|
3897
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3898
|
+
mock.expect_exactly(expected_changes)
|
|
3899
|
+
|
|
3900
|
+
error_msg = str(exc_info.value)
|
|
3901
|
+
print("\n" + "=" * 80)
|
|
3902
|
+
print("TEST: Unexpected change")
|
|
3903
|
+
print("=" * 80)
|
|
3904
|
+
print(error_msg)
|
|
3905
|
+
print("=" * 80)
|
|
3906
|
+
|
|
3907
|
+
assert "UNEXPECTED" in error_msg or "Unexpected" in error_msg
|
|
3908
|
+
|
|
3909
|
+
def test_wrong_field_value_fails(self):
|
|
3910
|
+
"""When change happens but field value doesn't match spec, should fail."""
|
|
3911
|
+
diff = {
|
|
3912
|
+
"users": {
|
|
3913
|
+
"table_name": "users",
|
|
3914
|
+
"primary_key": ["id"],
|
|
3915
|
+
"added_rows": [
|
|
3916
|
+
{"row_id": 1, "data": {"id": 1, "name": "Alice", "role": "admin"}},
|
|
3917
|
+
],
|
|
3918
|
+
"removed_rows": [],
|
|
3919
|
+
"modified_rows": [],
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
expected_changes = [
|
|
3923
|
+
{
|
|
3924
|
+
"table": "users",
|
|
3925
|
+
"pk": 1,
|
|
3926
|
+
"type": "insert",
|
|
3927
|
+
"fields": [("id", 1), ("name", "Alice"), ("role", "user")], # Expected 'user' not 'admin'
|
|
3928
|
+
}
|
|
3929
|
+
]
|
|
3930
|
+
|
|
3931
|
+
mock = MockSnapshotDiff(diff)
|
|
3932
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
3933
|
+
mock.expect_exactly(expected_changes)
|
|
3934
|
+
|
|
3935
|
+
error_msg = str(exc_info.value)
|
|
3936
|
+
print("\n" + "=" * 80)
|
|
3937
|
+
print("TEST: Wrong field value")
|
|
3938
|
+
print("=" * 80)
|
|
3939
|
+
print(error_msg)
|
|
3940
|
+
print("=" * 80)
|
|
3941
|
+
|
|
3942
|
+
assert "role" in error_msg or "UNEXPECTED" in error_msg
|
|
3943
|
+
|
|
3944
|
+
def test_comprehensive_all_errors(self):
|
|
3945
|
+
"""
|
|
3946
|
+
Comprehensive test with all 6 error types:
|
|
3947
|
+
- 3 correct (should pass)
|
|
3948
|
+
- 3 wrong field values (should fail)
|
|
3949
|
+
- 3 missing changes (should fail)
|
|
3950
|
+
"""
|
|
3951
|
+
diff = {
|
|
3952
|
+
"issues": {
|
|
3953
|
+
"table_name": "issues",
|
|
3954
|
+
"primary_key": ["id"],
|
|
3955
|
+
"added_rows": [
|
|
3956
|
+
# CORRECT insert
|
|
3957
|
+
{"row_id": 100, "data": {"id": 100, "title": "Correct", "status": "open"}},
|
|
3958
|
+
# WRONG FIELD insert - status is 'closed' not 'open'
|
|
3959
|
+
{"row_id": 101, "data": {"id": 101, "title": "Wrong", "status": "closed"}},
|
|
3960
|
+
# Row 102 NOT added (missing insert)
|
|
3961
|
+
],
|
|
3962
|
+
"removed_rows": [
|
|
3963
|
+
# CORRECT delete
|
|
3964
|
+
{"row_id": 200, "data": {"id": 200, "title": "Deleted"}},
|
|
3965
|
+
# UNEXPECTED delete - no spec for this
|
|
3966
|
+
{"row_id": 201, "data": {"id": 201, "title": "Unexpected delete"}},
|
|
3967
|
+
# Row 202 NOT deleted (missing delete)
|
|
3968
|
+
],
|
|
3969
|
+
"modified_rows": [
|
|
3970
|
+
# CORRECT modify
|
|
3971
|
+
{"row_id": 300, "changes": {"status": {"before": "open", "after": "closed"}},
|
|
3972
|
+
"data": {"id": 300, "status": "closed"}},
|
|
3973
|
+
# WRONG FIELD modify - status is 'closed' not 'resolved'
|
|
3974
|
+
{"row_id": 301, "changes": {"status": {"before": "open", "after": "closed"}},
|
|
3975
|
+
"data": {"id": 301, "status": "closed"}},
|
|
3976
|
+
# Row 302 NOT modified (missing modify)
|
|
3977
|
+
],
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
expected_changes = [
|
|
3982
|
+
# CORRECT insert
|
|
3983
|
+
{"table": "issues", "pk": 100, "type": "insert",
|
|
3984
|
+
"fields": [("id", 100), ("title", "Correct"), ("status", "open")]},
|
|
3985
|
+
# WRONG FIELD insert - expects 'open' but got 'closed'
|
|
3986
|
+
{"table": "issues", "pk": 101, "type": "insert",
|
|
3987
|
+
"fields": [("id", 101), ("title", "Wrong"), ("status", "open")]},
|
|
3988
|
+
# MISSING insert
|
|
3989
|
+
{"table": "issues", "pk": 102, "type": "insert",
|
|
3990
|
+
"fields": [("id", 102), ("title", "Missing"), ("status", "new")]},
|
|
3991
|
+
|
|
3992
|
+
# CORRECT delete
|
|
3993
|
+
{"table": "issues", "pk": 200, "type": "delete"},
|
|
3994
|
+
# No spec for 201 - it's unexpected
|
|
3995
|
+
# MISSING delete
|
|
3996
|
+
{"table": "issues", "pk": 202, "type": "delete"},
|
|
3997
|
+
|
|
3998
|
+
# CORRECT modify
|
|
3999
|
+
{"table": "issues", "pk": 300, "type": "modify",
|
|
4000
|
+
"resulting_fields": [("status", "closed")], "no_other_changes": True},
|
|
4001
|
+
# WRONG FIELD modify - expects 'resolved' but got 'closed'
|
|
4002
|
+
{"table": "issues", "pk": 301, "type": "modify",
|
|
4003
|
+
"resulting_fields": [("status", "resolved")], "no_other_changes": True},
|
|
4004
|
+
# MISSING modify
|
|
4005
|
+
{"table": "issues", "pk": 302, "type": "modify",
|
|
4006
|
+
"resulting_fields": [("status", "done")], "no_other_changes": True},
|
|
4007
|
+
]
|
|
4008
|
+
|
|
4009
|
+
mock = MockSnapshotDiff(diff)
|
|
4010
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
4011
|
+
mock.expect_exactly(expected_changes)
|
|
4012
|
+
|
|
4013
|
+
error_msg = str(exc_info.value)
|
|
4014
|
+
print("\n" + "=" * 80)
|
|
4015
|
+
print("TEST: Comprehensive - All Error Types")
|
|
4016
|
+
print("=" * 80)
|
|
4017
|
+
print(error_msg)
|
|
4018
|
+
print("=" * 80)
|
|
4019
|
+
|
|
4020
|
+
# Should detect MISSING changes
|
|
4021
|
+
assert "MISSING" in error_msg, "Should report missing changes"
|
|
4022
|
+
assert "102" in error_msg, "Should mention missing insert pk=102"
|
|
4023
|
+
assert "202" in error_msg, "Should mention missing delete pk=202"
|
|
4024
|
+
assert "302" in error_msg, "Should mention missing modify pk=302"
|
|
4025
|
+
|
|
4026
|
+
# Should detect UNEXPECTED changes
|
|
4027
|
+
assert "201" in error_msg, "Should mention unexpected delete pk=201"
|
|
4028
|
+
|
|
4029
|
+
# Should detect WRONG FIELD values
|
|
4030
|
+
assert "101" in error_msg, "Should mention wrong field insert pk=101"
|
|
4031
|
+
assert "301" in error_msg, "Should mention wrong field modify pk=301"
|
|
4032
|
+
|
|
4033
|
+
print("\n--- Error Categories Detected ---")
|
|
4034
|
+
print(f"Missing changes (102, 202, 302): {'102' in error_msg and '202' in error_msg and '302' in error_msg}")
|
|
4035
|
+
print(f"Unexpected change (201): {'201' in error_msg}")
|
|
4036
|
+
print(f"Wrong field values (101, 301): {'101' in error_msg and '301' in error_msg}")
|
|
4037
|
+
|
|
4038
|
+
def test_empty_diff_empty_spec_passes(self):
|
|
4039
|
+
"""Empty diff with empty specs should pass."""
|
|
4040
|
+
diff = {
|
|
4041
|
+
"users": {
|
|
4042
|
+
"table_name": "users",
|
|
4043
|
+
"primary_key": ["id"],
|
|
4044
|
+
"added_rows": [],
|
|
4045
|
+
"removed_rows": [],
|
|
4046
|
+
"modified_rows": [],
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
expected_changes = []
|
|
4050
|
+
|
|
4051
|
+
mock = MockSnapshotDiff(diff)
|
|
4052
|
+
result = mock.expect_exactly(expected_changes)
|
|
4053
|
+
assert result is mock
|
|
4054
|
+
|
|
4055
|
+
print("\n" + "=" * 80)
|
|
4056
|
+
print("TEST: Empty diff, empty spec - PASSED")
|
|
4057
|
+
print("=" * 80)
|
|
4058
|
+
|
|
4059
|
+
def test_no_other_changes_required(self):
|
|
4060
|
+
"""Modify specs with resulting_fields must have no_other_changes."""
|
|
4061
|
+
diff = {
|
|
4062
|
+
"issues": {
|
|
4063
|
+
"table_name": "issues",
|
|
4064
|
+
"primary_key": ["id"],
|
|
4065
|
+
"added_rows": [],
|
|
4066
|
+
"removed_rows": [],
|
|
4067
|
+
"modified_rows": [
|
|
4068
|
+
{
|
|
4069
|
+
"row_id": 100,
|
|
4070
|
+
"changes": {"status": {"before": "open", "after": "closed"}},
|
|
4071
|
+
"data": {"id": 100, "status": "closed"},
|
|
4072
|
+
}
|
|
4073
|
+
],
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
# Missing no_other_changes should raise ValueError
|
|
4078
|
+
expected_changes = [
|
|
4079
|
+
{
|
|
4080
|
+
"table": "issues",
|
|
4081
|
+
"pk": 100,
|
|
4082
|
+
"type": "modify",
|
|
4083
|
+
"resulting_fields": [("status", "closed")],
|
|
4084
|
+
# no_other_changes is MISSING
|
|
4085
|
+
}
|
|
4086
|
+
]
|
|
4087
|
+
|
|
4088
|
+
mock = MockSnapshotDiff(diff)
|
|
4089
|
+
with pytest.raises(ValueError) as exc_info:
|
|
4090
|
+
mock.expect_exactly(expected_changes)
|
|
4091
|
+
|
|
4092
|
+
error_msg = str(exc_info.value)
|
|
4093
|
+
assert "no_other_changes" in error_msg
|
|
4094
|
+
assert "missing required" in error_msg.lower()
|
|
4095
|
+
|
|
4096
|
+
print("\n" + "=" * 80)
|
|
4097
|
+
print("TEST: no_other_changes required - ValueError raised correctly")
|
|
4098
|
+
print(f"Error: {error_msg}")
|
|
4099
|
+
print("=" * 80)
|
|
4100
|
+
|
|
4101
|
+
def test_no_other_changes_must_be_boolean(self):
|
|
4102
|
+
"""no_other_changes must be a boolean, not a string or other type."""
|
|
4103
|
+
diff = {
|
|
4104
|
+
"issues": {
|
|
4105
|
+
"table_name": "issues",
|
|
4106
|
+
"primary_key": ["id"],
|
|
4107
|
+
"added_rows": [],
|
|
4108
|
+
"removed_rows": [],
|
|
4109
|
+
"modified_rows": [
|
|
4110
|
+
{
|
|
4111
|
+
"row_id": 100,
|
|
4112
|
+
"changes": {"status": {"before": "open", "after": "closed"}},
|
|
4113
|
+
"data": {"id": 100, "status": "closed"},
|
|
4114
|
+
}
|
|
4115
|
+
],
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
# no_other_changes as string should raise ValueError
|
|
4120
|
+
expected_changes = [
|
|
4121
|
+
{
|
|
4122
|
+
"table": "issues",
|
|
4123
|
+
"pk": 100,
|
|
4124
|
+
"type": "modify",
|
|
4125
|
+
"resulting_fields": [("status", "closed")],
|
|
4126
|
+
"no_other_changes": "True", # Wrong type - should be bool
|
|
4127
|
+
}
|
|
4128
|
+
]
|
|
4129
|
+
|
|
4130
|
+
mock = MockSnapshotDiff(diff)
|
|
4131
|
+
with pytest.raises(ValueError) as exc_info:
|
|
4132
|
+
mock.expect_exactly(expected_changes)
|
|
4133
|
+
|
|
4134
|
+
error_msg = str(exc_info.value)
|
|
4135
|
+
assert "boolean" in error_msg.lower()
|
|
4136
|
+
|
|
4137
|
+
print("\n" + "=" * 80)
|
|
4138
|
+
print("TEST: no_other_changes must be boolean - ValueError raised correctly")
|
|
4139
|
+
print(f"Error: {error_msg}")
|
|
4140
|
+
print("=" * 80)
|
|
4141
|
+
|
|
4142
|
+
|
|
4143
|
+
# ============================================================================
|
|
4144
|
+
# Run tests directly
|
|
4145
|
+
# ============================================================================
|
|
4146
|
+
|
|
4147
|
+
if __name__ == "__main__":
|
|
4148
|
+
pytest.main([__file__, "-v", "-s"])
|