Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions tests/test_project_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,3 +728,215 @@ def test_import_project_help_message(self, cli_runner: CliRunner) -> None:

assert result.exit_code == 0
assert "--file" in result.output


@pytest.mark.unit
@pytest.mark.cli
class TestProjectLogsCommand:
"""Test cases for the project logs command."""

def test_logs_flat_success_default_filename(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
sample_project: api_models.Project,
temp_dir: Path,
) -> None:
"""Test successful flat log download with auto-generated filename."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"log content"
mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "1"])

assert result.exit_code == 0
assert "saved to" in result.output
assert "flat" in result.output
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.assert_called_once_with(
id=1, grouped=False
)

def test_logs_grouped_success_default_filename(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
sample_project: api_models.Project,
temp_dir: Path,
) -> None:
"""Test successful grouped log download with auto-generated filename."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content"
mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "1", "--grouped"])

assert result.exit_code == 0
assert "grouped" in result.output
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.assert_called_once_with(
id=1, grouped=True
)

def test_logs_custom_output_file(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
sample_project: api_models.Project,
temp_dir: Path,
) -> None:
"""Test log download to a specified output file."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content"
output_path = str(temp_dir / "my_logs.zip")

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(project, ["logs", "--id", "1", "--output-file", output_path])

assert result.exit_code == 0
assert f"saved to '{output_path}'" in result.output
assert Path(output_path).read_bytes() == b"zip content"

def test_logs_file_content_is_written(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
sample_project: api_models.Project,
temp_dir: Path,
) -> None:
"""Test that the downloaded bytes are written correctly to disk."""
expected_bytes = b"PK\x03\x04fakezipdata"
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = expected_bytes
output_path = str(temp_dir / "output.zip")

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
cli_runner.invoke(project, ["logs", "--id", "42", "--output-file", output_path])

assert Path(output_path).read_bytes() == expected_bytes

def test_logs_api_error_project_not_found(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
) -> None:
"""A 404 for a missing project gives a friendly, non-JSON message."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = (
UnexpectedResponse(status_code=404, content={"detail": "Project not found"})
)

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(project, ["logs", "--id", "99"])

assert result.exit_code == 1
assert "Project ID '99' not found." in result.output

def test_logs_api_error_non_404_uses_generic_handler(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
) -> None:
"""Non-404 API errors still go through the generic error handler."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = (
UnexpectedResponse(status_code=500, content=b"Internal Server Error")
)

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
result = cli_runner.invoke(project, ["logs", "--id", "99"])

assert result.exit_code == 1
assert "500" in result.output

def test_logs_no_executions_returns_error_and_writes_no_file(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
temp_dir: Path,
) -> None:
"""A project with no test run executions surfaces a friendly message
instead of writing an empty/unusable zip file to disk. Mirrors the
dict-shaped content the real API client delivers via response.json()."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = (
UnexpectedResponse(
status_code=404,
content={"detail": "Project 11 has no test run executions to download logs for"},
)
)

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "11"])

assert result.exit_code == 1
assert "Nothing to download" in result.output
assert list(Path(".").glob("*.zip")) == []

def test_logs_no_executions_with_string_content_fallback(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
temp_dir: Path,
) -> None:
"""Also handles JSON-string content, in case the client ever
surfaces the raw response body instead of a parsed dict."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.side_effect = (
UnexpectedResponse(
status_code=404,
content='{"detail": "Project 11 has no test run executions to download logs for"}',
)
)

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "11"])

assert result.exit_code == 1
assert "Nothing to download" in result.output
assert list(Path(".").glob("*.zip")) == []

def test_logs_prints_progress_notice_before_download(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
sample_project: api_models.Project,
temp_dir: Path,
) -> None:
"""A heads-up is printed before the request, since building the zip
server-side can take a while for large projects."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"log content"
mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.return_value = sample_project

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "1"])

assert result.exit_code == 0
assert "Downloading logs for project 1" in result.output
assert "may take a while" in result.output

