"""Step definitions for database_handler_crud.feature. Tests CRUD and checkpoint operations for DatabaseResourceHandler: - read(), write(), delete(), list_children(), diff() - create_checkpoint(), rollback_to() Issue #1241: DatabaseResourceHandler CRUD and checkpoint methods. """ from __future__ import annotations import os import sqlite3 import tempfile from unittest.mock import MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.domain.models.core.resource import ( PhysVirt, Resource, ResourceCapabilities, ) from cleveragents.resource.handlers.database import DatabaseResourceHandler __all__: list[str] = [] # --------------------------------------------------------------------------- # ULID generation helper # --------------------------------------------------------------------------- _ULID_COUNTER = 0 def _next_ulid() -> str: """Generate a valid 26-char Crockford Base32 ID for tests.""" global _ULID_COUNTER _ULID_COUNTER += 1 cb32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" n = _ULID_COUNTER chars: list[str] = [] for _ in range(26): chars.append(cb32[n % 32]) n //= 32 return "".join(reversed(chars)) def _make_db_resource( resource_type: str, location: str | None, rid: str | None = None, ) -> Resource: """Create a database Resource for testing.""" return Resource( resource_id=rid or _next_ulid(), resource_type_name=resource_type, classification=PhysVirt.PHYSICAL, location=location, capabilities=ResourceCapabilities( readable=True, writable=True, sandboxable=True, checkpointable=True, ), ) def _create_sqlite_with_table(table_name: str) -> str: """Create a temp SQLite file with a single table, return path.""" fd, path = tempfile.mkstemp(suffix=".db", prefix="dbhandler_") os.close(fd) conn = sqlite3.connect(path) conn.execute(f"CREATE TABLE {table_name} (id INTEGER PRIMARY KEY, name TEXT)") conn.commit() conn.close() return path def _create_sqlite_with_tables(table1: str, table2: str) -> str: """Create a temp SQLite file with two tables, return path.""" fd, path = tempfile.mkstemp(suffix=".db", prefix="dbhandler_") os.close(fd) conn = sqlite3.connect(path) conn.execute(f"CREATE TABLE {table1} (id INTEGER PRIMARY KEY, name TEXT)") conn.execute(f"CREATE TABLE {table2} (id INTEGER PRIMARY KEY, value TEXT)") conn.commit() conn.close() return path def _create_empty_sqlite() -> str: """Create an empty temp SQLite file, return path.""" fd, path = tempfile.mkstemp(suffix=".db", prefix="dbhandler_") os.close(fd) conn = sqlite3.connect(path) conn.close() return path # --------------------------------------------------------------------------- # Given: database setup # --------------------------------------------------------------------------- @given('a SQLite database with tables "{table1}" and "{table2}"') def step_given_sqlite_two_tables(context: Context, table1: str, table2: str) -> None: context.db_path = _create_sqlite_with_tables(table1, table2) context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("sqlite", context.db_path) context.db_exceptions_raised: list[Exception] = [] @given('a SQLite database with table "{table_name}"') def step_given_sqlite_one_table(context: Context, table_name: str) -> None: context.db_path = _create_sqlite_with_table(table_name) context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("sqlite", context.db_path) context.db_exceptions_raised = [] @given("an empty SQLite database file") def step_given_empty_sqlite(context: Context) -> None: context.db_path = _create_empty_sqlite() context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("sqlite", context.db_path) context.db_exceptions_raised = [] @given('a remote postgres database resource with location "{location}"') def step_given_remote_postgres(context: Context, location: str) -> None: context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("postgres", location) context.db_exceptions_raised = [] @given('a remote mysql database resource with location "{location}"') def step_given_remote_mysql(context: Context, location: str) -> None: context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("mysql", location) context.db_exceptions_raised = [] @given("a database resource with no location") def step_given_no_location(context: Context) -> None: context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("sqlite", None) context.db_exceptions_raised = [] @given("a database resource pointing to a non-existent SQLite path") def step_given_nonexistent_sqlite(context: Context) -> None: context.db_handler = DatabaseResourceHandler() context.db_resource = _make_db_resource("sqlite", "/tmp/nonexistent_db_12345.db") context.db_exceptions_raised = [] @given("a mock sandbox manager") def step_given_mock_sandbox_manager(context: Context) -> None: context.mock_sandbox_manager = MagicMock() @given("a second SQLite database with the same schema") def step_given_second_sqlite_same_schema(context: Context) -> None: conn_src = sqlite3.connect(context.db_path) schema_rows = conn_src.execute( "SELECT name, sql FROM sqlite_master WHERE type='table'" ).fetchall() conn_src.close() fd, path2 = tempfile.mkstemp(suffix=".db", prefix="dbhandler2_") os.close(fd) conn2 = sqlite3.connect(path2) for _, sql in schema_rows: if sql: conn2.execute(sql) conn2.commit() conn2.close() context.second_db_path = path2 @given('a second SQLite database with table "{table_name}"') def step_given_second_sqlite_different(context: Context, table_name: str) -> None: context.second_db_path = _create_sqlite_with_table(table_name) @given('a second remote database location "{location}"') def step_given_second_remote_location(context: Context, location: str) -> None: context.second_db_path = location # --------------------------------------------------------------------------- # When: CRUD operations # --------------------------------------------------------------------------- @when("I read from the database handler") def step_when_read_db(context: Context) -> None: try: context.db_read_result = context.db_handler.read(resource=context.db_resource) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_read_result = None @when('I write SQL "{sql}" to the database handler') def step_when_write_sql(context: Context, sql: str) -> None: try: context.db_write_result = context.db_handler.write( resource=context.db_resource, path="", data=sql.encode("utf-8"), ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_write_result = None @when('I delete table "{table_name}" from the database handler') def step_when_delete_table(context: Context, table_name: str) -> None: try: context.db_delete_result = context.db_handler.delete( resource=context.db_resource, path=table_name, ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_delete_result = None @when("I delete with empty path from the database handler") def step_when_delete_empty(context: Context) -> None: try: context.db_delete_result = context.db_handler.delete( resource=context.db_resource, path="", ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_delete_result = None @when("I list children from the database handler") def step_when_list_children(context: Context) -> None: try: context.db_children_result = context.db_handler.list_children( resource=context.db_resource ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_children_result = [] @when("I diff the database handler against the second database") def step_when_diff_second_db(context: Context) -> None: try: context.db_diff_result = context.db_handler.diff( resource=context.db_resource, other_location=context.second_db_path, ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_diff_result = None @when("I diff the database handler against the second remote location") def step_when_diff_remote(context: Context) -> None: try: context.db_diff_result = context.db_handler.diff( resource=context.db_resource, other_location=context.second_db_path, ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_diff_result = None @when('I create a checkpoint with plan "{plan_id}" on the database handler') def step_when_create_checkpoint(context: Context, plan_id: str) -> None: try: context.db_checkpoint_result = context.db_handler.create_checkpoint( resource=context.db_resource, plan_id=plan_id, sandbox_manager=context.mock_sandbox_manager, ) context.db_last_checkpoint_id = context.db_checkpoint_result.checkpoint_id except Exception as exc: context.db_exceptions_raised.append(exc) context.db_checkpoint_result = None context.db_last_checkpoint_id = None @when('I insert a row into "{table_name}" via the database handler') def step_when_insert_row(context: Context, table_name: str) -> None: sql = f"INSERT INTO {table_name} (id, name) VALUES (42, 'test-row')" context.db_handler.write( resource=context.db_resource, path="", data=sql.encode("utf-8"), ) @when("I rollback to the last checkpoint on the database handler") def step_when_rollback_last(context: Context) -> None: try: context.db_rollback_result = context.db_handler.rollback_to( resource=context.db_resource, plan_id="PLAN-RB-001", checkpoint_id=context.db_last_checkpoint_id, sandbox_manager=context.mock_sandbox_manager, ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_rollback_result = None @when('I rollback to checkpoint "{checkpoint_id}" on the database handler') def step_when_rollback_id(context: Context, checkpoint_id: str) -> None: try: context.db_rollback_result = context.db_handler.rollback_to( resource=context.db_resource, plan_id="PLAN-TEST", checkpoint_id=checkpoint_id, sandbox_manager=context.mock_sandbox_manager, ) except Exception as exc: context.db_exceptions_raised.append(exc) context.db_rollback_result = None # --------------------------------------------------------------------------- # Then: read() assertions # --------------------------------------------------------------------------- @then('the db read content should contain "{text}"') def step_then_read_contains(context: Context, text: str) -> None: assert context.db_read_result is not None, "read() raised an exception" content_text = context.db_read_result.data.decode( context.db_read_result.encoding or "utf-8" ) assert text in content_text, ( f"Expected '{text}' in read content, got: {content_text[:200]}" ) @then('the db read content encoding should be "{encoding}"') def step_then_read_encoding(context: Context, encoding: str) -> None: assert context.db_read_result is not None, "read() raised an exception" assert context.db_read_result.encoding == encoding, ( f"Expected encoding '{encoding}', got '{context.db_read_result.encoding}'" ) @then("the db read content hash should not be empty") def step_then_read_hash_not_empty(context: Context) -> None: assert context.db_read_result is not None, "read() raised an exception" assert context.db_read_result.content_hash is not None, ( "Expected content_hash to be set" ) assert len(context.db_read_result.content_hash) > 0 @then("the db read content data should be empty or whitespace") def step_then_read_data_empty(context: Context) -> None: assert context.db_read_result is not None, "read() raised an exception" text = context.db_read_result.data.decode( context.db_read_result.encoding or "utf-8" ) assert text.strip() == "", f"Expected empty/whitespace content, got: {text[:200]}" @then("no db exceptions should have been raised") def step_then_no_exceptions(context: Context) -> None: assert len(context.db_exceptions_raised) == 0, ( f"Unexpected exceptions: {context.db_exceptions_raised}" ) # --------------------------------------------------------------------------- # Then: write() assertions # --------------------------------------------------------------------------- @then("the db write result should be successful") def step_then_write_success(context: Context) -> None: assert context.db_write_result is not None, "write() raised an exception" assert context.db_write_result.success is True, ( f"Expected write success, got message: {context.db_write_result.message}" ) @then("the db write result should not be successful") def step_then_write_not_success(context: Context) -> None: assert context.db_write_result is not None, "write() raised an exception" assert context.db_write_result.success is False, ( "Expected write failure, but got success" ) @then('the db write result message should mention "{text}"') def step_then_write_message_contains(context: Context, text: str) -> None: assert context.db_write_result is not None, "write() raised an exception" assert text.lower() in context.db_write_result.message.lower(), ( f"Expected '{text}' in write message: {context.db_write_result.message}" ) @then('the SQLite table "{table_name}" should contain {count:d} rows') @then('the SQLite table "{table_name}" should contain {count:d} row') def step_then_sqlite_table_row_count( context: Context, table_name: str, count: int ) -> None: conn = sqlite3.connect(context.db_path) rows = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone() conn.close() assert rows is not None assert rows[0] == count, f"Expected {count} rows, got {rows[0]}" @then('the SQLite database should have table "{table_name}"') def step_then_sqlite_has_table(context: Context, table_name: str) -> None: conn = sqlite3.connect(context.db_path) rows = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,), ).fetchall() conn.close() assert len(rows) == 1, f"Table '{table_name}' not found in database" @then('the SQLite database should not have table "{table_name}"') def step_then_sqlite_no_table(context: Context, table_name: str) -> None: conn = sqlite3.connect(context.db_path) rows = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,), ).fetchall() conn.close() assert len(rows) == 0, f"Table '{table_name}' still exists in database" # --------------------------------------------------------------------------- # Then: delete() assertions # --------------------------------------------------------------------------- @then("the db delete result should be successful") def step_then_delete_success(context: Context) -> None: assert context.db_delete_result is not None, "delete() raised an exception" assert context.db_delete_result.success is True, ( f"Expected delete success, got message: {context.db_delete_result.message}" ) @then("the db delete result should not be successful") def step_then_delete_not_success(context: Context) -> None: assert context.db_delete_result is not None, "delete() raised an exception" assert context.db_delete_result.success is False, ( "Expected delete failure, but got success" ) @then('the db delete result message should mention "{text}"') def step_then_delete_message_contains(context: Context, text: str) -> None: assert context.db_delete_result is not None, "delete() raised an exception" assert text.lower() in context.db_delete_result.message.lower(), ( f"Expected '{text}' in delete message: {context.db_delete_result.message}" ) # --------------------------------------------------------------------------- # Then: list_children() assertions # --------------------------------------------------------------------------- @then('the db children list should contain "{name}"') def step_then_children_contains(context: Context, name: str) -> None: assert name in context.db_children_result, ( f"Expected '{name}' in children: {context.db_children_result}" ) @then("the db children list should be sorted") def step_then_children_sorted(context: Context) -> None: assert context.db_children_result == sorted(context.db_children_result), ( f"Children list is not sorted: {context.db_children_result}" ) @then("the db children list should be empty") def step_then_children_empty(context: Context) -> None: assert context.db_children_result == [], ( f"Expected empty children list, got: {context.db_children_result}" ) # --------------------------------------------------------------------------- # Then: diff() assertions # --------------------------------------------------------------------------- @then("the db diff result should have changes") def step_then_diff_has_changes(context: Context) -> None: assert context.db_diff_result is not None, "diff() raised an exception" assert context.db_diff_result.has_changes is True, ( "Expected diff to have changes, but has_changes is False" ) @then("the db diff result should have no changes") def step_then_diff_no_changes(context: Context) -> None: assert context.db_diff_result is not None, "diff() raised an exception" assert context.db_diff_result.has_changes is False, ( "Expected no diff changes, but has_changes is True" ) # --------------------------------------------------------------------------- # Then: create_checkpoint() assertions # --------------------------------------------------------------------------- @then("the db checkpoint result should have a checkpoint_id") def step_then_checkpoint_has_id(context: Context) -> None: assert context.db_checkpoint_result is not None, ( "create_checkpoint() raised an exception" ) assert context.db_checkpoint_result.checkpoint_id, ( "Expected non-empty checkpoint_id" ) @then('the db checkpoint result plan_id should be "{plan_id}"') def step_then_checkpoint_plan_id(context: Context, plan_id: str) -> None: assert context.db_checkpoint_result is not None assert context.db_checkpoint_result.plan_id == plan_id, ( f"Expected plan_id '{plan_id}', got '{context.db_checkpoint_result.plan_id}'" ) @then('the db checkpoint result message should mention "{text}"') def step_then_checkpoint_message_contains(context: Context, text: str) -> None: assert context.db_checkpoint_result is not None assert text.lower() in context.db_checkpoint_result.message.lower(), ( f"Expected '{text}' in checkpoint message: {context.db_checkpoint_result.message}" ) # --------------------------------------------------------------------------- # Then: rollback_to() assertions # --------------------------------------------------------------------------- @then("the db rollback result should be successful") def step_then_rollback_success(context: Context) -> None: assert context.db_rollback_result is not None, "rollback_to() raised an exception" assert context.db_rollback_result.success is True, ( f"Expected rollback success, got message: {context.db_rollback_result.message}" ) @then("the db rollback result should not be successful") def step_then_rollback_not_success(context: Context) -> None: assert context.db_rollback_result is not None, "rollback_to() raised an exception" assert context.db_rollback_result.success is False, ( "Expected rollback failure, but got success" ) @then('the db rollback result message should mention "{text}"') def step_then_rollback_message_contains(context: Context, text: str) -> None: assert context.db_rollback_result is not None assert text.lower() in context.db_rollback_result.message.lower(), ( f"Expected '{text}' in rollback message: {context.db_rollback_result.message}" )