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.
Files changed (70) hide show
  1. examples/export_tasks.py +16 -5
  2. examples/export_tasks_filtered.py +245 -0
  3. examples/fetch_tasks.py +230 -0
  4. examples/import_tasks.py +140 -8
  5. examples/iterate_verifiers.py +725 -0
  6. fleet/__init__.py +128 -5
  7. fleet/_async/__init__.py +27 -3
  8. fleet/_async/base.py +24 -9
  9. fleet/_async/client.py +938 -41
  10. fleet/_async/env/client.py +60 -3
  11. fleet/_async/instance/client.py +52 -7
  12. fleet/_async/models.py +15 -0
  13. fleet/_async/resources/api.py +200 -0
  14. fleet/_async/resources/sqlite.py +1801 -46
  15. fleet/_async/tasks.py +122 -25
  16. fleet/_async/verifiers/bundler.py +22 -21
  17. fleet/_async/verifiers/verifier.py +25 -19
  18. fleet/agent/__init__.py +32 -0
  19. fleet/agent/gemini_cua/Dockerfile +45 -0
  20. fleet/agent/gemini_cua/__init__.py +10 -0
  21. fleet/agent/gemini_cua/agent.py +759 -0
  22. fleet/agent/gemini_cua/mcp/main.py +108 -0
  23. fleet/agent/gemini_cua/mcp_server/__init__.py +5 -0
  24. fleet/agent/gemini_cua/mcp_server/main.py +105 -0
  25. fleet/agent/gemini_cua/mcp_server/tools.py +178 -0
  26. fleet/agent/gemini_cua/requirements.txt +5 -0
  27. fleet/agent/gemini_cua/start.sh +30 -0
  28. fleet/agent/orchestrator.py +854 -0
  29. fleet/agent/types.py +49 -0
  30. fleet/agent/utils.py +34 -0
  31. fleet/base.py +34 -9
  32. fleet/cli.py +1061 -0
  33. fleet/client.py +1060 -48
  34. fleet/config.py +1 -1
  35. fleet/env/__init__.py +16 -0
  36. fleet/env/client.py +60 -3
  37. fleet/eval/__init__.py +15 -0
  38. fleet/eval/uploader.py +231 -0
  39. fleet/exceptions.py +8 -0
  40. fleet/instance/client.py +53 -8
  41. fleet/instance/models.py +1 -0
  42. fleet/models.py +303 -0
  43. fleet/proxy/__init__.py +25 -0
  44. fleet/proxy/proxy.py +453 -0
  45. fleet/proxy/whitelist.py +244 -0
  46. fleet/resources/api.py +200 -0
  47. fleet/resources/sqlite.py +1845 -46
  48. fleet/tasks.py +113 -20
  49. fleet/utils/__init__.py +7 -0
  50. fleet/utils/http_logging.py +178 -0
  51. fleet/utils/logging.py +13 -0
  52. fleet/utils/playwright.py +440 -0
  53. fleet/verifiers/bundler.py +22 -21
  54. fleet/verifiers/db.py +985 -1
  55. fleet/verifiers/decorator.py +1 -1
  56. fleet/verifiers/verifier.py +25 -19
  57. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/METADATA +28 -1
  58. fleet_python-0.2.105.dist-info/RECORD +115 -0
  59. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/WHEEL +1 -1
  60. fleet_python-0.2.105.dist-info/entry_points.txt +2 -0
  61. tests/test_app_method.py +85 -0
  62. tests/test_expect_exactly.py +4148 -0
  63. tests/test_expect_only.py +2593 -0
  64. tests/test_instance_dispatch.py +607 -0
  65. tests/test_sqlite_resource_dual_mode.py +263 -0
  66. tests/test_sqlite_shared_memory_behavior.py +117 -0
  67. fleet_python-0.2.66b2.dist-info/RECORD +0 -81
  68. tests/test_verifier_security.py +0 -427
  69. {fleet_python-0.2.66b2.dist-info → fleet_python-0.2.105.dist-info}/licenses/LICENSE +0 -0
  70. {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"])