From f7a72fe01b60ee858eee58fe562f755b246be636 Mon Sep 17 00:00:00 2001 From: samfleet-ai <283753584+samfleet-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:30:18 +0000 Subject: [PATCH] fix(verifier): expect_only_v2 enforces completeness, not just no-side-effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expect_only_v2 historically only enforced the no-side-effects half of its contract: an empty diff (agent made zero changes) vacuously satisfied "only expected changes occurred" because the empty set is a subset of any set. This let weight-0.5 ec_diff / validate_expected_changes partials award full credit for total inaction — the perverse incentive where doing nothing scores higher than partial-but-imperfect completion (canonical bug cac765f5, Linear ENVT-140646). Add require_completeness=True (default) to expect_only_v2 on both the sync (SyncSnapshotDiff) and async (AsyncSnapshotDiff) SQLite diff engines, plus the local SnapshotDiff used by tests. When enabled, every spec carrying an explicit type (insert/modify/delete) must also be realised in the current DB — mirroring the completeness half of expect_exactly. Specs without an explicit type (legacy whole-row / single-field specs) are skipped so only the v2 format gets the stricter behaviour. Pass require_completeness=False to preserve the legacy no-side-effects-only semantics. Fixes ENVT-140646. --- fleet/_async/resources/sqlite.py | 150 +++++++++++++++++++- fleet/resources/sqlite.py | 163 +++++++++++++++++++++- fleet/verifiers/db.py | 126 ++++++++++++++++- tests/test_expect_only.py | 227 +++++++++++++++++++++++++++++++ 4 files changed, 656 insertions(+), 10 deletions(-) diff --git a/fleet/_async/resources/sqlite.py b/fleet/_async/resources/sqlite.py index c2719c9a..affba3c8 100644 --- a/fleet/_async/resources/sqlite.py +++ b/fleet/_async/resources/sqlite.py @@ -1753,11 +1753,25 @@ async def expect_only(self, allowed_changes: List[Dict[str, Any]]): diff = await self._collect() return await self._validate_diff_against_allowed_changes(diff, allowed_changes) - async def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): + async def expect_only_v2( + self, + allowed_changes: List[Dict[str, Any]], + require_completeness: bool = True, + ): """Ensure only specified changes occurred, with field-level spec support. This version supports field-level specifications for added/removed rows, allowing users to specify expected field values instead of just whole-row specs. + + When ``require_completeness`` is True (the default), every spec that carries + an explicit ``type`` (``insert``/``modify``/``delete``) must also be realised + in the current database — an empty diff no longer vacuously passes a non-empty + expected-changes list. This closes the "0.5 credit for zero changes" gap where + an agent that does nothing satisfies the no-side-effects half of the contract + while skipping the completeness half. Set ``require_completeness=False`` to + preserve the legacy no-side-effects-only behaviour. Specs without an explicit + ``type`` (legacy whole-row / single-field specs) are not subject to the + completeness pass. """ # Normalize pk values: convert lists to tuples for hashability and consistency for change in allowed_changes: @@ -1796,17 +1810,145 @@ async def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): # Validate outside try block so AssertionError propagates if api_diff is not None: - return await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes) + await self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes) + if require_completeness: + await self._verify_completeness_v2(allowed_changes) + return self # For expect_only_v2, we can optimize by only checking the specific rows mentioned if self._can_use_targeted_queries(allowed_changes): - return await self._expect_only_targeted_v2(allowed_changes) + await self._expect_only_targeted_v2(allowed_changes) + if require_completeness: + await self._verify_completeness_v2(allowed_changes) + return self # Fall back to full diff for complex cases diff = await self._collect() - return await self._validate_diff_against_allowed_changes_v2( + await self._validate_diff_against_allowed_changes_v2( diff, allowed_changes ) + if require_completeness: + await self._verify_completeness_v2(allowed_changes) + return self + + async def _verify_completeness_v2(self, allowed_changes: List[Dict[str, Any]]): + """Verify every spec with an explicit ``type`` was realised in the current DB. + + ``expect_only_v2``'s no-side-effects check passes vacuously when the diff is + empty (the agent made zero changes). This completeness pass closes that gap by + requiring that each expected ``insert``/``modify``/``delete`` actually + occurred, mirroring the completeness half of ``expect_exactly``. Specs without + an explicit ``type`` (legacy whole-row / single-field specs) are skipped so the + stricter behaviour only applies to the v2 spec format. + + Raises ``AssertionError`` listing every missing or mismatched expected change. + """ + import asyncio + + missing: List[str] = [] + + specs_by_table: Dict[str, List[Dict[str, Any]]] = {} + for spec in allowed_changes: + if spec.get("type") is None: + continue + table = spec.get("table") + if table is None or self.ignore_config.should_ignore_table(table): + continue + specs_by_table.setdefault(table, []).append(spec) + + if not specs_by_table: + return self + + async def check_spec(table: str, spec: Dict[str, Any], pk_columns: List[str]) -> None: + try: + pk = spec.get("pk") + where_sql = self._build_pk_where_clause(pk_columns, pk) + select_sql = f"SELECT * FROM {_quote_identifier(table)} WHERE {where_sql}" + after_response = await self.after.resource.query(select_sql) + after_row = ( + dict(zip(after_response.columns, after_response.rows[0])) + if after_response.rows + else None + ) + spec_type = spec.get("type") + pk_label = repr(pk) + + if spec_type == "insert": + if after_row is None: + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + return + fields_spec = spec.get("fields") + if fields_spec is not None: + for field_name, expected_value in fields_spec: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + return + + if spec_type == "delete": + if after_row is not None: + missing.append( + f"Expected delete in table '{table}' pk={pk_label} " + f"did not occur (row still present in current DB)" + ) + return + + if spec_type == "modify": + if after_row is None: + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + return + resulting_fields = spec.get("resulting_fields") + if resulting_fields is None: + return + for field_name, expected_value in resulting_fields: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + return + + except Exception as e: + missing.append( + f"Completeness check error for table '{table}' " + f"pk={spec.get('pk')}: {e}" + ) + + coros = [] + for table, table_specs in specs_by_table.items(): + pk_columns = self._get_primary_key_columns(table) + for spec in table_specs: + coros.append(check_spec(table, spec, pk_columns)) + + await asyncio.gather(*coros) + + if missing: + raise AssertionError( + "expect_only_v2 completeness check failed — expected changes " + "did not occur:\n" + "\n".join(f" - {m}" for m in missing) + ) + + return self async def expect_exactly(self, expected_changes: List[Dict[str, Any]]): """Verify that EXACTLY the specified changes occurred. diff --git a/fleet/resources/sqlite.py b/fleet/resources/sqlite.py index a2b4d99f..51febe42 100644 --- a/fleet/resources/sqlite.py +++ b/fleet/resources/sqlite.py @@ -1801,11 +1801,25 @@ def expect_only(self, allowed_changes: List[Dict[str, Any]]): diff = self._collect() return self._validate_diff_against_allowed_changes(diff, allowed_changes) - def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): + def expect_only_v2( + self, + allowed_changes: List[Dict[str, Any]], + require_completeness: bool = True, + ): """Ensure only specified changes occurred, with field-level spec support. This version supports field-level specifications for added/removed rows, allowing users to specify expected field values instead of just whole-row specs. + + When ``require_completeness`` is True (the default), every spec that carries + an explicit ``type`` (``insert``/``modify``/``delete``) must also be realised + in the current database — an empty diff no longer vacuously passes a non-empty + expected-changes list. This closes the "0.5 credit for zero changes" gap where + an agent that does nothing satisfies the no-side-effects half of the contract + while skipping the completeness half. Set ``require_completeness=False`` to + preserve the legacy no-side-effects-only behaviour. Specs without an explicit + ``type`` (legacy whole-row / single-field specs) are not subject to the + completeness pass. """ # Normalize pk values: convert lists to tuples for hashability and consistency for change in allowed_changes: @@ -1844,15 +1858,156 @@ def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): # Validate outside try block so AssertionError propagates if api_diff is not None: - return self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes) + self._validate_diff_against_allowed_changes_v2(api_diff, allowed_changes) + if require_completeness: + self._verify_completeness_v2(allowed_changes) + return self # For expect_only_v2, we can optimize by only checking the specific rows mentioned if self._can_use_targeted_queries(allowed_changes): - return self._expect_only_targeted_v2(allowed_changes) + self._expect_only_targeted_v2(allowed_changes) + if require_completeness: + self._verify_completeness_v2(allowed_changes) + return self # Fall back to full diff for complex cases diff = self._collect() - return self._validate_diff_against_allowed_changes_v2(diff, allowed_changes) + self._validate_diff_against_allowed_changes_v2(diff, allowed_changes) + if require_completeness: + self._verify_completeness_v2(allowed_changes) + return self + + def _verify_completeness_v2(self, allowed_changes: List[Dict[str, Any]]): + """Verify every spec with an explicit ``type`` was realised in the current DB. + + ``expect_only_v2``'s no-side-effects check passes vacuously when the diff is + empty (the agent made zero changes). This completeness pass closes that gap by + requiring that each expected ``insert``/``modify``/``delete`` actually + occurred, mirroring the completeness half of ``expect_exactly``. Specs without + an explicit ``type`` (legacy whole-row / single-field specs) are skipped so the + stricter behaviour only applies to the v2 spec format. + + Raises ``AssertionError`` listing every missing or mismatched expected change. + """ + import concurrent.futures + from threading import Lock + + missing: List[str] = [] + missing_lock = Lock() + + specs_by_table: Dict[str, List[Dict[str, Any]]] = {} + for spec in allowed_changes: + if spec.get("type") is None: + continue + table = spec.get("table") + if table is None or self.ignore_config.should_ignore_table(table): + continue + specs_by_table.setdefault(table, []).append(spec) + + if not specs_by_table: + return self + + def check_spec(table: str, spec: Dict[str, Any], pk_columns: List[str]): + try: + pk = spec.get("pk") + where_sql = self._build_pk_where_clause(pk_columns, pk) + select_sql = f"SELECT * FROM {_quote_identifier(table)} WHERE {where_sql}" + after_response = self.after.resource.query(select_sql) + after_row = ( + dict(zip(after_response.columns, after_response.rows[0])) + if after_response.rows + else None + ) + spec_type = spec.get("type") + pk_label = repr(pk) + + if spec_type == "insert": + if after_row is None: + with missing_lock: + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + return + fields_spec = spec.get("fields") + if fields_spec is not None: + for field_name, expected_value in fields_spec: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + with missing_lock: + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + return + + if spec_type == "delete": + if after_row is not None: + with missing_lock: + missing.append( + f"Expected delete in table '{table}' pk={pk_label} " + f"did not occur (row still present in current DB)" + ) + return + + if spec_type == "modify": + if after_row is None: + with missing_lock: + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + return + resulting_fields = spec.get("resulting_fields") + if resulting_fields is None: + return + for field_name, expected_value in resulting_fields: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + with missing_lock: + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + return + + except Exception as e: + with missing_lock: + missing.append( + f"Completeness check error for table '{table}' " + f"pk={spec.get('pk')}: {e}" + ) + + checks: List[Tuple[str, Dict[str, Any], List[str]]] = [] + for table, table_specs in specs_by_table.items(): + pk_columns = self._get_primary_key_columns(table) + for spec in table_specs: + checks.append((table, spec, pk_columns)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit(check_spec, table, spec, pk_columns) + for table, spec, pk_columns in checks + ] + concurrent.futures.wait(futures) + + if missing: + raise AssertionError( + "expect_only_v2 completeness check failed — expected changes " + "did not occur:\n" + "\n".join(f" - {m}" for m in missing) + ) + + return self def expect_exactly(self, expected_changes: List[Dict[str, Any]]): """Verify that EXACTLY the specified changes occurred. diff --git a/fleet/verifiers/db.py b/fleet/verifiers/db.py index 85f7a43b..e363cd17 100644 --- a/fleet/verifiers/db.py +++ b/fleet/verifiers/db.py @@ -1438,7 +1438,11 @@ def _is_change_allowed( return self # ------------------------------------------------------------------ - def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): + def expect_only_v2( + self, + allowed_changes: List[Dict[str, Any]], + require_completeness: bool = True, + ): """Allowed changes with bulk field spec support and explicit type field. This version supports explicit change types via the "type" field: @@ -1464,6 +1468,15 @@ def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): When using "fields" for inserts, every field must be accounted for in the list. For modifications, use "resulting_fields" with explicit "no_other_changes". For deletions with "fields", all specified fields are validated against the deleted row. + + When ``require_completeness`` is True (the default), every spec that carries an + explicit ``type`` (``insert``/``modify``/``delete``) must also be realised in the + current database — an empty diff no longer vacuously passes a non-empty + expected-changes list. This closes the "0.5 credit for zero changes" gap where an + agent that does nothing satisfies the no-side-effects half while skipping the + completeness half. Set ``require_completeness=False`` to preserve the legacy + no-side-effects-only behaviour. Specs without an explicit ``type`` (legacy + whole-row / single-field specs) are not subject to the completeness pass. """ # Normalize pk values for change in allowed_changes: @@ -1487,7 +1500,10 @@ def expect_only_v2(self, allowed_changes: List[Dict[str, Any]]): # Use targeted queries when possible (matches production behavior) if self._can_use_targeted_queries(allowed_changes): - return self._expect_only_targeted_v2(allowed_changes) + self._expect_only_targeted_v2(allowed_changes) + if require_completeness: + self._verify_completeness_v2(allowed_changes) + return self # Fall back to full diff for complex cases diff = self._collect() @@ -1961,6 +1977,112 @@ def _validate_modification_with_fields_spec( raise AssertionError("\n".join(error_lines)) + if require_completeness: + self._verify_completeness_v2(allowed_changes) + return self + + def _verify_completeness_v2(self, allowed_changes: List[Dict[str, Any]]): + """Verify every spec with an explicit ``type`` was realised in the current DB. + + ``expect_only_v2``'s no-side-effects check passes vacuously when the diff is + empty (the agent made zero changes). This completeness pass closes that gap by + requiring that each expected ``insert``/``modify``/``delete`` actually + occurred, mirroring the completeness half of ``expect_exactly``. Specs without + an explicit ``type`` (legacy whole-row / single-field specs) are skipped so the + stricter behaviour only applies to the v2 spec format. + + Raises ``AssertionError`` listing every missing or mismatched expected change. + """ + missing: List[str] = [] + + specs_by_table: Dict[str, List[Dict[str, Any]]] = {} + for spec in allowed_changes: + if spec.get("type") is None: + continue + table = spec.get("table") + if table is None or self.ignore_config.should_ignore_table(table): + continue + specs_by_table.setdefault(table, []).append(spec) + + if not specs_by_table: + return self + + for table, table_specs in specs_by_table.items(): + pk_columns = self._get_pk_columns(table) + for spec in table_specs: + pk = spec.get("pk") + pk_label = repr(pk) + spec_type = spec.get("type") + try: + after_row = self._query_row( + self.after.db_path, table, pk_columns, pk + ) + except Exception as e: + missing.append( + f"Completeness check error for table '{table}' pk={pk_label}: {e}" + ) + continue + + if spec_type == "insert": + if after_row is None: + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + continue + fields_spec = spec.get("fields") + if fields_spec is not None: + for field_name, expected_value in fields_spec: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + missing.append( + f"Expected insert in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + continue + + if spec_type == "delete": + if after_row is not None: + missing.append( + f"Expected delete in table '{table}' pk={pk_label} " + f"did not occur (row still present in current DB)" + ) + continue + + if spec_type == "modify": + if after_row is None: + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"did not occur (row absent in current DB)" + ) + continue + resulting_fields = spec.get("resulting_fields") + if resulting_fields is None: + continue + for field_name, expected_value in resulting_fields: + if expected_value is ...: + continue + if self.ignore_config.should_ignore_field(table, field_name): + continue + actual = after_row.get(field_name) + if not _values_equivalent(expected_value, actual): + missing.append( + f"Expected modify in table '{table}' pk={pk_label} " + f"field '{field_name}': expected {repr(expected_value)}, " + f"got {repr(actual)}" + ) + + if missing: + raise AssertionError( + "expect_only_v2 completeness check failed — expected changes " + "did not occur:\n" + "\n".join(f" - {m}" for m in missing) + ) + return self def expect_exactly(self, expected_changes: List[Dict[str, Any]]): diff --git a/tests/test_expect_only.py b/tests/test_expect_only.py index 96362e25..37c3c97c 100644 --- a/tests/test_expect_only.py +++ b/tests/test_expect_only.py @@ -2591,3 +2591,230 @@ def test_targeted_row_exists_both_sides_no_change(): finally: os.unlink(before_db) os.unlink(after_db) + + +# ============================================================================ +# Tests for expect_only_v2 completeness enforcement (bug cac765f5) +# ============================================================================ +# expect_only_v2 historically only enforced the no-side-effects half of its +# contract: an empty diff (agent made zero changes) vacuously satisfied +# "only expected changes occurred" because the empty set is a subset of any +# set. With require_completeness=True (the new default), every spec that +# carries an explicit type must also be realised in the current DB. + + +def _write_identical_dbs(schema_sql, seed_rows): + """Create before/after DBs with identical contents (zero changes).""" + before_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name + after_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name + for path in (before_db, after_db): + conn = sqlite3.connect(path) + conn.execute(schema_sql) + for row in seed_rows: + conn.execute(row) + conn.commit() + conn.close() + return before_db, after_db + + +def test_expect_only_v2_completeness_zero_changes_modify_fails(): + """Zero DB changes + non-empty modify spec must fail (the reported bug).""" + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT, priority TEXT)", + ["INSERT INTO issues VALUES (1, NULL, 'Medium')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + with pytest.raises(AssertionError) as excinfo: + before.diff(after).expect_only_v2( + [ + { + "table": "issues", + "pk": 1, + "type": "modify", + "resulting_fields": [("owner", "ob-1"), ("priority", "High")], + "no_other_changes": True, + }, + ] + ) + assert "completeness check failed" in str(excinfo.value) + assert "priority" in str(excinfo.value) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_completeness_zero_changes_insert_fails(): + """Zero DB changes + insert spec must fail (expected insert did not occur).""" + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, name TEXT)", + ["INSERT INTO issues VALUES (1, 'existing')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + with pytest.raises(AssertionError) as excinfo: + before.diff(after).expect_only_v2( + [ + { + "table": "issues", + "pk": 2, + "type": "insert", + "fields": [("id", 2), ("name", "new")], + }, + ] + ) + assert "Expected insert" in str(excinfo.value) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_completeness_zero_changes_delete_fails(): + """Zero DB changes + delete spec must fail (row still present).""" + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, name TEXT)", + ["INSERT INTO issues VALUES (1, 'stale')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + with pytest.raises(AssertionError) as excinfo: + before.diff(after).expect_only_v2( + [{"table": "issues", "pk": 1, "type": "delete"}] + ) + assert "Expected delete" in str(excinfo.value) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_completeness_seed_already_has_expected_values_passes(): + """If the seed already has the expected resulting state, completeness passes. + + The resulting state the spec describes is present in the current DB, so the + expected change is satisfied even though no row-level change occurred. + """ + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT, priority TEXT)", + ["INSERT INTO issues VALUES (1, 'ob-1', 'High')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + # No AssertionError: expected resulting_fields are present in current DB. + before.diff(after).expect_only_v2( + [ + { + "table": "issues", + "pk": 1, + "type": "modify", + "resulting_fields": [("owner", "ob-1"), ("priority", "High")], + "no_other_changes": True, + }, + ] + ) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_require_completeness_false_preserves_vacuous_pass(): + """Opting out of completeness preserves the legacy vacuous-pass behaviour.""" + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT, priority TEXT)", + ["INSERT INTO issues VALUES (1, NULL, 'Medium')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + # No AssertionError: legacy no-side-effects-only behaviour. + before.diff(after).expect_only_v2( + [ + { + "table": "issues", + "pk": 1, + "type": "modify", + "resulting_fields": [("owner", "ob-1"), ("priority", "High")], + "no_other_changes": True, + }, + ], + require_completeness=False, + ) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_completeness_partial_changes_fails(): + """Making only some expected changes must fail for the missing ones.""" + before_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name + after_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name + try: + conn = sqlite3.connect(before_db) + conn.execute( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT, priority TEXT)" + ) + conn.execute("INSERT INTO issues VALUES (1, NULL, 'Medium')") + conn.execute("INSERT INTO issues VALUES (2, NULL, 'Medium')") + conn.commit() + conn.close() + + # After: only issue 1 was changed; issue 2 left unchanged. + conn = sqlite3.connect(after_db) + conn.execute( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT, priority TEXT)" + ) + conn.execute("INSERT INTO issues VALUES (1, 'ob-1', 'High')") + conn.execute("INSERT INTO issues VALUES (2, NULL, 'Medium')") + conn.commit() + conn.close() + + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + with pytest.raises(AssertionError) as excinfo: + before.diff(after).expect_only_v2( + [ + { + "table": "issues", + "pk": 1, + "type": "modify", + "resulting_fields": [("owner", "ob-1"), ("priority", "High")], + "no_other_changes": True, + }, + { + "table": "issues", + "pk": 2, + "type": "modify", + "resulting_fields": [("owner", "rb-2"), ("priority", "High")], + "no_other_changes": True, + }, + ] + ) + assert "completeness check failed" in str(excinfo.value) + # The missing change (issue 2) must be reported; the satisfied one (issue 1) not. + assert "pk=2" in str(excinfo.value) + assert "pk=1" not in str(excinfo.value) + finally: + os.unlink(before_db) + os.unlink(after_db) + + +def test_expect_only_v2_completeness_legacy_specs_skipped(): + """Legacy specs without an explicit type are not subject to completeness.""" + before_db, after_db = _write_identical_dbs( + "CREATE TABLE issues (id INTEGER PRIMARY KEY, owner TEXT)", + ["INSERT INTO issues VALUES (1, 'alice')"], + ) + try: + before = DatabaseSnapshot(before_db) + after = DatabaseSnapshot(after_db) + # Legacy whole-row spec (no "type") — completeness pass is skipped, so + # the no-side-effects check alone governs and passes on an empty diff. + before.diff(after).expect_only_v2( + [{"table": "issues", "pk": 1, "fields": None, "after": "__added__"}] + ) + finally: + os.unlink(before_db) + os.unlink(after_db)