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