def test_logs_falls_back_to_default_filename_when_project_name_lookup_fails(
self,
cli_runner: CliRunner,
mock_sync_apis: Mock,
temp_dir: Path,
) -> None:
"""Logs are still written even if fetching the project name fails."""
mock_sync_apis.projects_api.download_project_logs_api_v1_projects__id__logs_get.return_value = b"zip content"
mock_sync_apis.projects_api.read_project_api_v1_projects__id__get.side_effect = RuntimeError(
"network glitch"
)

with patch("th_cli.commands.project.SyncApis", return_value=mock_sync_apis):
with cli_runner.isolated_filesystem(temp_dir=temp_dir):
result = cli_runner.invoke(project, ["logs", "--id", "7"])

assert result.exit_code == 0
assert "saved to 'project-7-logs.zip'" in result.output
assert Path("project-7-logs.zip").read_bytes() == b"zip content"

def test_logs_help_message(self, cli_runner: CliRunner) -> None:
"""Test the help message for the logs command."""
result = cli_runner.invoke(project, ["logs", "--help"])

assert result.exit_code == 0
assert "--id" in result.output
assert "--grouped" in result.output
assert "--output-file" in result.output
29 changes: 29 additions & 0 deletions th_cli/api_lib_autogen/api/projects_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ def _build_for_importproject_config_api_v1_projects_import_post(
type_=m.Project, method="POST", url="/api/v1/projects/import", data=data, files=files
)

def _build_for_download_project_logs_api_v1_projects__id__logs_get(
self, id: int, grouped: bool | None = None
) -> Coroutine[Any, Any, bytes]:
"""
Download Project Logs
"""
path_params = {"id": str(id)}

query_params = {}
if grouped is not None:
query_params["grouped"] = str(grouped)

return self.api_client.request(
type_=bytes, method="GET", url="/api/v1/projects/{id}/logs", path_params=path_params, params=query_params
)


