From 5c91130be3e70bc873bc653013583be8ad5c5770 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 04:54:37 +0000 Subject: [PATCH 1/4] test: standardize SDK-boundary mocking on pytest-mock Adopt pytest-mock and route all test mocking through the `mocker` fixture, replacing the parallel use of `unittest.mock` (patch/MagicMock) that ran alongside pytest's monkeypatch. `unittest.mock` imports are now gone from the test suite. SDK-boundary patches (the `client.*`/`ams.*` wrapper functions and the assemblyai SDK classes) now use `autospec=True`, so calls are validated against the real signatures instead of silently accepting any call shape. Env/config isolation and the existing module-attribute stubs stay on monkeypatch, unchanged. Class/classmethod patches that fight autospec's self/cls binding, and the whole-module stubs in the init-template tests (which can't be autospec'd), are converted to mocker without autospec. --- pyproject.toml | 1 + tests/test_account_command.py | 88 ++++--- tests/test_audit_command.py | 51 ++-- tests/test_client.py | 195 +++++++------- tests/test_init_template_agent.py | 13 +- tests/test_init_template_stream.py | 13 +- tests/test_init_template_transcribe.py | 81 +++--- tests/test_keys.py | 103 ++++---- tests/test_login.py | 121 +++++---- tests/test_sessions_command.py | 41 +-- tests/test_transcribe.py | 339 ++++++++++++++----------- tests/test_transcripts.py | 85 ++++--- uv.lock | 14 + 13 files changed, 616 insertions(+), 529 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a3d40a58..4e6b07da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ aai = "aai_cli.main:run" dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", + "pytest-mock>=3.14.0", "hypothesis>=6.155.1", "ruff>=0.15.15", "mypy>=2.1.0", diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 7aa42379..d9d32d01 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -1,5 +1,4 @@ import json -from unittest.mock import patch from typer.testing import CliRunner @@ -25,32 +24,34 @@ def _human(monkeypatch): monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) -def test_balance_formats_dollars(monkeypatch): +def test_balance_formats_dollars(monkeypatch, mocker): _auth() _human(monkeypatch) - with patch( + mocker.patch( "aai_cli.commands.account.ams.get_balance", + autospec=True, return_value={"account_id": 42, "balance_in_cents": 2575}, - ): - result = runner.invoke(app, ["balance"]) + ) + result = runner.invoke(app, ["balance"]) assert result.exit_code == 0 assert "$25.75" in result.output -def test_balance_without_session_runs_login(monkeypatch): +def test_balance_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) - with patch( + get_balance = mocker.patch( "aai_cli.commands.account.ams.get_balance", + autospec=True, return_value={"account_id": 42, "balance_in_cents": 2575}, - ) as get_balance: - result = runner.invoke(app, ["balance", "--json"]) + ) + result = runner.invoke(app, ["balance", "--json"]) assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} get_balance.assert_not_called() assert "Run the same command again" in result.output -def test_usage_defaults_date_range_and_renders(monkeypatch): +def test_usage_defaults_date_range_and_renders(mocker): _auth() captured = {} @@ -67,8 +68,8 @@ def fake_usage(jwt, start, end, window): ] } - with patch("aai_cli.commands.account.ams.get_usage", side_effect=fake_usage): - result = runner.invoke(app, ["usage", "--json"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, side_effect=fake_usage) + result = runner.invoke(app, ["usage", "--json"]) assert result.exit_code == 0 # both bounds are tz-aware UTC ISO-8601 timestamps, defaulted when not passed # (AMS rejects naive datetimes with a 400). @@ -84,7 +85,7 @@ def fake_usage(jwt, start, end, window): assert data["usage_items"][0]["total"] == 12.5 -def test_usage_renders_table_human(monkeypatch): +def test_usage_renders_table_human(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -97,8 +98,8 @@ def test_usage_renders_table_human(monkeypatch): } ] } - with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): - result = runner.invoke(app, ["usage"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "2026-05-01" in result.output and "12.5" in result.output @@ -137,7 +138,7 @@ def test_usage_helpers_format_windows_and_line_items(): assert account._line_items_summary({"line_items": "bad"}) == "" -def test_usage_human_renders_breakdown(monkeypatch): +def test_usage_human_renders_breakdown(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -150,23 +151,25 @@ def test_usage_human_renders_breakdown(monkeypatch): } ] } - with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): - result = runner.invoke(app, ["usage"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "breakdown" in result.output assert "minutes: 10" in result.output -def test_usage_human_summarizes_empty_range(monkeypatch): +def test_usage_human_summarizes_empty_range(monkeypatch, mocker): _auth() _human(monkeypatch) - with patch("aai_cli.commands.account.ams.get_usage", return_value={"usage_items": []}): - result = runner.invoke(app, ["usage"]) + mocker.patch( + "aai_cli.commands.account.ams.get_usage", autospec=True, return_value={"usage_items": []} + ) + result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "No usage windows returned" in result.output -def test_usage_human_hides_zero_windows_by_default(monkeypatch): +def test_usage_human_hides_zero_windows_by_default(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -185,8 +188,8 @@ def test_usage_human_hides_zero_windows_by_default(monkeypatch): }, ] } - with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): - result = runner.invoke(app, ["usage"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "Usage total: 12.5" in result.output assert "2026-01-01" not in result.output @@ -194,7 +197,7 @@ def test_usage_human_hides_zero_windows_by_default(monkeypatch): assert "Hidden: 1 zero-usage window" in result.output -def test_usage_human_can_include_zero_windows(monkeypatch): +def test_usage_human_can_include_zero_windows(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -207,14 +210,14 @@ def test_usage_human_can_include_zero_windows(monkeypatch): } ] } - with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): - result = runner.invoke(app, ["usage", "--all"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage", "--all"]) assert result.exit_code == 0 assert "2026-01-01" in result.output assert "No usage in this range" not in result.output -def test_usage_human_summarizes_all_zero_range(monkeypatch): +def test_usage_human_summarizes_all_zero_range(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -227,20 +230,20 @@ def test_usage_human_summarizes_all_zero_range(monkeypatch): } ] } - with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): - result = runner.invoke(app, ["usage"]) + mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True, return_value=payload) + result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "Usage total: 0" in result.output assert "No usage in this range" in result.output assert "2026-01-01" not in result.output -def test_usage_passes_explicit_dates(): +def test_usage_passes_explicit_dates(mocker): _auth() - with patch( - "aai_cli.commands.account.ams.get_usage", return_value={"usage_items": []} - ) as get_usage: - result = runner.invoke(app, ["usage", "--start", "2026-01-01", "--end", "2026-02-01"]) + get_usage = mocker.patch( + "aai_cli.commands.account.ams.get_usage", autospec=True, return_value={"usage_items": []} + ) + result = runner.invoke(app, ["usage", "--start", "2026-01-01", "--end", "2026-02-01"]) assert result.exit_code == 0 # Dates are normalized to tz-aware UTC timestamps before hitting AMS. get_usage.assert_called_once_with( @@ -248,22 +251,23 @@ def test_usage_passes_explicit_dates(): ) -def test_usage_rejects_invalid_date(): +def test_usage_rejects_invalid_date(mocker): _auth() - with patch("aai_cli.commands.account.ams.get_usage") as get_usage: - result = runner.invoke(app, ["usage", "--start", "not-a-date"]) + get_usage = mocker.patch("aai_cli.commands.account.ams.get_usage", autospec=True) + result = runner.invoke(app, ["usage", "--start", "not-a-date"]) assert result.exit_code == 2 assert "Invalid date" in result.output get_usage.assert_not_called() -def test_limits_renders_services(monkeypatch): +def test_limits_renders_services(monkeypatch, mocker): _auth() _human(monkeypatch) - with patch( + mocker.patch( "aai_cli.commands.account.ams.get_rate_limits", + autospec=True, return_value={"rate_limits": ["bad", {"service": "transcript", "magnitude": 200}]}, - ): - result = runner.invoke(app, ["limits"]) + ) + result = runner.invoke(app, ["limits"]) assert result.exit_code == 0 assert "transcript" in result.output and "200" in result.output diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index ccb462af..12ef4044 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -1,5 +1,4 @@ import json -from unittest.mock import patch from typer.testing import CliRunner @@ -25,7 +24,7 @@ def _human(monkeypatch): monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) -def test_audit_renders_rows(): +def test_audit_renders_rows(mocker): _auth() payload = { "page_details": {"has_more": False}, @@ -41,8 +40,8 @@ def test_audit_renders_rows(): } ], } - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): - result = runner.invoke(app, ["audit", "--json"]) + mocker.patch("aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value=payload) + result = runner.invoke(app, ["audit", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data[0]["action_taken"] == "token.create" @@ -55,19 +54,19 @@ def test_audit_rows_filter_invalid_items(): ] -def test_audit_passes_filters(): +def test_audit_passes_filters(mocker): _auth() - with patch( - "aai_cli.commands.audit.ams.list_audit_logs", return_value={"data": []} - ) as list_logs: - result = runner.invoke(app, ["audit", "--limit", "5", "--action", "token.create"]) + list_logs = mocker.patch( + "aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value={"data": []} + ) + result = runner.invoke(app, ["audit", "--limit", "5", "--action", "token.create"]) assert result.exit_code == 0 list_logs.assert_called_once_with( "jwt", limit=5, action_taken="token.create", resource_type=None ) -def test_audit_human_mode_renders_table(monkeypatch): +def test_audit_human_mode_renders_table(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -91,8 +90,8 @@ def test_audit_human_mode_renders_table(monkeypatch): }, ] } - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): - result = runner.invoke(app, ["audit"]) + mocker.patch("aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value=payload) + result = runner.invoke(app, ["audit"]) assert result.exit_code == 0 assert "API key created" in result.output assert "2026-06-01 12:00:00" in result.output @@ -115,16 +114,18 @@ def test_audit_helpers_format_edge_cases(): assert audit._audit_rows({"data": "bad"}) == [] -def test_audit_human_empty_result(monkeypatch): +def test_audit_human_empty_result(monkeypatch, mocker): _auth() _human(monkeypatch) - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value={"data": []}): - result = runner.invoke(app, ["audit"]) + mocker.patch( + "aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value={"data": []} + ) + result = runner.invoke(app, ["audit"]) assert result.exit_code == 0 assert "No audit events found" in result.output -def test_audit_can_include_login_events(monkeypatch): +def test_audit_can_include_login_events(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -140,14 +141,14 @@ def test_audit_can_include_login_events(monkeypatch): } ] } - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): - result = runner.invoke(app, ["audit", "--include-logins"]) + mocker.patch("aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value=payload) + result = runner.invoke(app, ["audit", "--include-logins"]) assert result.exit_code == 0 assert "Login succeeded" in result.output assert "Hidden:" not in result.output -def test_audit_summarizes_all_login_rows(monkeypatch): +def test_audit_summarizes_all_login_rows(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -163,17 +164,19 @@ def test_audit_summarizes_all_login_rows(monkeypatch): } ] } - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): - result = runner.invoke(app, ["audit"]) + mocker.patch("aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value=payload) + result = runner.invoke(app, ["audit"]) assert result.exit_code == 0 assert "No notable audit events" in result.output assert "Hidden: 1 login event" in result.output -def test_audit_without_session_runs_login(monkeypatch): +def test_audit_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) - with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value={"data": []}) as logs: - result = runner.invoke(app, ["audit", "--json"]) + logs = mocker.patch( + "aai_cli.commands.audit.ams.list_audit_logs", autospec=True, return_value={"data": []} + ) + result = runner.invoke(app, ["audit", "--json"]) assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} logs.assert_not_called() diff --git a/tests/test_client.py b/tests/test_client.py index d101f7af..51f40a47 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,4 @@ import types as _types -from unittest.mock import MagicMock, patch import assemblyai as aai import pytest @@ -18,78 +17,78 @@ def _stream_params(sample_rate: int = 16000): ) -def test_validate_key_true_on_success(): - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.return_value = MagicMock() - assert client.validate_key("sk_good") is True +def test_validate_key_true_on_success(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.return_value = mocker.MagicMock() + assert client.validate_key("sk_good") is True # The probe asks for a single row — it only needs to confirm the key authenticates. params = T.return_value.list_transcripts.call_args.args[0] assert params.limit == 1 -def test_validate_key_false_on_auth_error(): - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( - "Authentication error, API token missing/invalid" - ) - assert client.validate_key("sk_bad") is False +def test_validate_key_false_on_auth_error(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( + "Authentication error, API token missing/invalid" + ) + assert client.validate_key("sk_bad") is False -def test_validate_key_raises_on_other_sdk_error(): - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("server exploded") - with pytest.raises(APIError): - client.validate_key("sk") +def test_validate_key_raises_on_other_sdk_error(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("server exploded") + with pytest.raises(APIError): + client.validate_key("sk") -def test_validate_key_raises_on_network_error(): - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.side_effect = ConnectionError("boom") - with pytest.raises(APIError): - client.validate_key("sk") +def test_validate_key_raises_on_network_error(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = ConnectionError("boom") + with pytest.raises(APIError): + client.validate_key("sk") -def test_list_transcripts_returns_dict_rows(): - item = MagicMock() +def test_list_transcripts_returns_dict_rows(mocker): + item = mocker.MagicMock() item.model_dump.return_value = {"id": "t1", "status": "completed", "created": "2026-01-01"} - resp = MagicMock() + resp = mocker.MagicMock() resp.transcripts = [item] - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.return_value = resp - rows = client.list_transcripts("sk", limit=5) + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.return_value = resp + rows = client.list_transcripts("sk", limit=5) assert rows == [{"id": "t1", "status": "completed", "created": "2026-01-01"}] item.model_dump.assert_called_once_with(mode="json") -def test_list_transcripts_supports_pydantic_v1_items(): +def test_list_transcripts_supports_pydantic_v1_items(mocker): # assemblyai's transcription models are pydantic v1: no model_dump, but .json(). import types item = types.SimpleNamespace(json=lambda: '{"id": "t2", "status": "queued"}') - resp = MagicMock() + resp = mocker.MagicMock() resp.transcripts = [item] - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.return_value = resp - rows = client.list_transcripts("sk", limit=5) + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.return_value = resp + rows = client.list_transcripts("sk", limit=5) assert rows == [{"id": "t2", "status": "queued"}] -def test_list_transcripts_auth_error_becomes_apierror(): - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("nope") - with pytest.raises(APIError): - client.list_transcripts("sk") +def test_list_transcripts_auth_error_becomes_apierror(mocker): + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError("nope") + with pytest.raises(APIError): + client.list_transcripts("sk") -def test_list_transcripts_rejected_key_becomes_not_authenticated(): +def test_list_transcripts_rejected_key_becomes_not_authenticated(mocker): from aai_cli.errors import NotAuthenticated - with patch.object(client.aai, "Transcriber") as T: - T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( - "Authentication error, API token missing/invalid" - ) - with pytest.raises(NotAuthenticated): - client.list_transcripts("sk_bad") + T = mocker.patch.object(client.aai, "Transcriber", autospec=True) + T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( + "Authentication error, API token missing/invalid" + ) + with pytest.raises(NotAuthenticated): + client.list_transcripts("sk_bad") def test_resolve_audio_source_sample_explicit_and_missing(): @@ -103,55 +102,55 @@ def test_resolve_audio_source_sample_explicit_and_missing(): assert "--sample" in (exc.value.suggestion or "") -def test_transcribe_blocks_and_returns_transcript(): - fake_transcript = MagicMock() +def test_transcribe_blocks_and_returns_transcript(mocker): + fake_transcript = mocker.MagicMock() fake_transcript.status = client.aai.TranscriptStatus.completed - fake_transcriber = MagicMock() + fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.return_value = fake_transcript cfg = aai.TranscriptionConfig(speaker_labels=True) - with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): - result = client.transcribe("sk", "audio.mp3", config=cfg) + mocker.patch.object(client.aai, "Transcriber", autospec=True, return_value=fake_transcriber) + result = client.transcribe("sk", "audio.mp3", config=cfg) fake_transcriber.transcribe.assert_called_once_with("audio.mp3", config=cfg) assert result is fake_transcript -def test_transcribe_raises_on_error_status(): - fake_transcript = MagicMock() +def test_transcribe_raises_on_error_status(mocker): + fake_transcript = mocker.MagicMock() fake_transcript.status = client.aai.TranscriptStatus.error fake_transcript.error = "decode failed" fake_transcript.id = "t_err" - fake_transcriber = MagicMock() + fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.return_value = fake_transcript - with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): - with pytest.raises(APIError) as exc: - client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) + mocker.patch.object(client.aai, "Transcriber", autospec=True, return_value=fake_transcriber) + with pytest.raises(APIError) as exc: + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) assert exc.value.transcript_id == "t_err" assert exc.value.message == "decode failed" # surfaces the SDK's error verbatim -def test_transcribe_error_status_without_message_uses_fallback(): +def test_transcribe_error_status_without_message_uses_fallback(mocker): # When the SDK reports an error status but no error text, fall back to a generic # message (pins the `transcript.error or "Transcription failed."`). - fake_transcript = MagicMock() + fake_transcript = mocker.MagicMock() fake_transcript.status = client.aai.TranscriptStatus.error fake_transcript.error = None fake_transcript.id = "t_err" - fake_transcriber = MagicMock() + fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.return_value = fake_transcript - with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): - with pytest.raises(APIError) as exc: - client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) + mocker.patch.object(client.aai, "Transcriber", autospec=True, return_value=fake_transcriber) + with pytest.raises(APIError) as exc: + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) assert exc.value.message == "Transcription failed." -def test_select_transcript_field_utterances_formats_speakers(): +def test_select_transcript_field_utterances_formats_speakers(mocker): import types - t = MagicMock() + t = mocker.MagicMock() t.utterances = [ types.SimpleNamespace(speaker="A", text="hello"), types.SimpleNamespace(speaker="B", text="hi there"), @@ -161,22 +160,22 @@ def test_select_transcript_field_utterances_formats_speakers(): ) -def test_select_transcript_field_utterances_falls_back_to_text_when_absent(): - t = MagicMock() +def test_select_transcript_field_utterances_falls_back_to_text_when_absent(mocker): + t = mocker.MagicMock() t.utterances = [] # no diarization -> plain transcript text t.text = "plain transcript" assert client.select_transcript_field(t, "utterances") == "plain transcript" -def test_select_transcript_field_utterances_ignores_non_list_value(): - t = MagicMock() +def test_select_transcript_field_utterances_ignores_non_list_value(mocker): + t = mocker.MagicMock() t.utterances = "bad" t.text = "plain transcript" assert client.select_transcript_field(t, "utterances") == "plain transcript" -def test_select_transcript_field_srt_uses_sdk(): - t = MagicMock() +def test_select_transcript_field_srt_uses_sdk(mocker): + t = mocker.MagicMock() t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" assert client.select_transcript_field(t, "srt") == ( "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" @@ -184,62 +183,62 @@ def test_select_transcript_field_srt_uses_sdk(): t.export_subtitles_srt.assert_called_once_with() -def test_select_transcript_field_srt_network_error_becomes_apierror(): - t = MagicMock() +def test_select_transcript_field_srt_network_error_becomes_apierror(mocker): + t = mocker.MagicMock() t.export_subtitles_srt.side_effect = RuntimeError("connection reset") with pytest.raises(APIError): client.select_transcript_field(t, "srt") -def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(): +def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(mocker): from aai_cli.errors import NotAuthenticated - t = MagicMock() + t = mocker.MagicMock() t.export_subtitles_srt.side_effect = RuntimeError("HTTP 401 Unauthorized") with pytest.raises(NotAuthenticated): client.select_transcript_field(t, "srt") -def test_get_transcript_calls_sdk(): - fake = MagicMock() - with patch.object(client.aai.Transcript, "get_by_id", return_value=fake) as g: - result = client.get_transcript("sk", "t_123") +def test_get_transcript_calls_sdk(mocker): + fake = mocker.MagicMock() + g = mocker.patch.object(client.aai.Transcript, "get_by_id", return_value=fake) + result = client.get_transcript("sk", "t_123") g.assert_called_once_with("t_123") assert result is fake -def test_get_transcript_generic_error_becomes_apierror(): - with patch.object(client.aai.Transcript, "get_by_id", side_effect=RuntimeError("boom")): - with pytest.raises(APIError): - client.get_transcript("sk", "t_x") +def test_get_transcript_generic_error_becomes_apierror(mocker): + mocker.patch.object(client.aai.Transcript, "get_by_id", side_effect=RuntimeError("boom")) + with pytest.raises(APIError): + client.get_transcript("sk", "t_x") -def test_get_transcript_auth_error_becomes_not_authenticated(): +def test_get_transcript_auth_error_becomes_not_authenticated(mocker): from aai_cli.errors import NotAuthenticated - with patch.object( + mocker.patch.object( client.aai.Transcript, "get_by_id", side_effect=RuntimeError("HTTP 401 Unauthorized") - ): - with pytest.raises(NotAuthenticated): - client.get_transcript("sk_bad", "t_x") + ) + with pytest.raises(NotAuthenticated): + client.get_transcript("sk_bad", "t_x") -def test_transcribe_network_error_becomes_apierror(): - fake_transcriber = MagicMock() +def test_transcribe_network_error_becomes_apierror(mocker): + fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("connection reset") - with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): - with pytest.raises(APIError): - client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) + mocker.patch.object(client.aai, "Transcriber", autospec=True, return_value=fake_transcriber) + with pytest.raises(APIError): + client.transcribe("sk", "audio.mp3", config=aai.TranscriptionConfig()) -def test_transcribe_auth_error_becomes_not_authenticated(): +def test_transcribe_auth_error_becomes_not_authenticated(mocker): from aai_cli.errors import NotAuthenticated - fake_transcriber = MagicMock() + fake_transcriber = mocker.MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("Invalid API key") - with patch.object(client.aai, "Transcriber", return_value=fake_transcriber): - with pytest.raises(NotAuthenticated): - client.transcribe("sk_bad", "audio.mp3", config=aai.TranscriptionConfig()) + mocker.patch.object(client.aai, "Transcriber", autospec=True, return_value=fake_transcriber) + with pytest.raises(NotAuthenticated): + client.transcribe("sk_bad", "audio.mp3", config=aai.TranscriptionConfig()) class _FakeStreamingClient: @@ -419,7 +418,7 @@ def stream(self, source): assert exc.value.exit_code == 2 # not rewrapped into APIError -def test_transcribe_passes_prebuilt_config(monkeypatch): +def test_transcribe_passes_prebuilt_config(monkeypatch, mocker): import assemblyai as aai from aai_cli import client @@ -430,7 +429,7 @@ class FakeTranscriber: def transcribe(self, audio, config=None): captured["audio"] = audio captured["config"] = config - t = MagicMock() + t = mocker.MagicMock() t.status = aai.TranscriptStatus.completed return t diff --git a/tests/test_init_template_agent.py b/tests/test_init_template_agent.py index def68cb0..ce866b21 100644 --- a/tests/test_init_template_agent.py +++ b/tests/test_init_template_agent.py @@ -1,7 +1,6 @@ import importlib import sys from pathlib import Path -from unittest.mock import MagicMock from fastapi.testclient import TestClient @@ -15,16 +14,16 @@ def _load_app(monkeypatch): return importlib.import_module("api.index") -def _ok_response(token="tok-123"): - resp = MagicMock() +def _ok_response(mocker, token="tok-123"): + resp = mocker.MagicMock() resp.json.return_value = {"token": token} resp.raise_for_status.return_value = None return resp -def test_token_returns_token_and_agent_ws_url(monkeypatch): +def test_token_returns_token_and_agent_ws_url(monkeypatch, mocker): mod = _load_app(monkeypatch) - monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response()) + monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response(mocker)) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 200 body = resp.json() @@ -32,7 +31,7 @@ def test_token_returns_token_and_agent_ws_url(monkeypatch): assert body["ws_url"].startswith("wss://") and body["ws_url"].endswith("/v1/ws") -def test_token_uses_bearer_authorization_header(monkeypatch): +def test_token_uses_bearer_authorization_header(monkeypatch, mocker): # The Voice Agent token uses Bearer auth (unlike the streaming token). monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-test") mod = _load_app(monkeypatch) @@ -40,7 +39,7 @@ def test_token_uses_bearer_authorization_header(monkeypatch): def fake_get(url, params=None, headers=None): captured.update(url=url, headers=headers) - return _ok_response() + return _ok_response(mocker) monkeypatch.setattr(mod.httpx2, "get", fake_get) TestClient(mod.app).post("/api/token") diff --git a/tests/test_init_template_stream.py b/tests/test_init_template_stream.py index 7a26fdd2..72de01d4 100644 --- a/tests/test_init_template_stream.py +++ b/tests/test_init_template_stream.py @@ -1,7 +1,6 @@ import importlib import sys from pathlib import Path -from unittest.mock import MagicMock from fastapi.testclient import TestClient @@ -15,16 +14,16 @@ def _load_app(monkeypatch): return importlib.import_module("api.index") -def _ok_response(token="tok-123"): - resp = MagicMock() +def _ok_response(mocker, token="tok-123"): + resp = mocker.MagicMock() resp.json.return_value = {"token": token} resp.raise_for_status.return_value = None return resp -def test_token_returns_token_and_streaming_ws_url(monkeypatch): +def test_token_returns_token_and_streaming_ws_url(monkeypatch, mocker): mod = _load_app(monkeypatch) - monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response()) + monkeypatch.setattr(mod.httpx2, "get", lambda *a, **k: _ok_response(mocker)) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 200 body = resp.json() @@ -32,7 +31,7 @@ def test_token_returns_token_and_streaming_ws_url(monkeypatch): assert body["ws_url"].startswith("wss://") and body["ws_url"].endswith("/v3/ws") -def test_token_uses_raw_authorization_header(monkeypatch): +def test_token_uses_raw_authorization_header(monkeypatch, mocker): # The streaming token uses the raw key as Authorization (NOT Bearer). monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-test") mod = _load_app(monkeypatch) @@ -40,7 +39,7 @@ def test_token_uses_raw_authorization_header(monkeypatch): def fake_get(url, params=None, headers=None): captured.update(url=url, headers=headers) - return _ok_response() + return _ok_response(mocker) monkeypatch.setattr(mod.httpx2, "get", fake_get) TestClient(mod.app).post("/api/token") diff --git a/tests/test_init_template_transcribe.py b/tests/test_init_template_transcribe.py index ef22004b..ce8937e8 100644 --- a/tests/test_init_template_transcribe.py +++ b/tests/test_init_template_transcribe.py @@ -1,32 +1,31 @@ import importlib import sys from pathlib import Path -from unittest.mock import MagicMock from fastapi.testclient import TestClient TEMPLATE_DIR = Path("aai_cli/init/templates/audio-transcription") -def _load_app(monkeypatch): +def _load_app(monkeypatch, mocker): """Import the template's api/index.py as a module and return its FastAPI app. The template is a standalone project (not part of aai_cli's import graph), so we load it by file path. The assemblyai SDK is stubbed so no network/key is needed. """ - fake_aai = MagicMock() + fake_aai = mocker.MagicMock() fake_aai.TranscriptStatus.completed = "completed" fake_aai.TranscriptStatus.error = "error" - submitted = MagicMock(id="t-123") + submitted = mocker.MagicMock(id="t-123") fake_aai.Transcriber.return_value.submit.return_value = submitted # The template fetches status via the SDK's non-blocking api.get_transcript, # so stub the assemblyai.api and assemblyai.client submodules too. - fake_api = MagicMock() - done = MagicMock(status="completed", error=None) + fake_api = mocker.MagicMock() + done = mocker.MagicMock(status="completed", error=None) done.dict.return_value = {"text": "hello world", "utterances": []} fake_api.get_transcript.return_value = done - fake_client = MagicMock() + fake_client = mocker.MagicMock() monkeypatch.setitem(sys.modules, "assemblyai", fake_aai) monkeypatch.setitem(sys.modules, "assemblyai.api", fake_api) @@ -60,10 +59,10 @@ def test_template_ships_no_real_key(): assert "your_assemblyai_api_key_here" in (TEMPLATE_DIR / "env.example").read_text() -def test_base_url_env_is_applied(monkeypatch): +def test_base_url_env_is_applied(monkeypatch, mocker): # aai init writes ASSEMBLYAI_BASE_URL so a sandbox key targets the sandbox host. monkeypatch.setenv("ASSEMBLYAI_BASE_URL", "https://api.sb.example") - _app, fake, _api = _load_app(monkeypatch) + _app, fake, _api = _load_app(monkeypatch, mocker) assert fake.settings.base_url == "https://api.sb.example" @@ -83,17 +82,17 @@ def test_page_explores_all_features_and_speakers(): assert token in ui_src, token -def test_ask_calls_llm_gateway_with_transcript_id(monkeypatch): +def test_ask_calls_llm_gateway_with_transcript_id(monkeypatch, mocker): # Stub the OpenAI SDK before the template module imports it. - fake_openai = MagicMock() - msg = MagicMock() + fake_openai = mocker.MagicMock() + msg = mocker.MagicMock() msg.content = "It's about wildfires." - fake_openai.OpenAI.return_value.chat.completions.create.return_value = MagicMock( - choices=[MagicMock(message=msg)] + fake_openai.OpenAI.return_value.chat.completions.create.return_value = mocker.MagicMock( + choices=[mocker.MagicMock(message=msg)] ) monkeypatch.setitem(sys.modules, "openai", fake_openai) - app, _aai, _api = _load_app(monkeypatch) + app, _aai, _api = _load_app(monkeypatch, mocker) client = TestClient(app) resp = client.post("/api/ask", json={"transcript_id": "t-1", "question": "What is this about?"}) assert resp.status_code == 200 @@ -103,16 +102,16 @@ def test_ask_calls_llm_gateway_with_transcript_id(monkeypatch): assert kwargs["extra_body"] == {"transcript_id": "t-1"} -def test_index_route_serves_page(monkeypatch): - app, _aai, _api = _load_app(monkeypatch) +def test_index_route_serves_page(monkeypatch, mocker): + app, _aai, _api = _load_app(monkeypatch, mocker) client = TestClient(app) resp = client.get("/") assert resp.status_code == 200 assert " the bundled sample assert resp.status_code == 200 @@ -139,8 +138,8 @@ def test_transcribe_url_defaults_to_sample(monkeypatch): assert "wildfires" in submitted -def test_status_returns_completed_transcript(monkeypatch): - app, _aai, _api = _load_app(monkeypatch) +def test_status_returns_completed_transcript(monkeypatch, mocker): + app, _aai, _api = _load_app(monkeypatch, mocker) client = TestClient(app) resp = client.get("/api/status/t-123") assert resp.status_code == 200 @@ -149,63 +148,63 @@ def test_status_returns_completed_transcript(monkeypatch): assert body["transcript"]["text"] == "hello world" -def test_status_returns_processing_when_not_done(monkeypatch): +def test_status_returns_processing_when_not_done(monkeypatch, mocker): # A poll endpoint MUST report a non-terminal status without blocking. This guards # against regressing to the blocking aai.Transcript.get_by_id() (which would only # ever return completed/error, making this branch dead code). - app, _aai, fake_api = _load_app(monkeypatch) - fake_api.get_transcript.return_value = MagicMock(status="processing", error=None) + app, _aai, fake_api = _load_app(monkeypatch, mocker) + fake_api.get_transcript.return_value = mocker.MagicMock(status="processing", error=None) client = TestClient(app) resp = client.get("/api/status/t-xyz") assert resp.status_code == 200 assert resp.json() == {"status": "processing"} -def test_status_returns_502_on_error(monkeypatch): - app, _aai, fake_api = _load_app(monkeypatch) - fake_api.get_transcript.return_value = MagicMock(status="error", error="bad audio") +def test_status_returns_502_on_error(monkeypatch, mocker): + app, _aai, fake_api = _load_app(monkeypatch, mocker) + fake_api.get_transcript.return_value = mocker.MagicMock(status="error", error="bad audio") client = TestClient(app) resp = client.get("/api/status/t-bad") assert resp.status_code == 502 -def test_submit_surfaces_sdk_exception_as_502_not_500(monkeypatch): +def test_submit_surfaces_sdk_exception_as_502_not_500(monkeypatch, mocker): # A missing/invalid key makes the SDK raise; the endpoint must return a clean 502, # not let the exception bubble into a 500 traceback. - app, fake, _api = _load_app(monkeypatch) + app, fake, _api = _load_app(monkeypatch, mocker) fake.Transcriber.return_value.submit.side_effect = ValueError("Please provide an API key") client = TestClient(app) resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) assert resp.status_code == 502 -def test_status_surfaces_sdk_exception_as_502_not_500(monkeypatch): - app, _aai, fake_api = _load_app(monkeypatch) +def test_status_surfaces_sdk_exception_as_502_not_500(monkeypatch, mocker): + app, _aai, fake_api = _load_app(monkeypatch, mocker) fake_api.get_transcript.side_effect = RuntimeError("network down") client = TestClient(app) resp = client.get("/api/status/t-x") assert resp.status_code == 502 -def test_submit_returns_502_when_no_id(monkeypatch): +def test_submit_returns_502_when_no_id(monkeypatch, mocker): # The SDK returned a transcript object without an id — surface a clean 502. - app, fake, _api = _load_app(monkeypatch) - fake.Transcriber.return_value.submit.return_value = MagicMock(id=None) + app, fake, _api = _load_app(monkeypatch, mocker) + fake.Transcriber.return_value.submit.return_value = mocker.MagicMock(id=None) client = TestClient(app) resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) assert resp.status_code == 502 assert "did not return an id" in resp.json()["detail"] -def test_ask_surfaces_gateway_error_as_502(monkeypatch): +def test_ask_surfaces_gateway_error_as_502(monkeypatch, mocker): # A gateway failure (e.g. no plan access) becomes a clean 502, not a 500 traceback. - fake_openai = MagicMock() + fake_openai = mocker.MagicMock() fake_openai.OpenAI.return_value.chat.completions.create.side_effect = RuntimeError( "no plan access" ) monkeypatch.setitem(sys.modules, "openai", fake_openai) - app, _aai, _api = _load_app(monkeypatch) + app, _aai, _api = _load_app(monkeypatch, mocker) client = TestClient(app) resp = client.post("/api/ask", json={"transcript_id": "t-1", "question": "what?"}) assert resp.status_code == 502 diff --git a/tests/test_keys.py b/tests/test_keys.py index b4c00d14..2fb306f9 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -1,5 +1,4 @@ import json -from unittest.mock import patch from typer.testing import CliRunner @@ -21,7 +20,7 @@ def _login_result(): ) -def test_keys_list_flattens_tokens(): +def test_keys_list_flattens_tokens(mocker): _auth() projects = [ { @@ -29,15 +28,15 @@ def test_keys_list_flattens_tokens(): "tokens": [{"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}], } ] - with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): - result = runner.invoke(app, ["keys", "list", "--json"]) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + result = runner.invoke(app, ["keys", "list", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data[0]["id"] == 10 assert "sk_abcdef1234" not in result.output # api key is masked -def test_keys_list_renders_table_human(monkeypatch): +def test_keys_list_renders_table_human(monkeypatch, mocker): _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) projects = [ @@ -46,8 +45,8 @@ def test_keys_list_renders_table_human(monkeypatch): "tokens": [{"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}], } ] - with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): - result = runner.invoke(app, ["keys", "list"]) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + result = runner.invoke(app, ["keys", "list"]) assert result.exit_code == 0 assert "ci" in result.output and "Default" in result.output assert "sk_abcdef1234" not in result.output # masked in the human table too @@ -61,39 +60,43 @@ def test_keys_shape_helpers_filter_invalid_values(): assert keys._project_id({"id": object()}) is None -def test_keys_create_rejects_missing_default_project_object(): +def test_keys_create_rejects_missing_default_project_object(mocker): _auth() - with ( - patch("aai_cli.commands.keys.ams.list_projects", return_value=[{"project": "bad"}]), - patch("aai_cli.commands.keys.ams.create_token") as create, - ): - result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + mocker.patch( + "aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=[{"project": "bad"}] + ) + create = mocker.patch("aai_cli.commands.keys.ams.create_token", autospec=True) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) assert result.exit_code == 1 create.assert_not_called() -def test_keys_create_rejects_default_project_without_int_id(): +def test_keys_create_rejects_default_project_without_int_id(mocker): _auth() - with ( - patch("aai_cli.commands.keys.ams.list_projects", return_value=[{"project": {"id": "bad"}}]), - patch("aai_cli.commands.keys.ams.create_token") as create, - ): - result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + mocker.patch( + "aai_cli.commands.keys.ams.list_projects", + autospec=True, + return_value=[{"project": {"id": "bad"}}], + ) + create = mocker.patch("aai_cli.commands.keys.ams.create_token", autospec=True) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) assert result.exit_code == 1 create.assert_not_called() -def test_keys_list_without_session_runs_login(monkeypatch): +def test_keys_list_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) - with patch("aai_cli.commands.keys.ams.list_projects", return_value=[]) as list_projects: - result = runner.invoke(app, ["keys", "list", "--json"]) + list_projects = mocker.patch( + "aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=[] + ) + result = runner.invoke(app, ["keys", "list", "--json"]) assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} list_projects.assert_not_called() assert "Run the same command again" in result.output -def test_keys_create_prints_new_key(): +def test_keys_create_prints_new_key(mocker): _auth() projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] created = { @@ -103,17 +106,17 @@ def test_keys_create_prints_new_key(): "api_key": "sk_newkey9999", "is_disabled": False, } - with ( - patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), - patch("aai_cli.commands.keys.ams.create_token", return_value=created) as create, - ): - result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + create = mocker.patch( + "aai_cli.commands.keys.ams.create_token", autospec=True, return_value=created + ) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) assert result.exit_code == 0 assert "sk_newkey9999" in result.output create.assert_called_once_with(42, 1, "ci", "jwt") -def test_keys_list_falls_back_to_token_name_when_name_is_null(): +def test_keys_list_falls_back_to_token_name_when_name_is_null(mocker): _auth() projects = [ { @@ -129,22 +132,22 @@ def test_keys_list_falls_back_to_token_name_when_name_is_null(): ], } ] - with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): - result = runner.invoke(app, ["keys", "list", "--json"]) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + result = runner.invoke(app, ["keys", "list", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data[0]["name"] == "AssemblyAI CLI" -def test_keys_rename_calls_ams(): +def test_keys_rename_calls_ams(mocker): _auth() - with patch("aai_cli.commands.keys.ams.rename_token") as rename: - result = runner.invoke(app, ["keys", "rename", "10", "prod"]) + rename = mocker.patch("aai_cli.commands.keys.ams.rename_token", autospec=True) + result = runner.invoke(app, ["keys", "rename", "10", "prod"]) assert result.exit_code == 0 rename.assert_called_once_with(42, 10, "prod", "jwt") -def test_keys_list_renders_human_table(): +def test_keys_list_renders_human_table(mocker): # Human-readable (non-JSON) render path: the table includes names, project, and # the disabled flag rendered as yes/no. _auth() @@ -157,11 +160,9 @@ def test_keys_list_renders_human_table(): ], } ] - with ( - patch("aai_cli.output.resolve_json", return_value=False), - patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), - ): - result = runner.invoke(app, ["keys", "list"]) + mocker.patch("aai_cli.output.resolve_json", autospec=True, return_value=False) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=projects) + result = runner.invoke(app, ["keys", "list"]) assert result.exit_code == 0 assert "ci" in result.output assert "Default" in result.output @@ -170,27 +171,25 @@ def test_keys_list_renders_human_table(): assert "sk_abcdef1234" not in result.output # masked -def test_keys_create_rejects_empty_project_list(): +def test_keys_create_rejects_empty_project_list(mocker): # No projects at all -> clean APIError, create_token never called. _auth() - with ( - patch("aai_cli.commands.keys.ams.list_projects", return_value=[]), - patch("aai_cli.commands.keys.ams.create_token") as create, - ): - result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True, return_value=[]) + create = mocker.patch("aai_cli.commands.keys.ams.create_token", autospec=True) + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) assert result.exit_code == 1 create.assert_not_called() -def test_keys_create_with_explicit_project_skips_lookup(): +def test_keys_create_with_explicit_project_skips_lookup(mocker): # Passing --project bypasses the default-project lookup entirely. _auth() created = {"id": 5, "name": "ci", "api_key": "sk_explicit999", "is_disabled": False} - with ( - patch("aai_cli.commands.keys.ams.list_projects") as list_projects, - patch("aai_cli.commands.keys.ams.create_token", return_value=created) as create, - ): - result = runner.invoke(app, ["keys", "create", "--name", "ci", "--project", "9"]) + list_projects = mocker.patch("aai_cli.commands.keys.ams.list_projects", autospec=True) + create = mocker.patch( + "aai_cli.commands.keys.ams.create_token", autospec=True, return_value=created + ) + result = runner.invoke(app, ["keys", "create", "--name", "ci", "--project", "9"]) assert result.exit_code == 0 list_projects.assert_not_called() create.assert_called_once_with(42, 9, "ci", "jwt") diff --git a/tests/test_login.py b/tests/test_login.py index efd3f848..0432e2f3 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from typer.testing import CliRunner from aai_cli import config @@ -13,36 +11,36 @@ def _fake_login_result(key="sk_from_oauth"): return LoginResult(api_key=key, session_jwt="jwt_x", session_token="tok_x", account_id=7) -def test_login_with_api_key_flag_stores_key(): +def test_login_with_api_key_flag_stores_key(mocker): import json - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login", "--api-key", "sk_flag", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_flag", "--json"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag" assert json.loads(result.output)["authenticated"] is True # pins the success flag -def test_login_rejects_invalid_key(): - with patch("aai_cli.commands.login.client.validate_key", return_value=False): - result = runner.invoke(app, ["login", "--api-key", "sk_bad"]) +def test_login_rejects_invalid_key(mocker): + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=False) + result = runner.invoke(app, ["login", "--api-key", "sk_bad"]) assert result.exit_code != 0 assert config.get_api_key("default") is None -def test_login_stores_under_named_profile(): - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) +def test_login_stores_under_named_profile(mocker): + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) assert result.exit_code == 0 assert config.get_api_key("staging") == "sk_s" -def test_whoami_reports_authenticated(): +def test_whoami_reports_authenticated(mocker): import json config.set_api_key("default", "sk_1234567890") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["profile"] == "default" @@ -50,11 +48,11 @@ def test_whoami_reports_authenticated(): assert data["api_key"].startswith("sk_") and "…" in data["api_key"] -def test_whoami_human_render_shows_detail_rows(monkeypatch): +def test_whoami_human_render_shows_detail_rows(monkeypatch, mocker): config.set_api_key("default", "sk_1234567890") monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["whoami"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami"]) assert result.exit_code == 0 # The shared borderless detail grid: labelled rows, no JSON, key masked. assert "Profile" in result.output and "default" in result.output @@ -62,10 +60,12 @@ def test_whoami_human_render_shows_detail_rows(monkeypatch): assert "…" in result.output and '"profile"' not in result.output -def test_whoami_unauthenticated_runs_login(monkeypatch): +def test_whoami_unauthenticated_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) - with patch("aai_cli.commands.login.client.validate_key", return_value=True) as validate: - result = runner.invoke(app, ["whoami", "--json"]) + validate = mocker.patch( + "aai_cli.commands.login.client.validate_key", autospec=True, return_value=True + ) + result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" validate.assert_not_called() @@ -97,20 +97,20 @@ def test_login_oauth_persists_session(monkeypatch): assert config.get_account_id("default") == 7 -def test_login_api_key_flag_does_not_persist_session(): - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) +def test_login_api_key_flag_does_not_persist_session(mocker): + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) assert result.exit_code == 0 assert config.get_session("default") is None -def test_login_api_key_flag_clears_prior_browser_session(): +def test_login_api_key_flag_clears_prior_browser_session(mocker): # A profile previously authenticated via browser becomes api-key-only on # re-login; the stale session must not linger and silently authenticate # account self-service commands as the previous identity. config.set_session("default", session_jwt="old_j", session_token="old_t", account_id=5) - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) assert result.exit_code == 0 assert config.get_session("default") is None assert config.get_account_id("default") is None @@ -136,13 +136,13 @@ def boom(): assert config.get_api_key("default") is None -def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): +def test_login_api_key_flag_still_bypasses_oauth(monkeypatch, mocker): monkeypatch.setattr( "aai_cli.context.run_login_flow", lambda: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")), ) - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag2" @@ -162,53 +162,54 @@ def test_sandbox_flag_is_shortcut_for_env(monkeypatch): assert config.get_profile_env("default") == "sandbox000" -def test_whoami_reports_env(): +def test_whoami_reports_env(mocker): import json config.set_api_key("default", "sk_1234567890") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["--env", "production", "whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["--env", "production", "whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["env"] == "production" -def test_root_callback_keeps_profile_env_without_sandbox(): +def test_root_callback_keeps_profile_env_without_sandbox(mocker): # Without --sandbox the profile's own env must stand (pins `sandbox and env is # None`: an `or` would force sandbox000 onto every default invocation). import json config.set_api_key("default", "sk_1234567890") config.set_profile_env("default", "production") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 0 assert json.loads(result.output)["env"] == "production" -def test_root_callback_sandbox_overrides_profile_env(): +def test_root_callback_sandbox_overrides_profile_env(mocker): # --sandbox forces sandbox000 even when the profile is bound elsewhere (pins the # `env is None` arm: an `is not None` would leave the profile env in place). import json config.set_api_key("default", "sk_1234567890") config.set_profile_env("default", "production") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["--sandbox", "whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["--sandbox", "whoami", "--json"]) assert result.exit_code == 0 # A profile/env mismatch warning prints to stderr first; the JSON is the last line. payload = json.loads(result.output.strip().splitlines()[-1]) assert payload["env"] == "sandbox000" -def test_unknown_env_exits_2(): +def test_unknown_env_exits_2(mocker): # Routed through the standard error path. Output is human-by-default (the root # callback can't see a per-command --json, and we never auto-switch to JSON on a # pipe/agent), so it's the "Error:" + "Suggestion:" pair on stderr, not a JSON blob — # regardless of whether stdout is a TTY. + is_agentic = mocker.patch("aai_cli.output._is_agentic", autospec=True) for agentic in (True, False): - with patch("aai_cli.output._is_agentic", return_value=agentic): - result = runner.invoke(app, ["--env", "bogus", "whoami"]) + is_agentic.return_value = agentic + result = runner.invoke(app, ["--env", "bogus", "whoami"]) assert result.exit_code == 2 assert "Error:" in result.output assert "Suggestion:" in result.output @@ -216,13 +217,13 @@ def test_unknown_env_exits_2(): assert '"error"' not in result.output # never the JSON shape -def test_env_override_prints_warning_to_stderr(): +def test_env_override_prints_warning_to_stderr(mocker): # The root callback warns when an explicit --env contradicts the profile's stored # env (the stored key was minted for a different environment). config.set_api_key("default", "sk_1234567890") config.set_profile_env("default", "production") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["--env", "sandbox000", "whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["--env", "sandbox000", "whoami", "--json"]) assert result.exit_code == 0 assert "may be rejected by sandbox000" in result.output @@ -236,41 +237,39 @@ def test_rejected_api_key_has_suggestion(monkeypatch): assert "Check the key and retry" in result.output -def test_whoami_reports_session_and_account(): +def test_whoami_reports_session_and_account(mocker): import json config.set_api_key("default", "sk_1234567890") config.set_session("default", session_jwt="j", session_token="t", account_id=77) - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["account_id"] == 77 assert data["session"] == "stored" -def test_whoami_session_none_without_browser_login(): +def test_whoami_session_none_without_browser_login(mocker): import json config.set_api_key("default", "sk_1234567890") - with patch("aai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["whoami", "--json"]) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["session"] == "none" assert data["account_id"] is None -def test_whoami_renders_human_table_reachable(): +def test_whoami_renders_human_table_reachable(mocker): # Human-readable (non-JSON) render path: the grid lists profile, env, masked key, # a reachable status, and the account/session rows. config.set_api_key("default", "sk_1234567890") config.set_session("default", session_jwt="j", session_token="t", account_id=77) - with ( - patch("aai_cli.output.resolve_json", return_value=False), - patch("aai_cli.commands.login.client.validate_key", return_value=True), - ): - result = runner.invoke(app, ["whoami"]) + mocker.patch("aai_cli.output.resolve_json", autospec=True, return_value=False) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["whoami"]) assert result.exit_code == 0 assert "Profile" in result.output assert "default" in result.output @@ -280,15 +279,13 @@ def test_whoami_renders_human_table_reachable(): assert "sk_1234567890" not in result.output # masked -def test_whoami_renders_human_table_rejected_key(): +def test_whoami_renders_human_table_rejected_key(mocker): # The non-JSON render path also covers the "key rejected" branch and the # account/session "none" fallbacks (the em-dash placeholder). config.set_api_key("default", "sk_1234567890") - with ( - patch("aai_cli.output.resolve_json", return_value=False), - patch("aai_cli.commands.login.client.validate_key", return_value=False), - ): - result = runner.invoke(app, ["whoami"]) + mocker.patch("aai_cli.output.resolve_json", autospec=True, return_value=False) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=False) + result = runner.invoke(app, ["whoami"]) assert result.exit_code == 0 assert "key rejected" in result.output assert "none" in result.output diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index d9e89642..93bf853f 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -1,5 +1,4 @@ import json -from unittest.mock import patch from typer.testing import CliRunner @@ -25,7 +24,7 @@ def _human(monkeypatch): monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) -def test_sessions_list_renders_rows(): +def test_sessions_list_renders_rows(mocker): _auth() payload = { "page_details": {"has_more": False}, @@ -39,8 +38,10 @@ def test_sessions_list_renders_rows(): } ], } - with patch("aai_cli.commands.sessions.ams.list_streaming", return_value=payload): - result = runner.invoke(app, ["sessions", "list", "--json"]) + mocker.patch( + "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value=payload + ) + result = runner.invoke(app, ["sessions", "list", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data[0]["session_id"] == "s_1" @@ -51,7 +52,7 @@ def test_session_rows_filter_invalid_items(): assert sessions._session_rows([{"session_id": "s_1"}, "bad"]) == [{"session_id": "s_1"}] -def test_sessions_list_renders_table_human(monkeypatch): +def test_sessions_list_renders_table_human(monkeypatch, mocker): _auth() _human(monkeypatch) payload = { @@ -65,8 +66,10 @@ def test_sessions_list_renders_table_human(monkeypatch): } ] } - with patch("aai_cli.commands.sessions.ams.list_streaming", return_value=payload): - result = runner.invoke(app, ["sessions", "list"]) + mocker.patch( + "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value=payload + ) + result = runner.invoke(app, ["sessions", "list"]) assert result.exit_code == 0 assert "s_1" in result.output and "universal" in result.output # The created/duration columns must render their values (pins `value or ""`: an @@ -75,17 +78,17 @@ def test_sessions_list_renders_table_human(monkeypatch): assert "12.0" in result.output -def test_sessions_list_passes_status_filter(): +def test_sessions_list_passes_status_filter(mocker): _auth() - with patch( - "aai_cli.commands.sessions.ams.list_streaming", return_value={"data": []} - ) as list_streaming: - result = runner.invoke(app, ["sessions", "list", "--status", "error", "--limit", "5"]) + list_streaming = mocker.patch( + "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value={"data": []} + ) + result = runner.invoke(app, ["sessions", "list", "--status", "error", "--limit", "5"]) assert result.exit_code == 0 list_streaming.assert_called_once_with("jwt", limit=5, status="error") -def test_sessions_get_renders_detail(monkeypatch): +def test_sessions_get_renders_detail(monkeypatch, mocker): _auth() _human(monkeypatch) detail = { @@ -95,18 +98,20 @@ def test_sessions_get_renders_detail(monkeypatch): "audio_duration_sec": 30.0, "error": None, } - with patch("aai_cli.commands.sessions.ams.get_streaming", return_value=detail): - result = runner.invoke(app, ["sessions", "get", "s_1"]) + mocker.patch("aai_cli.commands.sessions.ams.get_streaming", autospec=True, return_value=detail) + result = runner.invoke(app, ["sessions", "get", "s_1"]) assert result.exit_code == 0 assert "s_1" in result.output and "universal" in result.output # Field labels are humanized (underscores -> spaces) for the detail view. assert "speech model" in result.output and "speech_model" not in result.output -def test_sessions_without_session_runs_login(monkeypatch): +def test_sessions_without_session_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) - with patch("aai_cli.commands.sessions.ams.list_streaming", return_value={"data": []}) as list_: - result = runner.invoke(app, ["sessions", "list", "--json"]) + list_ = mocker.patch( + "aai_cli.commands.sessions.ams.list_streaming", autospec=True, return_value={"data": []} + ) + result = runner.invoke(app, ["sessions", "list", "--json"]) assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} list_.assert_not_called() diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 57842344..79a021df 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -1,5 +1,4 @@ import json -from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -20,8 +19,8 @@ def _login_result(): ) -def _fake_transcript(): - t = MagicMock() +def _fake_transcript(mocker): + t = mocker.MagicMock() t.id = "t_1" t.text = "hello world" t.status = "completed" @@ -44,12 +43,14 @@ def _enum_or_str(value): return getattr(value, "value", value) -def test_transcribe_sample_prints_text(): +def test_transcribe_sample_prints_text(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - result = runner.invoke(app, ["transcribe", "--sample"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "--sample"]) assert result.exit_code == 0 assert "hello world" in result.output audio_arg = tx.call_args.args[1] @@ -62,69 +63,89 @@ def test_transcribe_requires_source(): assert result.exit_code == 2 -def test_transcribe_passes_speaker_labels(): +def test_transcribe_passes_speaker_labels(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) assert tx.call_args.kwargs["config"].speaker_labels is True -def test_transcribe_json_output(): +def test_transcribe_json_output(mocker): _auth() - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert '"id": "t_1"' in result.output -def test_transcribe_unauthenticated_runs_login_then_transcribes(monkeypatch): +def test_transcribe_unauthenticated_runs_login_then_transcribes(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - result = runner.invoke(app, ["transcribe", "--sample"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "--sample"]) assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" tx.assert_not_called() assert "Run the same command again" in result.output -def test_transcribe_output_text_field(): +def test_transcribe_output_text_field(mocker): _auth() - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text"]) assert result.exit_code == 0 assert result.output.strip() == "hello world" # raw text, pipe-friendly -def test_transcribe_output_id_field(): +def test_transcribe_output_id_field(mocker): _auth() - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--output", "id"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--output", "id"]) assert result.exit_code == 0 assert result.output.strip() == "t_1" -def test_transcribe_output_srt_field(): +def test_transcribe_output_srt_field(mocker): _auth() - t = _fake_transcript() + t = _fake_transcript(mocker) t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): - result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "srt"]) + mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True, return_value=t) + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "srt"]) assert result.exit_code == 0 assert "00:00:00,000 --> 00:00:02,000" in result.output # SRT body, pipe-friendly t.export_subtitles_srt.assert_called_once() -def test_transcribe_output_invalid_exits_2(): +def test_transcribe_output_invalid_exits_2(mocker): _auth() - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "bogus"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "bogus"]) assert result.exit_code == 2 # unknown field rejected -def test_transcribe_reads_audio_from_stdin(monkeypatch): +def test_transcribe_reads_audio_from_stdin(monkeypatch, mocker): import pathlib _auth() @@ -133,7 +154,7 @@ def test_transcribe_reads_audio_from_stdin(monkeypatch): def fake_transcribe(api_key, audio, *, config): # The piped bytes are buffered to a temp file the SDK can upload. seen["bytes"] = pathlib.Path(audio).read_bytes() - return _fake_transcript() + return _fake_transcript(mocker) monkeypatch.setattr("aai_cli.commands.transcribe.client.transcribe", fake_transcribe) result = runner.invoke(app, ["transcribe", "-", "-o", "text"], input=b"RIFFfake-wav-bytes") @@ -148,20 +169,20 @@ def test_transcribe_empty_stdin_exits_2(): assert result.exit_code == 2 # nothing piped -> usage error -def test_transcribe_status_renders_enum_value(): +def test_transcribe_status_renders_enum_value(mocker): import assemblyai as aai _auth() - t = _fake_transcript() + t = _fake_transcript(mocker) t.status = aai.TranscriptStatus.completed t.json_response = None - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) + mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True, return_value=t) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert result.exit_code == 0 assert '"status": "completed"' in result.output -def test_transcribe_prompt_transforms_json(monkeypatch): +def test_transcribe_prompt_transforms_json(monkeypatch, mocker): _auth() seen = {} @@ -171,9 +192,13 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens, transcr seen["transcript_id"] = transcript_id return "a short summary" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) - result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize", "--json"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["text"] == "hello world" # raw transcript still present in JSON @@ -184,7 +209,7 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens, transcr assert seen["model"] == "claude-haiku-4-5-20251001" -def test_transcribe_chains_multiple_gateway_prompts(monkeypatch): +def test_transcribe_chains_multiple_gateway_prompts(monkeypatch, mocker): _auth() calls = [] @@ -196,20 +221,24 @@ def fake_transform( ) return f"out({prompt})" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) - result = runner.invoke( - app, - [ - "transcribe", - "audio.mp3", - "--json", - "--llm", - "summarize", - "--llm", - "translate", - ], - ) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) + result = runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--json", + "--llm", + "summarize", + "--llm", + "translate", + ], + ) assert result.exit_code == 0 # Step 1 runs over the transcript; step 2 chains over step 1's output. assert calls[0]["transcript_id"] == "t_1" and calls[0]["transcript_text"] is None @@ -221,68 +250,80 @@ def fake_transform( ] -def test_transcribe_prompt_human_shows_only_transform(monkeypatch): +def test_transcribe_prompt_human_shows_only_transform(monkeypatch, mocker): _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - monkeypatch.setattr( - "aai_cli.commands.transcribe.llm.transform_transcript", - lambda *a, **k: "TRANSFORMED", - ) - result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + monkeypatch.setattr( + "aai_cli.commands.transcribe.llm.transform_transcript", + lambda *a, **k: "TRANSFORMED", + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize"]) assert result.exit_code == 0 assert "TRANSFORMED" in result.output assert "hello world" not in result.output # human mode shows the transform only -def test_transcribe_chained_prompts_human_labels_each_step(monkeypatch): +def test_transcribe_chained_prompts_human_labels_each_step(monkeypatch, mocker): # Human render of a multi-step chain labels each step (the single-step path # prints only the lone output; this one enumerates "Step N"). _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - monkeypatch.setattr( - "aai_cli.commands.transcribe.llm.transform_transcript", - lambda *a, **k: f"out({k['prompt']})", - ) - result = runner.invoke( - app, - ["transcribe", "audio.mp3", "--llm", "summarize", "--llm", "translate"], - ) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + monkeypatch.setattr( + "aai_cli.commands.transcribe.llm.transform_transcript", + lambda *a, **k: f"out({k['prompt']})", + ) + result = runner.invoke( + app, + ["transcribe", "audio.mp3", "--llm", "summarize", "--llm", "translate"], + ) assert result.exit_code == 0 assert "Step 1 — summarize:" in result.output assert "Step 2 — translate:" in result.output assert "out(summarize)" in result.output -def test_transcribe_prompt_biases_speech_model(): +def test_transcribe_prompt_biases_speech_model(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) assert result.exit_code == 0 # --prompt is the speech-model prompt, forwarded to the transcription call. assert tx.call_args.kwargs["config"].prompt == "expect medical terms" -def test_transcribe_maps_analysis_flags(): +def test_transcribe_maps_analysis_flags(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke( - app, - [ - "transcribe", - "audio.mp3", - "--summarization", - "--summary-type", - "bullets", - "--sentiment-analysis", - "--topic-detection", - ], - ) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--summarization", + "--summary-type", + "bullets", + "--sentiment-analysis", + "--topic-detection", + ], + ) cfg = tx.call_args.kwargs["config"] assert cfg.raw.summarization is True assert cfg.raw.summary_type == "bullets" @@ -290,21 +331,23 @@ def test_transcribe_maps_analysis_flags(): assert cfg.raw.iab_categories is True -def test_transcribe_redact_pii_policy_csv(): +def test_transcribe_redact_pii_policy_csv(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke( - app, - [ - "transcribe", - "audio.mp3", - "--redact-pii", - "--redact-pii-policy", - "person_name,phone_number", - ], - ) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--redact-pii", + "--redact-pii-policy", + "person_name,phone_number", + ], + ) cfg = tx.call_args.kwargs["config"] assert cfg.raw.redact_pii is True assert [_enum_or_str(p) for p in cfg.raw.redact_pii_policies] == [ @@ -313,54 +356,64 @@ def test_transcribe_redact_pii_policy_csv(): ] -def test_transcribe_config_escape_hatch(): +def test_transcribe_config_escape_hatch(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke(app, ["transcribe", "audio.mp3", "--config", "speech_threshold=0.5"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + runner.invoke(app, ["transcribe", "audio.mp3", "--config", "speech_threshold=0.5"]) assert tx.call_args.kwargs["config"].raw.speech_threshold == 0.5 -def test_transcribe_unknown_config_field_exits_2(): +def test_transcribe_unknown_config_field_exits_2(mocker): _auth() - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) + mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) assert result.exit_code == 2 assert "bogus" in result.output -def test_transcribe_webhook_auth_header(): +def test_transcribe_webhook_auth_header(mocker): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - runner.invoke( - app, - [ - "transcribe", - "audio.mp3", - "--webhook-url", - "https://example.com/hook", - "--webhook-auth-header", - "X-Token:secret", - ], - ) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--webhook-url", + "https://example.com/hook", + "--webhook-auth-header", + "X-Token:secret", + ], + ) cfg = tx.call_args.kwargs["config"] assert cfg.raw.webhook_url == "https://example.com/hook" assert cfg.raw.webhook_auth_header_name == "X-Token" assert cfg.raw.webhook_auth_header_value == "secret" -def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path): +def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, mocker, tmp_path): _auth() fake = tmp_path / "vid.m4a" fake.write_bytes(b"x") monkeypatch.setattr("aai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake) - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ) as tx: - result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) + tx = mocker.patch( + "aai_cli.commands.transcribe.client.transcribe", + autospec=True, + return_value=_fake_transcript(mocker), + ) + result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) assert result.exit_code == 0 assert tx.call_args.args[1] == str(fake) # transcribed the downloaded local file @@ -412,14 +465,14 @@ def _boom(*a, **k): assert '"transcript_id": transcript.id' in result.output -def test_transcribe_renders_summary_human(monkeypatch): +def test_transcribe_renders_summary_human(monkeypatch, mocker): _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - t = _fake_transcript() + t = _fake_transcript(mocker) t.summary = "three bullet summary" t.chapters = [] - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) + mocker.patch("aai_cli.commands.transcribe.client.transcribe", autospec=True, return_value=t) + result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) assert result.exit_code == 0 assert "Summary:" in result.output assert "three bullet summary" in result.output diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index bc320392..e53ab24c 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -1,5 +1,4 @@ import json -from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -16,51 +15,59 @@ def _login_result(): ) -def test_get_prints_transcript_text(): +def test_get_prints_transcript_text(mocker): config.set_api_key("default", "sk_live") - fake = MagicMock() + fake = mocker.MagicMock() fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["transcripts", "get", "t_42"]) + mocker.patch( + "aai_cli.commands.transcripts.client.get_transcript", autospec=True, return_value=fake + ) + result = runner.invoke(app, ["transcripts", "get", "t_42"]) assert result.exit_code == 0 assert "retrieved text" in result.output -def test_get_output_text_prints_raw(): +def test_get_output_text_prints_raw(mocker): config.set_api_key("default", "sk_live") - fake = MagicMock() + fake = mocker.MagicMock() fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "text"]) + mocker.patch( + "aai_cli.commands.transcripts.client.get_transcript", autospec=True, return_value=fake + ) + result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "text"]) assert result.exit_code == 0 assert result.output.strip() == "retrieved text" -def test_get_output_id_prints_id(): +def test_get_output_id_prints_id(mocker): config.set_api_key("default", "sk_live") - fake = MagicMock() + fake = mocker.MagicMock() fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "id"]) + mocker.patch( + "aai_cli.commands.transcripts.client.get_transcript", autospec=True, return_value=fake + ) + result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "id"]) assert result.exit_code == 0 assert result.output.strip() == "t_42" -def test_get_json_emits_full_payload(): +def test_get_json_emits_full_payload(mocker): config.set_api_key("default", "sk_live") - fake = MagicMock() + fake = mocker.MagicMock() fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" fake.json_response = None # falls back to the compact summary - with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["transcripts", "get", "t_42", "--json"]) + mocker.patch( + "aai_cli.commands.transcripts.client.get_transcript", autospec=True, return_value=fake + ) + result = runner.invoke(app, ["transcripts", "get", "t_42", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["id"] == "t_42" @@ -73,50 +80,56 @@ def test_get_output_invalid_field_exits_2(): assert result.exit_code == 2 -def test_list_renders_rows(): +def test_list_renders_rows(mocker): config.set_api_key("default", "sk_live") rows = [{"id": "t1", "status": "completed"}, {"id": "t2", "status": "processing"}] - with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): - result = runner.invoke(app, ["transcripts", "list", "--json"]) + mocker.patch( + "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows + ) + result = runner.invoke(app, ["transcripts", "list", "--json"]) assert result.exit_code == 0 assert "t1" in result.output and "t2" in result.output -def test_list_unauthenticated_runs_login(monkeypatch): +def test_list_unauthenticated_runs_login(monkeypatch, mocker): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) rows = [{"id": "t1", "status": "completed"}] - with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows) as list_: - result = runner.invoke(app, ["transcripts", "list", "--json"]) + list_ = mocker.patch( + "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows + ) + result = runner.invoke(app, ["transcripts", "list", "--json"]) assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" list_.assert_not_called() assert "Run the same command again" in result.output -def test_list_human_mode_renders_table(monkeypatch): +def test_list_human_mode_renders_table(monkeypatch, mocker): config.set_api_key("default", "sk_live") monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] - with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): - result = runner.invoke(app, ["transcripts", "list"]) + mocker.patch( + "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows + ) + result = runner.invoke(app, ["transcripts", "list"]) assert result.exit_code == 0 assert "t1" in result.output # rendered through the Rich table path -def test_get_errored_transcript_exits_nonzero(): +def test_get_errored_transcript_exits_nonzero(mocker): config.set_api_key("default", "sk_live") - from unittest.mock import MagicMock - - fake = MagicMock() + fake = mocker.MagicMock() fake.id = "t_err" fake.status = "error" fake.error = "decode failed" - with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): - result = runner.invoke(app, ["transcripts", "get", "t_err"]) + mocker.patch( + "aai_cli.commands.transcripts.client.get_transcript", autospec=True, return_value=fake + ) + result = runner.invoke(app, ["transcripts", "get", "t_err"]) assert result.exit_code == 1 -def test_list_table_colors_status(monkeypatch): +def test_list_table_colors_status(monkeypatch, mocker): from aai_cli.theme import make_console config.set_api_key("default", "sk_live") @@ -130,8 +143,10 @@ def test_list_table_colors_status(monkeypatch): {"id": "t1", "status": "completed", "created": "2026-01-01"}, {"id": "t2", "status": "error", "created": "2026-01-02"}, ] - with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): - result = runner.invoke(app, ["transcripts", "list"], color=True) + mocker.patch( + "aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=rows + ) + result = runner.invoke(app, ["transcripts", "list"], color=True) assert result.exit_code == 0 assert "completed" in result.output assert "error" in result.output diff --git a/uv.lock b/uv.lock index 24dcf120..043e8abd 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "ruff" }, @@ -85,6 +86,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.409" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "ruff", specifier = ">=0.15.15" }, @@ -1453,6 +1455,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" From c7beae991c3315a9e08aec2205f7c227f27475b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 05:07:24 +0000 Subject: [PATCH 2/4] test: add pytest-randomly to catch order-dependence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Randomizes test order (and seeds RNG) each run, so inter-test state leakage surfaces instead of hiding behind a fixed collection order — relevant here given the process-global active environment and the module-level mutable state in the streaming/template tests. Verified the full default suite passes under multiple seeds with no order-dependence; coverage and snapshot gates are order-independent and unaffected. --- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4e6b07da..4b1a9d05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", "pytest-mock>=3.14.0", + "pytest-randomly>=3.16.0", "hypothesis>=6.155.1", "ruff>=0.15.15", "mypy>=2.1.0", diff --git a/uv.lock b/uv.lock index 043e8abd..b94d639a 100644 --- a/uv.lock +++ b/uv.lock @@ -44,6 +44,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-randomly" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "ruff" }, @@ -87,6 +88,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "pytest-randomly", specifier = ">=3.16.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "ruff", specifier = ">=0.15.15" }, @@ -1467,6 +1469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pytest-randomly" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" From c6e4038bb46acf635f22ead549389d8bc72fd9cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 05:09:31 +0000 Subject: [PATCH 3/4] test: parallelize the suite with pytest-xdist (-n auto) Wire `-n auto` into the check.sh pytest stage, halving the suite wall time (~42s -> ~21s with coverage). Safe because the suite is now order-independent (pytest-randomly), and verified that pytest-cov's per-worker combine preserves the per-test --cov-context=test contexts the diff-scoped mutation gate depends on. --- pyproject.toml | 1 + scripts/check.sh | 6 +++++- uv.lock | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4b1a9d05..db627800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "pytest-cov>=7.1.0", "pytest-mock>=3.14.0", "pytest-randomly>=3.16.0", + "pytest-xdist>=3.6.0", "hypothesis>=6.155.1", "ruff>=0.15.15", "mypy>=2.1.0", diff --git a/scripts/check.sh b/scripts/check.sh index 08ae507a..14b99f03 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -168,7 +168,11 @@ echo "==> pytest (with branch-coverage gate)" # uv run pytest -m e2e # uv run pytest -m install # uv run pytest -m install_script -uv run pytest -q --strict-config --strict-markers -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 +# -n auto parallelizes across CPUs (pytest-xdist); pytest-cov combines per-worker +# data, and the per-test --cov-context=test contexts the mutation gate below relies +# on survive that combine. The suite is order-independent (pytest-randomly), so +# splitting it across workers is safe. +uv run pytest -q --strict-config --strict-markers -n auto -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 echo "==> diff-cover (patch coverage: every changed line must be tested)" # The 90% gate above is project-wide, so new code can ride on the existing suite and diff --git a/uv.lock b/uv.lock index b94d639a..342b3638 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-randomly" }, + { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "ruff" }, @@ -89,6 +90,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-randomly", specifier = ">=3.16.0" }, + { name = "pytest-xdist", specifier = ">=3.6.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "ruff", specifier = ">=0.15.15" }, @@ -626,6 +628,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "fastapi" version = "0.136.3" @@ -1481,6 +1492,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" From 338cd5f92c82418cae5fbfbfc0afbac6f4f05014 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 05:37:14 +0000 Subject: [PATCH 4/4] test: cover the three lines diff-cover flagged on this branch Add focused tests for previously-uncovered branches so the patch-coverage gate (diff-cover vs origin/main) reaches 100%: - agent._resolve_system_prompt: the OSError -> CLIError path when an unreadable --system-prompt-file is given (agent.py). - audit._format_action: the direct known-key match that skips normalization (audit.py). - run_session: the `connect is None` default branch that lazily imports websockets' sync client (agent/session.py), patched so no real socket is opened. --- tests/test_agent_command.py | 17 +++++++++++++++++ tests/test_agent_session.py | 24 ++++++++++++++++++++++++ tests/test_audit_command.py | 2 ++ 3 files changed, 43 insertions(+) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 39c91866..96794f6f 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -290,3 +290,20 @@ def test_unknown_voice_suggests_list_voices(): assert result.exit_code == 2 # JSON error on stderr carries the structured suggestion. assert "--list-voices" in result.output + + +def test_resolve_system_prompt_unreadable_file_raises_clierror(tmp_path): + # An unreadable --system-prompt-file surfaces a clean CLIError (exit 2), not a + # raw OSError traceback. + from pathlib import Path + + import pytest + + from aai_cli.commands import agent + from aai_cli.errors import CLIError + + missing = Path(tmp_path) / "does-not-exist.txt" + with pytest.raises(CLIError) as exc: + agent._resolve_system_prompt("fallback prompt", missing) + assert exc.value.exit_code == 2 + assert "system-prompt-file" in exc.value.message diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 486216a7..ae0821c4 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -457,3 +457,27 @@ def capture(url, **kwargs): connect=capture, ) assert seen["url"] == expected + + +def test_run_session_defaults_to_websockets_sync_connect(monkeypatch): + # With no injected connect, run_session lazily imports websockets' sync client + # (pins the `connect is None` default-import branch). Patch the import target so + # no real socket is opened; an empty message stream ends the loop immediately. + class _CleanWS: + def send(self, _msg): + pass + + def __iter__(self): + return iter(()) + + def close(self): + pass + + monkeypatch.setattr("websockets.sync.client.connect", lambda url, **kwargs: _CleanWS()) + run_session( + "sk_live", + renderer=FakeRenderer(), + player=FakePlayer(), + mic=[], + config=AgentRunConfig(voice="ivy", system_prompt="x", greeting="hi"), + ) diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index 12ef4044..4584ad66 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -102,6 +102,8 @@ def test_audit_human_mode_renders_table(monkeypatch, mocker): def test_audit_helpers_format_edge_cases(): + # A raw action that is already a known dotted key is mapped directly (no normalize). + assert audit._format_action("token.create") == "API key created" assert audit._format_action("account__created") == "Account created" assert audit._format_action("account__tos.accepted") == "Terms accepted" assert audit._format_action("custom_event.name") == "Custom event name"