From 6bc07a28764ff8059dc09335ba69907b96e364fb Mon Sep 17 00:00:00 2001 From: Petr Simecek Date: Thu, 26 Feb 2026 13:10:35 +0100 Subject: [PATCH] Phase 3: Configuration listing (config service, CLI commands, output formatter) Implement multi-project configuration listing and detail retrieval: - Add ConfigService with resolve_projects(), list_configs(), get_config_detail() - Replace config command stubs with full Typer implementation - Add Rich table formatter grouped by project alias - Add config detail panel with configuration parameters display - Wire ConfigService into CLI context - Per-project errors collected without stopping other projects - Component type and component ID filtering support - 37 new tests covering service logic, CLI commands in JSON/human modes, partial failures, filtering, and exit codes Co-Authored-By: Claude Opus 4.6 --- src/keboola_agent_cli/cli.py | 3 + src/keboola_agent_cli/commands/config.py | 90 ++- src/keboola_agent_cli/output.py | 114 +++ .../services/config_service.py | 169 +++++ tests/test_cli.py | 708 +++++++++++++++++- tests/test_services.py | 583 +++++++++++++- 6 files changed, 1658 insertions(+), 9 deletions(-) create mode 100644 src/keboola_agent_cli/services/config_service.py diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 780e6b5b..b8a95039 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -10,6 +10,7 @@ from .commands.project import project_app from .config_store import ConfigStore from .output import OutputFormatter +from .services.config_service import ConfigService from .services.project_service import ProjectService app = typer.Typer( @@ -58,6 +59,7 @@ def main( config_store = ConfigStore() project_service = ProjectService(config_store=config_store) + config_service = ConfigService(config_store=config_store) ctx.ensure_object(dict) ctx.obj["formatter"] = formatter @@ -66,3 +68,4 @@ def main( ctx.obj["no_color"] = effective_no_color ctx.obj["config_store"] = config_store ctx.obj["project_service"] = project_service + ctx.obj["config_service"] = config_service diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index dc9c5f2e..32f76d03 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -1,33 +1,86 @@ -"""Configuration browsing commands - list and detail.""" +"""Configuration browsing commands - list and detail. + +Thin CLI layer: parses arguments, calls ConfigService, formats output. +No business logic belongs here. +""" from typing import Optional import typer -from ..output import OutputFormatter +from ..errors import ConfigError, KeboolaApiError +from ..output import OutputFormatter, format_config_detail, format_configs_table +from ..services.config_service import ConfigService config_app = typer.Typer(help="Browse and inspect configurations") +VALID_COMPONENT_TYPES = ["extractor", "writer", "transformation", "application"] + def _get_formatter(ctx: typer.Context) -> OutputFormatter: """Retrieve the OutputFormatter from the Typer context.""" return ctx.obj["formatter"] +def _get_service(ctx: typer.Context) -> ConfigService: + """Retrieve the ConfigService from the Typer context.""" + return ctx.obj["config_service"] + + @config_app.command("list") def config_list( ctx: typer.Context, - project: Optional[list[str]] = typer.Option(None, "--project", help="Project alias (can be repeated)"), + project: Optional[list[str]] = typer.Option( + None, + "--project", + help="Project alias to query (can be repeated for multiple projects)", + ), component_type: Optional[str] = typer.Option( None, "--component-type", help="Filter by component type: extractor, writer, transformation, application", ), - component_id: Optional[str] = typer.Option(None, "--component-id", help="Filter by specific component ID"), + component_id: Optional[str] = typer.Option( + None, + "--component-id", + help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)", + ), ) -> None: """List configurations from connected projects.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + # Validate component_type if provided + if component_type and component_type not in VALID_COMPONENT_TYPES: + formatter.error( + message=f"Invalid component type '{component_type}'. " + f"Valid types: {', '.join(VALID_COMPONENT_TYPES)}", + error_code="INVALID_ARGUMENT", + ) + raise typer.Exit(code=2) + + try: + result = service.list_configs( + aliases=project, + component_type=component_type, + component_id=component_id, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) + + # In JSON mode, include both configs and errors in the response + if formatter.json_mode: + formatter.output(result) + else: + # In human mode, show per-project errors as warnings and configs as table + format_configs_table(formatter.console, result) + + # Show error warnings on stderr too + for err in result.get("errors", []): + formatter.warning( + f"Project '{err['project_alias']}': {err['message']}" + ) @config_app.command("detail") @@ -39,4 +92,29 @@ def config_detail( ) -> None: """Show detailed information about a specific configuration.""" formatter = _get_formatter(ctx) - formatter.output("Not yet implemented", lambda c, d: c.print(d)) + service = _get_service(ctx) + + try: + result = service.get_config_detail( + alias=project, + component_id=component_id, + config_id=config_id, + ) + formatter.output(result, format_config_detail) + except ConfigError as exc: + formatter.error(message=exc.message, error_code="CONFIG_ERROR") + raise typer.Exit(code=5) + except KeboolaApiError as exc: + if exc.error_code == "INVALID_TOKEN": + exit_code = 3 + elif exc.error_code in ("TIMEOUT", "CONNECTION_ERROR", "RETRY_EXHAUSTED"): + exit_code = 4 + else: + exit_code = 1 + formatter.error( + message=exc.message, + error_code=exc.error_code, + project=project, + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) diff --git a/src/keboola_agent_cli/output.py b/src/keboola_agent_cli/output.py index 0784af48..e0fbcb32 100644 --- a/src/keboola_agent_cli/output.py +++ b/src/keboola_agent_cli/output.py @@ -5,6 +5,8 @@ from typing import Any, Callable from rich.console import Console +from rich.panel import Panel +from rich.table import Table from .models import ErrorResponse, SuccessResponse @@ -86,3 +88,115 @@ def success(self, message: str) -> None: sys.stdout.write(response.model_dump_json(indent=2) + "\n") else: self.console.print(f"[bold green]Success:[/bold green] {message}") + + def warning(self, message: str) -> None: + """Output a warning message to stderr (human mode only). + + In JSON mode, warnings are not printed separately -- they are + embedded in the structured response via the errors list. + + Args: + message: The warning message to display. + """ + if not self.json_mode: + self.err_console.print(f"[bold yellow]Warning:[/bold yellow] {message}") + + +def format_configs_table(console: Console, data: dict[str, Any]) -> None: + """Render a Rich table of configurations grouped by project alias. + + Args: + console: Rich Console instance. + data: Dict with "configs" (list of config dicts) and "errors" (list of error dicts). + """ + configs = data.get("configs", []) + errors = data.get("errors", []) + + # Show per-project errors as warnings + for err in errors: + console.print( + f"[bold yellow]Warning:[/bold yellow] Project [bold]{err['project_alias']}[/bold]: " + f"{err['message']}" + ) + + if not configs: + if not errors: + console.print("No configurations found. Use [bold]kbagent project add[/bold] to connect a project first.") + else: + console.print("No configurations retrieved (all projects failed).") + return + + # Group configs by project alias + projects_order: list[str] = [] + grouped: dict[str, list[dict[str, Any]]] = {} + for cfg in configs: + alias = cfg["project_alias"] + if alias not in grouped: + projects_order.append(alias) + grouped[alias] = [] + grouped[alias].append(cfg) + + for alias in projects_order: + project_configs = grouped[alias] + table = Table(title=f"Configurations - {alias}") + table.add_column("Component", style="bold cyan") + table.add_column("Type", style="dim") + table.add_column("Config ID", justify="right") + table.add_column("Config Name") + table.add_column("Description", style="dim", max_width=40) + + for cfg in project_configs: + table.add_row( + cfg["component_id"], + cfg["component_type"], + cfg["config_id"], + cfg["config_name"], + cfg.get("config_description", ""), + ) + + console.print(table) + console.print() + + +def format_config_detail(console: Console, data: dict[str, Any]) -> None: + """Render detailed configuration information. + + Args: + console: Rich Console instance. + data: Configuration detail dict from the API. + """ + alias = data.get("project_alias", "unknown") + name = data.get("name", "Unknown") + config_id = data.get("id", "") + description = data.get("description", "") + component_id = data.get("component_id", data.get("componentId", "")) + + header = f"Configuration Detail - {alias}" + + lines = [ + f"[bold]Name:[/bold] {name}", + f"[bold]Config ID:[/bold] {config_id}", + f"[bold]Component:[/bold] {component_id}", + ] + if description: + lines.append(f"[bold]Description:[/bold] {description}") + + # Show configuration parameters if present + configuration = data.get("configuration", {}) + if configuration: + import json as _json + config_str = _json.dumps(configuration, indent=2) + lines.append(f"\n[bold]Configuration:[/bold]\n{config_str}") + + # Show rows if present + rows = data.get("rows", []) + if rows: + lines.append(f"\n[bold]Rows:[/bold] {len(rows)} row(s)") + for row in rows[:10]: # Show at most 10 rows + row_name = row.get("name", row.get("id", "")) + lines.append(f" - {row_name}") + if len(rows) > 10: + lines.append(f" ... and {len(rows) - 10} more") + + panel = Panel("\n".join(lines), title=header, expand=False) + console.print(panel) diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py new file mode 100644 index 00000000..354ec3ba --- /dev/null +++ b/src/keboola_agent_cli/services/config_service.py @@ -0,0 +1,169 @@ +"""Configuration listing service - business logic for listing and detailing configs. + +Orchestrates multi-project configuration retrieval, filtering, and aggregation +without knowing about CLI or HTTP details. +""" + +from typing import Any, Callable + +from ..client import KeboolaClient +from ..config_store import ConfigStore +from ..errors import ConfigError, KeboolaApiError +from ..models import ProjectConfig + +ClientFactory = Callable[[str, str], KeboolaClient] + + +def default_client_factory(stack_url: str, token: str) -> KeboolaClient: + """Create a KeboolaClient with the given stack URL and token.""" + return KeboolaClient(stack_url=stack_url, token=token) + + +class ConfigService: + """Business logic for listing and inspecting Keboola configurations. + + Supports multi-project aggregation: queries multiple projects in sequence, + collects results, and reports per-project errors without stopping others. + + Uses dependency injection for config_store and client_factory to enable + easy testing with mocks. + """ + + def __init__( + self, + config_store: ConfigStore, + client_factory: ClientFactory | None = None, + ) -> None: + self._config_store = config_store + self._client_factory = client_factory or default_client_factory + + def resolve_projects( + self, aliases: list[str] | None = None + ) -> dict[str, ProjectConfig]: + """Resolve project aliases to ProjectConfig instances. + + Args: + aliases: Specific project aliases to resolve. If None or empty, + returns all configured projects. + + Returns: + Dict mapping alias to ProjectConfig for the resolved projects. + + Raises: + ConfigError: If any specified alias is not found in the config. + """ + config = self._config_store.load() + + if not aliases: + return dict(config.projects) + + resolved: dict[str, ProjectConfig] = {} + for alias in aliases: + if alias not in config.projects: + raise ConfigError(f"Project '{alias}' not found.") + resolved[alias] = config.projects[alias] + + return resolved + + def list_configs( + self, + aliases: list[str] | None = None, + component_type: str | None = None, + component_id: str | None = None, + ) -> dict[str, Any]: + """List configurations across one or multiple projects. + + Queries each resolved project for components and their configurations, + flattens them into a unified list. Per-project errors are collected + but do not stop other projects from being queried. + + Args: + aliases: Project aliases to query. None means all projects. + component_type: Optional filter by component type + (extractor, writer, transformation, application). + component_id: Optional filter by specific component ID + (e.g. keboola.ex-db-snowflake). + + Returns: + Dict with keys: + - "configs": list of config dicts with project_alias, + component_id, component_name, component_type, + config_id, config_name, config_description + - "errors": list of error dicts with project_alias, + error_code, message + + Raises: + ConfigError: If a specified alias is not found (before querying). + """ + projects = self.resolve_projects(aliases) + + all_configs: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + + for alias, project in projects.items(): + client = self._client_factory(project.stack_url, project.token) + try: + components = client.list_components(component_type=component_type) + for component in components: + comp_id = component.get("id", "") + comp_name = component.get("name", "") + comp_type = component.get("type", "") + + # Apply component_id filter if specified + if component_id and comp_id != component_id: + continue + + configurations = component.get("configurations", []) + for cfg in configurations: + all_configs.append({ + "project_alias": alias, + "component_id": comp_id, + "component_name": comp_name, + "component_type": comp_type, + "config_id": str(cfg.get("id", "")), + "config_name": cfg.get("name", ""), + "config_description": cfg.get("description", ""), + }) + except KeboolaApiError as exc: + errors.append({ + "project_alias": alias, + "error_code": exc.error_code, + "message": exc.message, + }) + finally: + client.close() + + return {"configs": all_configs, "errors": errors} + + def get_config_detail( + self, + alias: str, + component_id: str, + config_id: str, + ) -> dict[str, Any]: + """Get detailed information about a specific configuration. + + Args: + alias: Project alias to query. + component_id: The component ID (e.g. keboola.ex-db-snowflake). + config_id: The configuration ID. + + Returns: + Dict with the full configuration detail from the API, + plus a "project_alias" key. + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + + client = self._client_factory(project.stack_url, project.token) + try: + detail = client.get_config_detail(component_id, config_id) + finally: + client.close() + + detail["project_alias"] = alias + return detail diff --git a/tests/test_cli.py b/tests/test_cli.py index e9ce630f..80247931 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -"""Tests for CLI commands via CliRunner - project add, list in JSON and human mode.""" +"""Tests for CLI commands via CliRunner - project and config commands.""" import json from pathlib import Path @@ -10,7 +10,8 @@ from keboola_agent_cli.cli import app from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ConfigError, KeboolaApiError -from keboola_agent_cli.models import TokenVerifyResponse +from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.services.config_service import ConfigService from keboola_agent_cli.services.project_service import ProjectService runner = CliRunner() @@ -481,3 +482,706 @@ def test_project_edit_config_error_exit_code_5(self, tmp_path: Path) -> None: assert result.exit_code == 5 output = json.loads(result.output) assert output["status"] == "error" + + +# --------------------------------------------------------------------------- +# Helpers for config command tests +# --------------------------------------------------------------------------- + +SAMPLE_COMPONENTS = [ + { + "id": "keboola.ex-db-snowflake", + "name": "Snowflake Extractor", + "type": "extractor", + "configurations": [ + { + "id": "101", + "name": "Production Load", + "description": "Loads production data", + }, + { + "id": "102", + "name": "Dev Load", + "description": "Loads dev data", + }, + ], + }, + { + "id": "keboola.wr-db-snowflake", + "name": "Snowflake Writer", + "type": "writer", + "configurations": [ + { + "id": "201", + "name": "Write to DWH", + "description": "Writes to data warehouse", + }, + ], + }, +] + +SAMPLE_COMPONENTS_2 = [ + { + "id": "keboola.python-transformation-v2", + "name": "Python Transformation", + "type": "transformation", + "configurations": [ + { + "id": "301", + "name": "Aggregate Data", + "description": "Aggregation script", + }, + ], + }, +] + + +def _make_list_components_client(components: list[dict]) -> MagicMock: + """Create a mock KeboolaClient with list_components returning given data.""" + mock_client = MagicMock() + mock_client.list_components.return_value = components + return mock_client + + +def _setup_config_test(config_dir: Path, projects: dict[str, dict] | None = None): + """Set up a ConfigStore with given projects for testing config commands. + + Args: + config_dir: Directory for config files. + projects: Dict mapping alias to dict with 'token' and optional 'stack_url'. + + Returns: + Configured ConfigStore instance. + """ + store = ConfigStore(config_dir=config_dir) + if projects: + for alias, info in projects.items(): + store.add_project(alias, ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info["token"], + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + )) + return store + + +class TestConfigList: + """Tests for `kbagent config list` command.""" + + def test_config_list_json_output(self, tmp_path: Path) -> None: + """config list --json returns structured JSON with configs from all projects.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, ["--json", "config", "list"]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + configs = output["data"]["configs"] + assert len(configs) == 3 + assert configs[0]["project_alias"] == "prod" + assert configs[0]["component_id"] == "keboola.ex-db-snowflake" + assert configs[0]["config_name"] == "Production Load" + + def test_config_list_human_output(self, tmp_path: Path) -> None: + """config list in human mode shows Rich table grouped by project.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, ["config", "list"]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + # Should show project-grouped table + assert "prod" in result.output + assert "Configurations" in result.output + assert "Production Load" in result.output + # Rich may truncate long component IDs, so check for prefix + assert "keboola.ex-db-" in result.output + + def test_config_list_project_filter(self, tmp_path: Path) -> None: + """config list --project X returns configs only from that project.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + prod_client = _make_list_components_client(SAMPLE_COMPONENTS) + dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + "dev": {"token": "532-abcdef-ghijklmnopqrst"}, + }) + + def factory(url, token): + if "901" in token: + return prod_client + return dev_client + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=factory, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "list", + "--project", "prod", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + configs = output["data"]["configs"] + assert len(configs) == 3 + assert all(c["project_alias"] == "prod" for c in configs) + + def test_config_list_multiple_projects(self, tmp_path: Path) -> None: + """config list --project X --project Y returns configs from both.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + prod_client = _make_list_components_client(SAMPLE_COMPONENTS) + dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + "dev": {"token": "532-abcdef-ghijklmnopqrst"}, + }) + + def factory(url, token): + if "901" in token: + return prod_client + return dev_client + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=factory, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "list", + "--project", "prod", + "--project", "dev", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + configs = output["data"]["configs"] + assert len(configs) == 4 # 3 from prod + 1 from dev + aliases = {c["project_alias"] for c in configs} + assert aliases == {"prod", "dev"} + + def test_config_list_type_filter(self, tmp_path: Path) -> None: + """config list --component-type extractor filters by type.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Client returns only extractors when type filter is applied + extractor_only = [SAMPLE_COMPONENTS[0]] + mock_client = _make_list_components_client(extractor_only) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "list", + "--component-type", "extractor", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + configs = output["data"]["configs"] + assert len(configs) == 2 + assert all(c["component_type"] == "extractor" for c in configs) + + def test_config_list_component_id_filter(self, tmp_path: Path) -> None: + """config list --component-id X filters by specific component.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "list", + "--component-id", "keboola.wr-db-snowflake", + ]) + + assert result.exit_code == 0 + output = json.loads(result.output) + configs = output["data"]["configs"] + assert len(configs) == 1 + assert configs[0]["component_id"] == "keboola.wr-db-snowflake" + + def test_config_list_unknown_alias_exit_code_5(self, tmp_path: Path) -> None: + """config list --project unknown returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test(config_dir) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke(app, [ + "--json", "config", "list", + "--project", "nonexistent", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + assert "not found" in output["error"]["message"] + + def test_config_list_partial_failure_json(self, tmp_path: Path) -> None: + """config list shows errors for failed projects while returning others.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + good_client = _make_list_components_client(SAMPLE_COMPONENTS) + bad_client = MagicMock() + bad_client.list_components.side_effect = KeboolaApiError( + message="Token expired", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + store = _setup_config_test(config_dir, { + "good": {"token": "901-good-abcdefghijklmnop"}, + "bad": {"token": "532-bad-abcdefghijklmnopq"}, + }) + + def factory(url, token): + if "good" in token: + return good_client + return bad_client + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=factory, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, ["--json", "config", "list"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + + configs = output["data"]["configs"] + errors = output["data"]["errors"] + + assert len(configs) == 3 + assert all(c["project_alias"] == "good" for c in configs) + + assert len(errors) == 1 + assert errors[0]["project_alias"] == "bad" + assert errors[0]["error_code"] == "INVALID_TOKEN" + + def test_config_list_partial_failure_human(self, tmp_path: Path) -> None: + """config list in human mode shows warnings for failed projects.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + good_client = _make_list_components_client(SAMPLE_COMPONENTS) + bad_client = MagicMock() + bad_client.list_components.side_effect = KeboolaApiError( + message="Token expired", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + store = _setup_config_test(config_dir, { + "good": {"token": "901-good-abcdefghijklmnop"}, + "bad": {"token": "532-bad-abcdefghijklmnopq"}, + }) + + def factory(url, token): + if "good" in token: + return good_client + return bad_client + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=factory, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, ["config", "list"]) + + assert result.exit_code == 0 + # Should show configs from good project + assert "Configurations" in result.output + assert "Production Load" in result.output + # Should show warning about bad project + assert "bad" in result.output + assert "Token expired" in result.output + + def test_config_list_empty_json(self, tmp_path: Path) -> None: + """config list --json with no configs returns empty data.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = _make_list_components_client([]) + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, ["--json", "config", "list"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["configs"] == [] + assert output["data"]["errors"] == [] + + def test_config_list_invalid_component_type_exit_code_2(self, tmp_path: Path) -> None: + """config list with invalid --component-type returns exit code 2.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke(app, [ + "--json", "config", "list", + "--component-type", "invalid-type", + ]) + + assert result.exit_code == 2 + output = json.loads(result.output) + assert output["status"] == "error" + assert "INVALID_ARGUMENT" in output["error"]["code"] + + +class TestConfigDetail: + """Tests for `kbagent config detail` command.""" + + def test_config_detail_json_output(self, tmp_path: Path) -> None: + """config detail --json returns full config detail.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + detail_response = { + "id": "101", + "name": "Production Load", + "description": "Loads production data", + "componentId": "keboola.ex-db-snowflake", + "configuration": {"parameters": {"db": "prod"}}, + "rows": [], + } + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = detail_response + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "prod", + "--component-id", "keboola.ex-db-snowflake", + "--config-id", "101", + ]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["id"] == "101" + assert output["data"]["name"] == "Production Load" + assert output["data"]["project_alias"] == "prod" + assert output["data"]["configuration"] == {"parameters": {"db": "prod"}} + + def test_config_detail_human_output(self, tmp_path: Path) -> None: + """config detail in human mode shows a Rich panel with details.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + detail_response = { + "id": "101", + "name": "Production Load", + "description": "Loads production data", + "componentId": "keboola.ex-db-snowflake", + "configuration": {"parameters": {"db": "prod"}}, + "rows": [], + } + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = detail_response + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "config", "detail", + "--project", "prod", + "--component-id", "keboola.ex-db-snowflake", + "--config-id", "101", + ]) + + assert result.exit_code == 0, f"Exit code {result.exit_code}: {result.output}" + assert "Production Load" in result.output + assert "Configuration Detail" in result.output + + def test_config_detail_unknown_alias_exit_code_5(self, tmp_path: Path) -> None: + """config detail with unknown alias returns exit code 5.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + store = _setup_config_test(config_dir) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCfgService.return_value = ConfigService(config_store=store) + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "nonexistent", + "--component-id", "keboola.ex-db-snowflake", + "--config-id", "101", + ]) + + assert result.exit_code == 5 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "CONFIG_ERROR" + assert "not found" in output["error"]["message"] + + def test_config_detail_api_error_exit_code(self, tmp_path: Path) -> None: + """config detail with API error returns appropriate exit code.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Config not found", + status_code=404, + error_code="NOT_FOUND", + retryable=False, + ) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "prod", + "--component-id", "keboola.ex-db-snowflake", + "--config-id", "999", + ]) + + assert result.exit_code == 1 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "NOT_FOUND" + + def test_config_detail_auth_error_exit_code_3(self, tmp_path: Path) -> None: + """config detail with auth error returns exit code 3.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + store = _setup_config_test(config_dir, { + "prod": {"token": "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k"}, + }) + + with patch("keboola_agent_cli.cli.ConfigStore") as MockStore, \ + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, \ + patch("keboola_agent_cli.cli.ConfigService") as MockCfgService: + + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + config_service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + MockCfgService.return_value = config_service + + result = runner.invoke(app, [ + "--json", "config", "detail", + "--project", "prod", + "--component-id", "keboola.ex-db-snowflake", + "--config-id", "101", + ]) + + assert result.exit_code == 3 + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "INVALID_TOKEN" diff --git a/tests/test_services.py b/tests/test_services.py index 29866d80..2be50eb5 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,4 +1,4 @@ -"""Tests for ProjectService - add, remove, edit, list, status.""" +"""Tests for ProjectService and ConfigService.""" from pathlib import Path from unittest.mock import MagicMock @@ -8,6 +8,7 @@ from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ConfigError, KeboolaApiError from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.services.config_service import ConfigService from keboola_agent_cli.services.project_service import ProjectService @@ -461,3 +462,583 @@ def test_status_token_masked(self, tmp_config_dir: Path) -> None: result = service.get_status() assert result[0]["token"] != full_token assert "901-...pt0k" == result[0]["token"] + + +# --------------------------------------------------------------------------- +# Helpers for ConfigService tests +# --------------------------------------------------------------------------- + +def _make_list_components_client( + components: list[dict], +) -> MagicMock: + """Create a mock KeboolaClient with list_components returning given data.""" + mock_client = MagicMock() + mock_client.list_components.return_value = components + return mock_client + + +SAMPLE_COMPONENTS = [ + { + "id": "keboola.ex-db-snowflake", + "name": "Snowflake Extractor", + "type": "extractor", + "configurations": [ + { + "id": "101", + "name": "Production Load", + "description": "Loads production data", + }, + { + "id": "102", + "name": "Dev Load", + "description": "Loads dev data", + }, + ], + }, + { + "id": "keboola.wr-db-snowflake", + "name": "Snowflake Writer", + "type": "writer", + "configurations": [ + { + "id": "201", + "name": "Write to DWH", + "description": "Writes to data warehouse", + }, + ], + }, +] + +SAMPLE_COMPONENTS_2 = [ + { + "id": "keboola.python-transformation-v2", + "name": "Python Transformation", + "type": "transformation", + "configurations": [ + { + "id": "301", + "name": "Aggregate Data", + "description": "Aggregation script", + }, + ], + }, +] + + +class TestConfigServiceListConfigs: + """Tests for ConfigService.list_configs().""" + + def test_list_configs_single_project_all_configs(self, tmp_config_dir: Path) -> None: + """list_configs returns all configs from a single project.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Production", + project_id=1234, + )) + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.list_configs() + configs = result["configs"] + errors = result["errors"] + + assert len(errors) == 0 + assert len(configs) == 3 # 2 from extractor + 1 from writer + + # Verify structure of first config + first = configs[0] + assert first["project_alias"] == "prod" + assert first["component_id"] == "keboola.ex-db-snowflake" + assert first["component_name"] == "Snowflake Extractor" + assert first["component_type"] == "extractor" + assert first["config_id"] == "101" + assert first["config_name"] == "Production Load" + assert first["config_description"] == "Loads production data" + + def test_list_configs_multi_project_aggregation(self, tmp_config_dir: Path) -> None: + """list_configs aggregates configs across multiple projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + project_name="Production", + project_id=1234, + )) + store.add_project("dev", ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-abcdef-ghijklmnopqrst", + project_name="Development", + project_id=5678, + )) + + prod_client = _make_list_components_client(SAMPLE_COMPONENTS) + dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) + + def factory(url, token): + if "901" in token: + return prod_client + return dev_client + + service = ConfigService( + config_store=store, + client_factory=factory, + ) + + result = service.list_configs() + configs = result["configs"] + errors = result["errors"] + + assert len(errors) == 0 + assert len(configs) == 4 # 3 from prod + 1 from dev + + prod_configs = [c for c in configs if c["project_alias"] == "prod"] + dev_configs = [c for c in configs if c["project_alias"] == "dev"] + + assert len(prod_configs) == 3 + assert len(dev_configs) == 1 + assert dev_configs[0]["component_id"] == "keboola.python-transformation-v2" + + def test_list_configs_filter_by_component_type(self, tmp_config_dir: Path) -> None: + """list_configs passes component_type filter to the client.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + # When filtering by type, API returns only matching components + extractor_only = [SAMPLE_COMPONENTS[0]] + mock_client = _make_list_components_client(extractor_only) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.list_configs(component_type="extractor") + configs = result["configs"] + + assert len(configs) == 2 + assert all(c["component_type"] == "extractor" for c in configs) + + # Verify the type filter was passed to the client + mock_client.list_components.assert_called_once_with(component_type="extractor") + + def test_list_configs_filter_by_component_id(self, tmp_config_dir: Path) -> None: + """list_configs filters configs to only the specified component_id.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.list_configs(component_id="keboola.wr-db-snowflake") + configs = result["configs"] + + assert len(configs) == 1 + assert configs[0]["component_id"] == "keboola.wr-db-snowflake" + assert configs[0]["config_name"] == "Write to DWH" + + def test_list_configs_filter_by_project_alias(self, tmp_config_dir: Path) -> None: + """list_configs with aliases only queries specified projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + store.add_project("dev", ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-abcdef-ghijklmnopqrst", + )) + + prod_client = _make_list_components_client(SAMPLE_COMPONENTS) + dev_client = _make_list_components_client(SAMPLE_COMPONENTS_2) + + def factory(url, token): + if "901" in token: + return prod_client + return dev_client + + service = ConfigService( + config_store=store, + client_factory=factory, + ) + + # Only request from prod + result = service.list_configs(aliases=["prod"]) + configs = result["configs"] + + assert len(configs) == 3 + assert all(c["project_alias"] == "prod" for c in configs) + + # dev_client.list_components should NOT have been called + dev_client.list_components.assert_not_called() + + def test_list_configs_partial_failure(self, tmp_config_dir: Path) -> None: + """list_configs continues when one project fails, reporting the error.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("good", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-good-abcdefghijklmnop", + )) + store.add_project("bad", ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-bad-abcdefghijklmnopq", + )) + + good_client = _make_list_components_client(SAMPLE_COMPONENTS) + bad_client = MagicMock() + bad_client.list_components.side_effect = KeboolaApiError( + message="Token expired for bad project", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + def factory(url, token): + if "good" in token: + return good_client + return bad_client + + service = ConfigService( + config_store=store, + client_factory=factory, + ) + + result = service.list_configs() + configs = result["configs"] + errors = result["errors"] + + # Good project configs should still be present + assert len(configs) == 3 + assert all(c["project_alias"] == "good" for c in configs) + + # Bad project error should be reported + assert len(errors) == 1 + assert errors[0]["project_alias"] == "bad" + assert errors[0]["error_code"] == "INVALID_TOKEN" + assert "Token expired" in errors[0]["message"] + + def test_list_configs_empty_results(self, tmp_config_dir: Path) -> None: + """list_configs returns empty configs list when no configurations exist.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("empty", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + # No components returned + mock_client = _make_list_components_client([]) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.list_configs() + assert result["configs"] == [] + assert result["errors"] == [] + + def test_list_configs_no_projects_configured(self, tmp_config_dir: Path) -> None: + """list_configs with no projects returns empty results.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ConfigService(config_store=store) + + result = service.list_configs() + assert result["configs"] == [] + assert result["errors"] == [] + + def test_list_configs_unknown_alias_raises_config_error(self, tmp_config_dir: Path) -> None: + """list_configs with unknown alias raises ConfigError.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ConfigService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.list_configs(aliases=["nonexistent"]) + + def test_list_configs_client_closed_after_use(self, tmp_config_dir: Path) -> None: + """list_configs always closes the client after querying.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.list_configs() + mock_client.close.assert_called_once() + + def test_list_configs_client_closed_on_error(self, tmp_config_dir: Path) -> None: + """list_configs closes the client even when the API call fails.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("bad", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = MagicMock() + mock_client.list_components.side_effect = KeboolaApiError( + message="Server error", + status_code=500, + error_code="API_ERROR", + retryable=True, + ) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + service.list_configs() + mock_client.close.assert_called_once() + + def test_list_configs_combined_type_and_component_id_filter(self, tmp_config_dir: Path) -> None: + """list_configs applies both component_type and component_id filters.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = _make_list_components_client(SAMPLE_COMPONENTS) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.list_configs( + component_type="extractor", + component_id="keboola.ex-db-snowflake", + ) + configs = result["configs"] + + assert len(configs) == 2 + assert all(c["component_id"] == "keboola.ex-db-snowflake" for c in configs) + + # component_type was passed to client + mock_client.list_components.assert_called_once_with(component_type="extractor") + + def test_list_configs_multiple_aliases(self, tmp_config_dir: Path) -> None: + """list_configs with multiple aliases queries exactly those projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("proj-a", ProjectConfig( + stack_url="https://a.com", + token="901-aaa-abcdefghijklmnop", + )) + store.add_project("proj-b", ProjectConfig( + stack_url="https://b.com", + token="902-bbb-abcdefghijklmnop", + )) + store.add_project("proj-c", ProjectConfig( + stack_url="https://c.com", + token="903-ccc-abcdefghijklmnop", + )) + + client_a = _make_list_components_client(SAMPLE_COMPONENTS) + client_b = _make_list_components_client(SAMPLE_COMPONENTS_2) + client_c = _make_list_components_client([]) + + def factory(url, token): + if "aaa" in token: + return client_a + elif "bbb" in token: + return client_b + return client_c + + service = ConfigService( + config_store=store, + client_factory=factory, + ) + + result = service.list_configs(aliases=["proj-a", "proj-b"]) + configs = result["configs"] + + assert len(configs) == 4 # 3 from a + 1 from b + aliases_in_result = {c["project_alias"] for c in configs} + assert aliases_in_result == {"proj-a", "proj-b"} + + # proj-c should not have been queried + client_c.list_components.assert_not_called() + + +class TestConfigServiceGetConfigDetail: + """Tests for ConfigService.get_config_detail().""" + + def test_get_config_detail_success(self, tmp_config_dir: Path) -> None: + """get_config_detail returns full config detail with project_alias.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + detail_response = { + "id": "101", + "name": "Production Load", + "description": "Loads production data", + "componentId": "keboola.ex-db-snowflake", + "configuration": {"parameters": {"db": "prod"}}, + "rows": [], + } + + mock_client = MagicMock() + mock_client.get_config_detail.return_value = detail_response + + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.get_config_detail( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="101", + ) + + assert result["id"] == "101" + assert result["name"] == "Production Load" + assert result["project_alias"] == "prod" + assert result["configuration"] == {"parameters": {"db": "prod"}} + mock_client.get_config_detail.assert_called_once_with( + "keboola.ex-db-snowflake", "101" + ) + mock_client.close.assert_called_once() + + def test_get_config_detail_unknown_alias(self, tmp_config_dir: Path) -> None: + """get_config_detail raises ConfigError for unknown alias.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ConfigService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.get_config_detail( + alias="nonexistent", + component_id="keboola.ex-db-snowflake", + config_id="101", + ) + + def test_get_config_detail_api_error(self, tmp_config_dir: Path) -> None: + """get_config_detail propagates KeboolaApiError from the client.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Config not found", + status_code=404, + error_code="NOT_FOUND", + retryable=False, + ) + + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + service.get_config_detail( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="999", + ) + + assert exc_info.value.error_code == "NOT_FOUND" + mock_client.close.assert_called_once() + + def test_get_config_detail_client_closed_on_error(self, tmp_config_dir: Path) -> None: + """get_config_detail closes the client even when API call fails.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + mock_client = MagicMock() + mock_client.get_config_detail.side_effect = KeboolaApiError( + message="Server error", + status_code=500, + error_code="API_ERROR", + retryable=True, + ) + + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError): + service.get_config_detail("prod", "comp-x", "cfg-y") + + mock_client.close.assert_called_once() + + +class TestResolveProjects: + """Tests for ConfigService.resolve_projects().""" + + def test_resolve_all_projects(self, tmp_config_dir: Path) -> None: + """resolve_projects with no aliases returns all projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + store.add_project("dev", ProjectConfig( + stack_url="https://b.com", + token="532-abcdef-ghijklmnopqrst", + )) + + service = ConfigService(config_store=store) + result = service.resolve_projects() + assert set(result.keys()) == {"prod", "dev"} + + def test_resolve_specific_aliases(self, tmp_config_dir: Path) -> None: + """resolve_projects with aliases returns only matching projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + store.add_project("dev", ProjectConfig( + stack_url="https://b.com", + token="532-abcdef-ghijklmnopqrst", + )) + + service = ConfigService(config_store=store) + result = service.resolve_projects(aliases=["prod"]) + assert set(result.keys()) == {"prod"} + + def test_resolve_unknown_alias_raises_config_error(self, tmp_config_dir: Path) -> None: + """resolve_projects raises ConfigError for unknown alias.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ConfigService(config_store=store) + + with pytest.raises(ConfigError, match="not found"): + service.resolve_projects(aliases=["nonexistent"]) + + def test_resolve_empty_aliases_list(self, tmp_config_dir: Path) -> None: + """resolve_projects with empty list returns all projects.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project("prod", ProjectConfig( + stack_url="https://a.com", + token="901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k", + )) + + service = ConfigService(config_store=store) + result = service.resolve_projects(aliases=[]) + assert set(result.keys()) == {"prod"}