# #region test_task_persistence [C:2] [TYPE Module] # @RELATION BINDS_TO -> SrcRoot # @SEMANTICS: test, task, persistence, unit_test # @PURPOSE: Unit tests for TaskPersistenceService. # @LAYER Tests # @TEST_DATA: valid_task -> {"id": "test-uuid-1", "plugin_id": "backup", "status": "PENDING"} from datetime import UTC, datetime from unittest.mock import patch from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.core.task_manager.models import LogEntry, Task, TaskStatus from src.core.task_manager.persistence import TaskPersistenceService from src.models.mapping import Base, Environment from src.models.task import TaskRecord # #region TestTaskPersistenceHelpers [C:2] [TYPE Class] # @RELATION BINDS_TO -> test_task_persistence # @PURPOSE: Test suite for TaskPersistenceService static helper methods. class TestTaskPersistenceHelpers: # #region test_json_load_if_needed_none [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with None input. def test_json_load_if_needed_none(self): assert TaskPersistenceService._json_load_if_needed(None) is None # #endregion test_json_load_if_needed_none # #region test_json_load_if_needed_dict [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with dict input. def test_json_load_if_needed_dict(self): data = {"key": "value"} assert TaskPersistenceService._json_load_if_needed(data) == data # #endregion test_json_load_if_needed_dict # #region test_json_load_if_needed_list [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with list input. def test_json_load_if_needed_list(self): data = [1, 2, 3] assert TaskPersistenceService._json_load_if_needed(data) == data # #endregion test_json_load_if_needed_list # #region test_json_load_if_needed_json_string [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with JSON string. def test_json_load_if_needed_json_string(self): result = TaskPersistenceService._json_load_if_needed('{"key": "value"}') assert result == {"key": "value"} # #endregion test_json_load_if_needed_json_string # #region test_json_load_if_needed_empty_string [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with empty/null strings. def test_json_load_if_needed_empty_string(self): assert TaskPersistenceService._json_load_if_needed("") is None assert TaskPersistenceService._json_load_if_needed("null") is None assert TaskPersistenceService._json_load_if_needed(" null ") is None # #endregion test_json_load_if_needed_empty_string # #region test_json_load_if_needed_plain_string [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with non-JSON string. def test_json_load_if_needed_plain_string(self): result = TaskPersistenceService._json_load_if_needed("not json") assert result == "not json" # #endregion test_json_load_if_needed_plain_string # #region test_json_load_if_needed_integer [C:2] [TYPE Function] # @PURPOSE: Test _json_load_if_needed with integer. def test_json_load_if_needed_integer(self): assert TaskPersistenceService._json_load_if_needed(42) == 42 # #endregion test_json_load_if_needed_integer # #region test_parse_datetime_none [C:2] [TYPE Function] # @PURPOSE: Test _parse_datetime with None. def test_parse_datetime_none(self): assert TaskPersistenceService._parse_datetime(None) is None # #endregion test_parse_datetime_none # #region test_parse_datetime_datetime_object [C:2] [TYPE Function] # @PURPOSE: Test _parse_datetime with datetime object. def test_parse_datetime_datetime_object(self): dt = datetime(2024, 1, 1, 12, 0, 0) assert TaskPersistenceService._parse_datetime(dt) == dt # #endregion test_parse_datetime_datetime_object # #region test_parse_datetime_iso_string [C:2] [TYPE Function] # @PURPOSE: Test _parse_datetime with ISO string. def test_parse_datetime_iso_string(self): result = TaskPersistenceService._parse_datetime("2024-01-01T12:00:00") assert isinstance(result, datetime) assert result.year == 2024 # #endregion test_parse_datetime_iso_string # #region test_parse_datetime_invalid_string [C:2] [TYPE Function] # @PURPOSE: Test _parse_datetime with invalid string. def test_parse_datetime_invalid_string(self): assert TaskPersistenceService._parse_datetime("not-a-date") is None # #endregion test_parse_datetime_invalid_string # #region test_parse_datetime_integer [C:2] [TYPE Function] # @PURPOSE: Test _parse_datetime with non-string, non-datetime. def test_parse_datetime_integer(self): assert TaskPersistenceService._parse_datetime(12345) is None # #endregion test_parse_datetime_integer # #endregion TestTaskPersistenceHelpers # #region TestTaskPersistenceService [C:2] [TYPE Class] # @RELATION BINDS_TO -> test_task_persistence # @PURPOSE: Test suite for TaskPersistenceService CRUD operations. # @TEST_DATA: valid_task -> {"id": "test-uuid-1", "plugin_id": "backup", "status": "PENDING"} class TestTaskPersistenceService: # #region setup_class [C:2] [TYPE Function] # @PURPOSE: Setup in-memory test database. @classmethod def setup_class(cls): """Create an in-memory SQLite database for testing.""" cls.engine = create_engine("sqlite:///:memory:") from sqlalchemy import event event.listen(cls.engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=cls.engine) cls.TestSessionLocal = sessionmaker(bind=cls.engine) cls.service = TaskPersistenceService() # #endregion setup_class # #region teardown_class [C:2] [TYPE Function] # @PURPOSE: Dispose of test database. @classmethod def teardown_class(cls): cls.engine.dispose() # #endregion teardown_class # #region setup_method [C:2] [TYPE Function] # @PURPOSE: Clean task_records table before each test. def setup_method(self): session = self.TestSessionLocal() session.query(TaskRecord).delete() session.query(Environment).delete() session.commit() session.close() # #endregion setup_method def _patched(self): """Helper: returns a patch context for TasksSessionLocal.""" return patch( "src.core.task_manager.persistence.TasksSessionLocal", self.TestSessionLocal ) def _make_task(self, **kwargs): """Helper: create a Task with test defaults.""" defaults = { "id": "test-uuid-1", "plugin_id": "backup", "status": TaskStatus.PENDING, "params": {"source_env_id": "env-1"}, } defaults.update(kwargs) return Task(**defaults) # #region test_persist_task_new [C:2] [TYPE Function] # @PURPOSE: Test persisting a new task creates a record. # @PRE: Empty database. # @POST: TaskRecord exists in database. def test_persist_task_new(self): """Test persisting a new task creates a record.""" task = self._make_task() with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record is not None assert record.type == "backup" assert record.status == "PENDING" # #endregion test_persist_task_new # #region test_persist_task_update [C:2] [TYPE Function] # @PURPOSE: Test updating an existing task. # @PRE: Task already persisted. # @POST: Task record updated with new status. def test_persist_task_update(self): """Test persisting an existing task updates the record.""" task = self._make_task(status=TaskStatus.PENDING) with self._patched(): self.service.persist_task(task) # Update status task.status = TaskStatus.RUNNING task.started_at = datetime.now(UTC) with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record.status == "RUNNING" assert record.started_at is not None # #endregion test_persist_task_update # #region test_persist_task_with_logs [C:2] [TYPE Function] # @PURPOSE: Test persisting a task with log entries. # @PRE: Task has logs attached. # @POST: Logs serialized as JSON in task record. def test_persist_task_with_logs(self): """Test persisting a task with log entries.""" task = self._make_task() task.logs = [ LogEntry(message="Step 1", level="INFO", source="plugin"), LogEntry(message="Step 2", level="INFO", source="plugin"), ] with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record.logs is not None assert len(record.logs) == 2 # #endregion test_persist_task_with_logs # #region test_persist_task_failed_extracts_error [C:2] [TYPE Function] # @PURPOSE: Test that FAILED task extracts last error message. # @PRE: Task has FAILED status with ERROR logs. # @POST: record.error contains last error message. def test_persist_task_failed_extracts_error(self): """Test that FAILED tasks extract the last error message.""" task = self._make_task(status=TaskStatus.FAILED) task.logs = [ LogEntry(message="Started OK", level="INFO", source="plugin"), LogEntry(message="Connection failed", level="ERROR", source="plugin"), LogEntry(message="Retrying...", level="INFO", source="plugin"), LogEntry(message="Fatal: timeout", level="ERROR", source="plugin"), ] with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record.error == "Fatal: timeout" # #endregion test_persist_task_failed_extracts_error # #region test_persist_tasks_batch [C:2] [TYPE Function] # @PURPOSE: Test persisting multiple tasks. # @PRE: Empty database. # @POST: All task records created. def test_persist_tasks_batch(self): """Test persisting multiple tasks at once.""" tasks = [ self._make_task(id=f"batch-{i}", plugin_id="migration") for i in range(3) ] with self._patched(): self.service.persist_tasks(tasks) session = self.TestSessionLocal() count = session.query(TaskRecord).count() session.close() assert count == 3 # #endregion test_persist_tasks_batch # #region test_load_tasks [C:2] [TYPE Function] # @PURPOSE: Test loading tasks from database. # @PRE: Tasks persisted. # @POST: Returns list of Task objects with correct data. def test_load_tasks(self): """Test loading tasks from database (round-trip).""" task = self._make_task( status=TaskStatus.SUCCESS, started_at=datetime.now(UTC), finished_at=datetime.now(UTC), ) task.params = {"key": "value"} task.result = {"output": "done"} task.logs = [LogEntry(message="Done", level="INFO", source="plugin")] with self._patched(): self.service.persist_task(task) with self._patched(): loaded = self.service.load_tasks(limit=10) assert len(loaded) == 1 assert loaded[0].id == "test-uuid-1" assert loaded[0].plugin_id == "backup" assert loaded[0].status == TaskStatus.SUCCESS assert loaded[0].params == {"key": "value"} # #endregion test_load_tasks # #region test_load_tasks_with_status_filter [C:2] [TYPE Function] # @PURPOSE: Test loading tasks filtered by status. # @PRE: Tasks with different statuses persisted. # @POST: Returns only tasks matching status filter. def test_load_tasks_with_status_filter(self): """Test loading tasks filtered by status.""" tasks = [ self._make_task(id="s1", status=TaskStatus.SUCCESS), self._make_task(id="s2", status=TaskStatus.FAILED), self._make_task(id="s3", status=TaskStatus.SUCCESS), ] with self._patched(): self.service.persist_tasks(tasks) with self._patched(): failed_tasks = self.service.load_tasks(status=TaskStatus.FAILED) assert len(failed_tasks) == 1 assert failed_tasks[0].id == "s2" assert failed_tasks[0].status == TaskStatus.FAILED # #endregion test_load_tasks_with_status_filter # #region test_load_tasks_with_limit [C:2] [TYPE Function] # @PURPOSE: Test loading tasks with limit. # @PRE: Multiple tasks persisted. # @POST: Returns at most `limit` tasks. def test_load_tasks_with_limit(self): """Test loading tasks respects limit parameter.""" tasks = [ self._make_task(id=f"lim-{i}") for i in range(10) ] with self._patched(): self.service.persist_tasks(tasks) with self._patched(): loaded = self.service.load_tasks(limit=3) assert len(loaded) == 3 # #endregion test_load_tasks_with_limit # #region test_delete_tasks [C:2] [TYPE Function] # @PURPOSE: Test deleting tasks by ID list. # @PRE: Tasks persisted. # @POST: Specified tasks deleted, others remain. def test_delete_tasks(self): """Test deleting tasks by ID list.""" tasks = [ self._make_task(id="del-1"), self._make_task(id="del-2"), self._make_task(id="keep-1"), ] with self._patched(): self.service.persist_tasks(tasks) with self._patched(): self.service.delete_tasks(["del-1", "del-2"]) session = self.TestSessionLocal() remaining = session.query(TaskRecord).all() session.close() assert len(remaining) == 1 assert remaining[0].id == "keep-1" # #endregion test_delete_tasks # #region test_delete_tasks_empty_list [C:2] [TYPE Function] # @PURPOSE: Test deleting with empty list (no-op). # @PRE: None. # @POST: No error, no changes. def test_delete_tasks_empty_list(self): """Test deleting with empty list is a no-op.""" with self._patched(): self.service.delete_tasks([]) # Should not raise # #endregion test_delete_tasks_empty_list # #region test_persist_task_with_datetime_in_params [C:2] [TYPE Function] # @PURPOSE: Test json_serializable handles datetime in params. # @PRE: Task params contain datetime values. # @POST: Params serialized correctly. def test_persist_task_with_datetime_in_params(self): """Test that datetime values in params are serialized to ISO format.""" dt = datetime(2024, 6, 15, 10, 30, 0) task = self._make_task(params={"timestamp": dt, "name": "test"}) with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record.params is not None assert record.params["timestamp"] == "2024-06-15T10:30:00" assert record.params["name"] == "test" # #endregion test_persist_task_with_datetime_in_params # #region test_persist_task_resolves_environment_slug_to_existing_id [C:2] [TYPE Function] # @PURPOSE: Ensure slug-like environment token resolves to environments.id before persisting task. # @PRE: environments table contains env with name convertible to provided slug token. # @POST: task_records.environment_id stores actual environments.id and does not violate FK. def test_persist_task_resolves_environment_slug_to_existing_id(self): session = self.TestSessionLocal() env = Environment(id="env-uuid-1", name="SS DEV", url="https://example.local", credentials_id="cred-1") session.add(env) session.commit() session.close() task = self._make_task(params={"environment_id": "ss-dev"}) with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record is not None assert record.environment_id == "env-uuid-1" # #endregion test_persist_task_resolves_environment_slug_to_existing_id # #region test_resolve_environment_id_direct_id_match [C:2] [TYPE Function] # @PURPOSE: _resolve_environment_id returns match on direct primary key lookup. def test_resolve_environment_id_direct_id_match(self): session = self.TestSessionLocal() env = Environment(id="direct-id", name="Some Env", url="https://x", credentials_id="cred-1") session.add(env) session.commit() session.close() result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "direct-id") assert result == "direct-id" # #endregion test_resolve_environment_id_direct_id_match # #region test_resolve_environment_id_name_match [C:2] [TYPE Function] # @PURPOSE: _resolve_environment_id returns match on name lookup. def test_resolve_environment_id_name_match(self): session = self.TestSessionLocal() env = Environment(id="env-name-id", name="Production", url="https://prod", credentials_id="cred-1") session.add(env) session.commit() session.close() result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "Production") assert result == "env-name-id" # #endregion test_resolve_environment_id_name_match # #region test_resolve_environment_id_empty_token [C:2] [TYPE Function] # @PURPOSE: _resolve_environment_id returns None when normalized token is empty. def test_resolve_environment_id_empty_token(self): # No envs in DB; value "---" normalizes to "" (all non-alnum chars) → returns None result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "---") assert result is None # #endregion test_resolve_environment_id_empty_token # #region test_persist_task_with_list_in_params [C:2] [TYPE Function] # @PURPOSE: json_serializable handles list values in params. def test_persist_task_with_list_in_params(self): task = self._make_task(params={"items": [1, 2, {"nested": "val"}]}) with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record is not None assert record.params["items"] == [1, 2, {"nested": "val"}] # #endregion test_persist_task_with_list_in_params # #region test_persist_task_with_context_datetime [C:2] [TYPE Function] # @PURPOSE: Log context with datetime values gets json_serializable treatment. def test_persist_task_with_context_datetime(self): from datetime import UTC, datetime task = self._make_task() dt_val = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC) task.logs = [ LogEntry(message="ctx test", level="INFO", source="test", context={"time": dt_val, "val": 42}), ] with self._patched(): self.service.persist_task(task) session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record is not None assert len(record.logs) == 1 assert record.logs[0]["context"]["time"] == "2024-07-04T12:00:00+00:00" # #endregion test_persist_task_with_context_datetime # #region test_persist_task_db_error_rollback [C:2] [TYPE Function] # @PURPOSE: persist_task rollbacks on database error. def test_persist_task_db_error_rollback(self): task = self._make_task() # Force a DB error by passing params that cause SQLite issue with patch.object(self.TestSessionLocal(), "commit", side_effect=RuntimeError("db down")): # Repatch TasksSessionLocal to use the broken session def broken_session(): s = self.TestSessionLocal() s.commit = lambda: (_ for _ in ()).throw(RuntimeError("db down")) return s with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session): self.service.persist_task(task) # Should not raise session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() session.close() assert record is None # rolled back # #endregion test_persist_task_db_error_rollback # #region test_load_tasks_with_non_dict_logs [C:2] [TYPE Function] # @PURPOSE: Load tasks where log entries are not dicts (skipped gracefully). def test_load_tasks_with_non_dict_logs(self): task = self._make_task(status=TaskStatus.SUCCESS) with self._patched(): self.service.persist_task(task) # Manually inject non-dict log data session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="test-uuid-1").first() record.logs = [42, "not-a-dict"] session.commit() session.close() with self._patched(): loaded = self.service.load_tasks() assert len(loaded) == 1 assert loaded[0].id == "test-uuid-1" assert loaded[0].logs == [] # non-dict entries skipped # #endregion test_load_tasks_with_non_dict_logs # #region test_load_tasks_corrupt_record_skipped [C:2] [TYPE Function] # @PURPOSE: Load tasks gracefully skips records that fail reconstruction. def test_load_tasks_corrupt_record_skipped(self): session = self.TestSessionLocal() # Create a record with invalid status value record = TaskRecord(id="corrupt-1", type="test", status="INVALID_STATUS") session.add(record) session.commit() session.close() with self._patched(): loaded = self.service.load_tasks() # Corrupt record should be skipped, no exception raised ids = [t.id for t in loaded] assert "corrupt-1" not in ids # #endregion test_load_tasks_corrupt_record_skipped # #region test_delete_tasks_db_error [C:2] [TYPE Function] # @PURPOSE: delete_tasks handles database error gracefully. def test_delete_tasks_db_error(self): # Pre-create a task task = self._make_task(id="del-err") with self._patched(): self.service.persist_task(task) # Force error during delete def broken_session(): s = self.TestSessionLocal() original_commit = s.commit s.commit = lambda: (_ for _ in ()).throw(RuntimeError("delete failed")) return s with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session): self.service.delete_tasks(["del-err"]) # Should not raise # Task still exists session = self.TestSessionLocal() record = session.query(TaskRecord).filter_by(id="del-err").first() session.close() assert record is not None # #endregion test_delete_tasks_db_error # #endregion TestTaskPersistenceService # #endregion test_task_persistence