class AsyncProjectsApi(_ProjectsApi):
async def read_projects_api_v1_projects__get(
Expand Down Expand Up @@ -303,6 +319,12 @@ async def importproject_config_api_v1_projects_import_post(
"""
return await self._build_for_importproject_config_api_v1_projects_import_post(body=body)

async def download_project_logs_api_v1_projects__id__logs_get(self, id: int, grouped: bool | None = None) -> bytes:
"""
Download Project Logs
"""
return await self._build_for_download_project_logs_api_v1_projects__id__logs_get(id=id, grouped=grouped)


class SyncProjectsApi(_ProjectsApi):
def read_projects_api_v1_projects__get(
Expand Down Expand Up @@ -407,3 +429,10 @@ def importproject_config_api_v1_projects_import_post(
"""
coroutine = self._build_for_importproject_config_api_v1_projects_import_post(body=body)
return get_event_loop().run_until_complete(coroutine)

def download_project_logs_api_v1_projects__id__logs_get(self, id: int, grouped: bool | None = None) -> bytes:
"""
Download Project Logs
"""
coroutine = self._build_for_download_project_logs_api_v1_projects__id__logs_get(id=id, grouped=grouped)
return get_event_loop().run_until_complete(coroutine)
39 changes: 0 additions & 39 deletions th_cli/api_lib_autogen/api/test_run_executions_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,24 +358,6 @@ def _build_for_import_test_run_execution_api_v1_test_run_executions_import_post(
files=files,
)

def _build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post(
self, id: int, project_id: int
) -> Coroutine[Any, Any, dict[str, Any]]:
"""
Generate Summary Log
"""
path_params = {"id": str(id)}

query_params = {"project_id": str(project_id)}

return self.api_client.request(
type_=dict[str, Any],
method="POST",
url="/api/v1/test_run_executions/{id}/performance_summary",
path_params=path_params,
params=query_params,
)


class AsyncTestRunExecutionsApi(_TestRunExecutionsApi):
async def read_test_run_executions_api_v1_test_run_executions__get(
Expand Down Expand Up @@ -548,16 +530,6 @@ async def import_test_run_execution_api_v1_test_run_executions_import_post(
body=body, project_id=project_id
)

async def generate_summary_log_api_v1_test_run_executions__id__performance_summary_post(
self, id: int, project_id: int
) -> dict[str, Any]:
"""
Generate Summary Log
"""
return await self._build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post(
id=id, project_id=project_id
)


class SyncTestRunExecutionsApi(_TestRunExecutionsApi):
def read_test_run_executions_api_v1_test_run_executions__get(
Expand Down Expand Up @@ -743,14 +715,3 @@ def import_test_run_execution_api_v1_test_run_executions_import_post(
body=body, project_id=project_id
)
return get_event_loop().run_until_complete(coroutine)

def generate_summary_log_api_v1_test_run_executions__id__performance_summary_post(
self, id: int, project_id: int
) -> dict[str, Any]:
"""
Generate Summary Log
"""
coroutine = self._build_for_generate_summary_log_api_v1_test_run_executions__id__performance_summary_post(
id=id, project_id=project_id
)
return get_event_loop().run_until_complete(coroutine)
18 changes: 8 additions & 10 deletions th_cli/api_lib_autogen/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,12 @@ def close(self) -> None:
@overload
async def request(
self, *, type_: Type[T], method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
) -> T:
...
) -> T: ...

@overload
async def request(
self, *, type_: None, method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
) -> None:
...
) -> None: ...

async def request(
self, *, type_: Any, method: str, url: str, path_params: dict[str, Any] | None = None, **kwargs: Any
Expand All @@ -99,12 +97,10 @@ async def request(
return await self.send(request, type_)

@overload
def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T:
...
def request_sync(self, *, type_: Type[T], **kwargs: Any) -> T: ...

@overload
def request_sync(self, *, type_: None, **kwargs: Any) -> None:
...
def request_sync(self, *, type_: None, **kwargs: Any) -> None: ...

def request_sync(self, *, type_: Any, **kwargs: Any) -> Any:
"""
Expand All @@ -114,13 +110,15 @@ def request_sync(self, *, type_: Any, **kwargs: Any) -> Any:

async def send(self, request: Request, type_: Type[T]) -> T | str:
response = await self.middleware(request, self.send_inner)
if response.status_code in [200, 201]:
if response.status_code in [200, 201, 204]:
try:
# Use Pydantic v2 TypeAdapter for validation
if type_ is None or response.status_code == 204:
return None
adapter = TypeAdapter(type_)
if type_ == bytes:
return adapter.validate_python(response.content)
return adapter.validate_python(response.json()) if type_ else response.text
return adapter.validate_python(response.json())
except Exception as e:
raise ResponseHandlingException(e)
raise UnexpectedResponse.for_response(response)
Expand Down
10 changes: 5 additions & 5 deletions th_cli/api_lib_autogen/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.json
# timestamp: 2026-04-08T20:07:43+00:00
# filename: tmp1nhhp82y.json
# timestamp: 2026-07-07T19:42:35+00:00

from __future__ import annotations

Expand Down Expand Up @@ -438,9 +438,9 @@ class TestRunExecutionToExport(BaseModel):
started_at: Annotated[datetime | None, Field(title="Started At")] = None
completed_at: Annotated[datetime | None, Field(title="Completed At")] = None
archived_at: Annotated[datetime | None, Field(title="Archived At")] = None
test_suite_executions: Annotated[
list[TestSuiteExecutionToExport] | None, Field(title="Test Suite Executions")
] = None
test_suite_executions: Annotated[list[TestSuiteExecutionToExport] | None, Field(title="Test Suite Executions")] = (
None
)
created_at: Annotated[datetime, Field(title="Created At")]
log: Annotated[list[TestRunLogEntry], Field(title="Log")]
operator: OperatorToExport | None = None
Expand Down
Loading
Loading