diff --git a/pyproject.toml b/pyproject.toml index ff6b3e1f..b902cb87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dev = [ "time-machine>=2.19.0", "ty>=0.0.25", "zizmor>=1.24.1", + "inline-snapshot>=0.35.4", ] github-actions = [ "smokeshow>=0.5.0", @@ -84,6 +85,9 @@ addopts = ["--strict-config", "--strict-markers"] xfail_strict = true junit_family = "xunit2" +[tool.inline-snapshot] +format-command = "ruff format --stdin-filename {filename}" + [tool.coverage.run] parallel = true data_file = "coverage/.coverage" diff --git a/tests/domains/test_cli.py b/tests/domains/test_cli.py index f6b56cda..0e1f5d42 100644 --- a/tests/domains/test_cli.py +++ b/tests/domains/test_cli.py @@ -1,6 +1,5 @@ import json from datetime import datetime, timezone -from textwrap import dedent from typing import Any from unittest.mock import patch @@ -8,25 +7,20 @@ import respx import time_machine from httpx import ConnectError, Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.api import CustomDomainStatus from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.commands.domains.rendering import DOMAIN_STATUS from tests.conftest import ConfiguredApp -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() APP_ID = "00000000-0000-4000-8000-000000000002" DOMAIN_ID = "00000000-0000-4000-8000-000000000003" -def _normalize_output(output: str) -> str: - output = dedent(output.replace("\u200b", "")).strip() - return "\n".join(line.rstrip() for line in output.splitlines()) - - def custom_domain(**overrides: Any) -> dict[str, Any]: return { "id": DOMAIN_ID, @@ -120,16 +114,14 @@ def test_domains_list_uses_linked_app_and_renders_rows( result = runner.invoke(app, ["domains", "list"]) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - Domain Status Setup mode Last check +Domain Status Setup mode Last check - new.example.com Live Zero-downtime 4 days ago - old.example.com Pending Standard 4 days ago - """ - ) +new.example.com Live Zero-downtime 4 days ago +old.example.com Pending Standard 4 days ago\ +""") @pytest.mark.respx @@ -166,7 +158,11 @@ def test_domains_list_renders_empty_state( result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID]) assert result.exit_code == 0 - assert "No custom domains found." in result.output + assert result.output == snapshot("""\ +custom domains + +No custom domains found.\ +""") @pytest.mark.respx @@ -195,33 +191,15 @@ def test_domain_status_metadata_is_exhaustive() -> None: @pytest.mark.respx -@pytest.mark.parametrize( - ("overrides", "expected_output"), - [ - ( - { - "status": "internal_dcv_timeout", - "setup_in_progress": False, - "setup_failed": True, - }, - ( - "Correct the DNS records, then run `fastapi cloud domains restart", - "api.example.com`.", - ), - ), - ( - {"status": "internal_dcv_invalid"}, - ("Correct the DNS records shown. We'll check again automatically.",), - ), - ], -) -def test_domains_get_renders_next_action_when_user_intervention_is_required( - overrides: dict[str, Any], - expected_output: tuple[str, ...], +def test_domains_get_renders_restart_action_when_setup_failed( logged_in_cli: None, respx_mock: respx.MockRouter, ) -> None: - domain = custom_domain(**overrides) + domain = custom_domain( + status="internal_dcv_timeout", + setup_in_progress=False, + setup_failed=True, + ) respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( return_value=Response(200, json={"data": [domain], "count": 1}) ) @@ -232,8 +210,97 @@ def test_domains_get_renders_next_action_when_user_intervention_is_required( ) assert result.exit_code == 0 - for text in expected_output: - assert text in result.output + assert result.output == snapshot("""\ +custom domains + +🌐 api.example.com + +⚠️ Restart domain verification + We couldn't verify the domain in time, it's possible the DNS changes may + still be propagating. Please restart the domain verification process. + + To verify ownership and route traffic, add this CNAME record at your DNS + provider: + + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + +💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud). + + Correct the DNS records, then run `fastapi cloud domains restart + api.example.com`.\ +""") + + +@pytest.mark.respx +def test_domains_get_renders_correction_action_when_dns_is_invalid( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain(status="internal_dcv_invalid") + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + + result = runner.invoke( + app, + ["domains", "get", domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert result.output == snapshot("""\ +custom domains + +🌐 api.example.com + +⚠️ Verification needed + The DNS records were found but don't match the expected values. Double-check + your provider settings. + + To verify ownership and route traffic, add this CNAME record at your DNS + provider: + + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + +💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud). + + Correct the DNS records shown. We'll check again automatically.\ +""") + + +@pytest.mark.respx +def test_domains_get_omits_next_action_while_setup_is_in_progress( + logged_in_cli: None, + respx_mock: respx.MockRouter, +) -> None: + domain = custom_domain(dns_records=[]) + respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( + return_value=Response(200, json={"data": [domain], "count": 1}) + ) + + result = runner.invoke( + app, + ["domains", "get", domain["name"], "--app-id", APP_ID], + ) + + assert result.exit_code == 0 + assert result.output == snapshot("""\ +custom domains + +🌐 api.example.com + +⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. + + Add the record below. We'll verify ownership, issue your TLS certificate, + and route traffic automatically.\ +""") def test_domains_get_json_requires_domain(logged_in_cli: None) -> None: @@ -288,64 +355,30 @@ def test_domains_get_prompts_with_selector_and_renders_details( result = runner.invoke(app, ["domains", "get", "--app-id", APP_ID]) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains - - Select the custom domain to get: - ● api.example.com - - Select the custom domain to get: api.example.com + assert result.output == snapshot("""\ +custom domains - 🌐 api.example.com + Select the custom domain to get: + ● api.example.com - ⏳ Waiting domain verification - We are checking your DNS configuration to confirm domain ownership. This - usually takes a few minutes. + Select the custom domain to get: api.example.com - To verify ownership and route traffic, add this CNAME record at your DNS - provider: - - type CNAME - name api - value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. - - 💡 Copy as shown; remove the trailing dot only if your provider rejects it. - Cloudflare: use DNS only (gray cloud). - """ - ) - - -@pytest.mark.respx -def test_domains_get_renders_description_before_dns_records_are_available( - logged_in_cli: None, - respx_mock: respx.MockRouter, -) -> None: - domain = custom_domain(dns_records=[]) - respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( - return_value=Response(200, json={"data": [domain], "count": 1}) - ) - - result = runner.invoke( - app, - ["domains", "get", domain["name"], "--app-id", APP_ID], - ) +🌐 api.example.com - assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains +⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. - 🌐 api.example.com + To verify ownership and route traffic, add this CNAME record at your DNS + provider: - ⏳ Waiting domain verification - We are checking your DNS configuration to confirm domain ownership. This - usually takes a few minutes. + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. - Add the record below. We'll verify ownership, issue your TLS certificate, - and route traffic automatically. - """ - ) +💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud).\ +""") @pytest.mark.respx @@ -386,7 +419,11 @@ def test_domains_get_selector_handles_empty_collection( result = runner.invoke(app, ["domains", "get", "--app-id", APP_ID]) assert result.exit_code == 0 - assert "No custom domains found." in result.output + assert result.output == snapshot("""\ +custom domains + +No custom domains found.\ +""") PREVALIDATION_RECORDS = [ @@ -463,15 +500,13 @@ def test_domains_get_shows_url_and_no_action_when_live( ) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - 🌐 https://api.example.com +🌐 https://api.example.com - ✅ Your domain is live. - """ - ) +✅ Your domain is live.\ +""") def test_domains_add_json_requires_domain(logged_in_cli: None) -> None: @@ -643,23 +678,21 @@ def test_domains_add_with_arguments_renders_result( ) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - 🐔 Added api.example.com +🐔 Added api.example.com - To verify ownership and route traffic, add this CNAME record at your DNS - provider: + To verify ownership and route traffic, add this CNAME record at your DNS + provider: - type CNAME - name api - value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. - 💡 Copy as shown; remove the trailing dot only if your provider rejects it. - Cloudflare: use DNS only (gray cloud). - """ - ) +💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud).\ +""") @pytest.mark.respx @@ -678,38 +711,36 @@ def test_domains_add_standard_wizard( result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - 🌐 What domain do you want to add? +🌐 What domain do you want to add? - 🌐 What domain do you want to add? - api.example.com +🌐 What domain do you want to add? + api.example.com - 🌐 What domain do you want to add? api.example.com +🌐 What domain do you want to add? api.example.com - Is api.example.com already serving traffic? + Is api.example.com already serving traffic? - ● No — set up a new or unused domain - ○ Yes — migrate it without downtime + ● No — set up a new or unused domain + ○ Yes — migrate it without downtime - No — set up a new or unused domain + No — set up a new or unused domain - 🐔 Added api.example.com +🐔 Added api.example.com - To verify ownership and route traffic, add this CNAME record at your DNS - provider: + To verify ownership and route traffic, add this CNAME record at your DNS + provider: - type CNAME - name api - value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. + type CNAME + name api + value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev. - 💡 Copy as shown; remove the trailing dot only if your provider rejects it. - Cloudflare: use DNS only (gray cloud). - """ - ) +💡 Copy as shown; remove the trailing dot only if your provider rejects it. + Cloudflare: use DNS only (gray cloud).\ +""") @pytest.mark.respx @@ -731,42 +762,40 @@ def test_domains_add_zero_downtime_wizard_hides_traffic_records( result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - 🌐 What domain do you want to add? +🌐 What domain do you want to add? - 🌐 What domain do you want to add? - api.example.com +🌐 What domain do you want to add? + api.example.com - 🌐 What domain do you want to add? api.example.com +🌐 What domain do you want to add? api.example.com - Is api.example.com already serving traffic? + Is api.example.com already serving traffic? - ● No — set up a new or unused domain - ○ Yes — migrate it without downtime + ● No — set up a new or unused domain + ○ Yes — migrate it without downtime - ○ No — set up a new or unused domain - ● Yes — migrate it without downtime + ○ No — set up a new or unused domain + ● Yes — migrate it without downtime - Yes — migrate it without downtime + Yes — migrate it without downtime - 🐔 Added api.example.com +🐔 Added api.example.com - 1️⃣ Prove ownership - Add this TXT record at your DNS provider: +1️⃣ Prove ownership + Add this TXT record at your DNS provider: - type TXT - name _fc-dcv.api - value ownership-value + type TXT + name _fc-dcv.api + value ownership-value - 2️⃣ Secure your domain +2️⃣ Secure your domain - 3️⃣ Switch traffic - """ - ) +3️⃣ Switch traffic\ +""") @pytest.mark.respx @@ -932,13 +961,19 @@ def test_domains_remove_warns_and_confirms( ) assert result.exit_code == 0 - assert ( - "FastAPI Cloud resources for api.example.com will be removed" in result.output - ) - assert "DNS records at" in result.output - assert "will not be changed" in result.output - assert "Remove api.example.com?" in result.output - assert "Removed api.example.com" in result.output + assert result.output == snapshot("""\ +custom domains + +⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at + your provider will not be changed. + + Remove api.example.com? + ● Yes ○ No + + Remove api.example.com? Yes + +🐔 Removed api.example.com\ +""") @pytest.mark.respx @@ -961,8 +996,19 @@ def test_domains_remove_can_select_domain( ) assert result.exit_code == 0 - assert "Select the custom domain to remove:" in result.output - assert "Removed api.example.com" in result.output + assert result.output == snapshot("""\ +custom domains + + Select the custom domain to remove: + ● api.example.com + + Select the custom domain to remove: api.example.com + +⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at + your provider will not be changed. + +🐔 Removed api.example.com\ +""") @pytest.mark.respx @@ -985,7 +1031,22 @@ def test_domains_remove_can_be_cancelled( ) assert result.exit_code == 0 - assert "Removal cancelled." in result.output + assert result.output == snapshot("""\ +custom domains + +⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at + your provider will not be changed. + + Remove api.example.com? + ● Yes ○ No + + Remove api.example.com? + ○ Yes ● No + + Remove api.example.com? No + + Removal cancelled.\ +""") @pytest.mark.respx @@ -1033,7 +1094,11 @@ def test_domains_remove_selector_handles_empty_collection( ) assert result.exit_code == 0 - assert "No custom domains found." in result.output + assert result.output == snapshot("""\ +custom domains + +No custom domains found.\ +""") def test_domains_restart_json_requires_domain(logged_in_cli: None) -> None: @@ -1112,24 +1177,22 @@ def test_domains_restart_with_argument_renders_result( ) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - 🐔 Restarted verification for api.example.com +🐔 Restarted verification for api.example.com - 🌐 api.example.com +🌐 api.example.com - ⏳ Waiting domain verification - We are checking your DNS configuration to confirm domain ownership. This - usually takes a few minutes. +⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. - Add the record below. We'll verify ownership, issue your TLS certificate, - and route traffic automatically. + Add the record below. We'll verify ownership, issue your TLS certificate, + and route traffic automatically. - hint: Run `fastapi cloud domains get api.example.com` to check progress. - """ - ) + hint: Run `fastapi cloud domains get api.example.com` to check progress.\ +""") @pytest.mark.respx @@ -1169,37 +1232,35 @@ def test_domains_restart_selector_only_shows_failed_domains( result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) assert result.exit_code == 0 - assert _normalize_output(result.output) == _normalize_output( - """ - custom domains + assert result.output == snapshot("""\ +custom domains - Select the custom domain to restart: - ● failed.example.com + Select the custom domain to restart: + ● failed.example.com - Select the custom domain to restart: failed.example.com + Select the custom domain to restart: failed.example.com - 🐔 Restarted verification for failed.example.com +🐔 Restarted verification for failed.example.com - 🌐 failed.example.com +🌐 failed.example.com - ⏳ Waiting domain verification - We are checking your DNS configuration to confirm domain ownership. This - usually takes a few minutes. +⏳ Waiting domain verification + We are checking your DNS configuration to confirm domain ownership. This + usually takes a few minutes. - 1️⃣ Prove ownership - Add this TXT record at your DNS provider: +1️⃣ Prove ownership + Add this TXT record at your DNS provider: - type TXT - name _fc-dcv.api - value ownership-value + type TXT + name _fc-dcv.api + value ownership-value - 2️⃣ Secure your domain +2️⃣ Secure your domain - 3️⃣ Switch traffic +3️⃣ Switch traffic - hint: Run `fastapi cloud domains get failed.example.com` to check progress. - """ - ) + hint: Run `fastapi cloud domains get failed.example.com` to check progress.\ +""") @pytest.mark.respx @@ -1219,7 +1280,11 @@ def test_domains_restart_selector_handles_no_failed_domains( result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) assert result.exit_code == 0 - assert "No failed custom domains found." in result.output + assert result.output == snapshot("""\ +custom domains + +No failed custom domains found.\ +""") @pytest.mark.respx diff --git a/tests/integrations/providers/test_list.py b/tests/integrations/providers/test_list.py index acaf5c01..7ce2beca 100644 --- a/tests/integrations/providers/test_list.py +++ b/tests/integrations/providers/test_list.py @@ -5,13 +5,13 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() TEAM_ID = "00000000-0000-4000-8000-000000000001" LINKED_TEAM_ID = "00000000-0000-4000-8000-000000000002" @@ -215,14 +215,22 @@ def test_lists_providers_prompts_for_team_and_renders_statuses( result = runner.invoke(app, ["integrations", "providers", "list"]) assert result.exit_code == 0 - assert "Select the team:" in result.output - assert "Acme" in result.output - assert "integrations" in result.output - assert "Name" in result.output - assert "coming soon" in result.output - assert "not connected" in result.output - assert "connected" in result.output - assert INTEGRATION_ID in result.output + assert result.output == snapshot("""\ +Select the team: +Filter: + +● Acme + +Select the team: Acme + +integrations + +Name Status Integration ID + +Logfire coming soon - +Neon connected 00000000-0000-4000-8000-000000000003 +Redis Cloud not connected -\ +""") @pytest.mark.respx @@ -239,8 +247,11 @@ def test_lists_providers_returns_missing_input_when_no_teams_exist( result = runner.invoke(app, ["integrations", "providers", "list"]) assert result.exit_code == 1 - assert "No teams found." in result.output - assert "Create a team before listing integration providers." in result.output + assert result.output == snapshot("""\ +✗ error: No teams found. + + hint: Create a team before listing integration providers.\ +""") @pytest.mark.respx @@ -258,7 +269,11 @@ def test_lists_providers_in_human_output_empty( ) assert result.exit_code == 0 - assert "No integration providers available." in result.output + assert result.output == snapshot("""\ +integrations + +No integration providers available.\ +""") @pytest.mark.respx diff --git a/tests/integrations/resources/test_connect.py b/tests/integrations/resources/test_connect.py index b4e4fe85..43d39958 100644 --- a/tests/integrations/resources/test_connect.py +++ b/tests/integrations/resources/test_connect.py @@ -5,14 +5,14 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.config import Settings from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config -from tests.utils import changing_dir +from tests.utils import SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() APP_ID = "00000000-0000-4000-8000-000000000001" LINKED_APP_ID = "00000000-0000-4000-8000-000000000002" @@ -136,14 +136,15 @@ def test_connect_resource_opens_browser_for_linked_app( assert result.exit_code == 0 mock_launch.assert_called_once_with(CONNECT_URL) - assert "connect resource" in result.output - assert "Opened the integration setup for API in your browser." in result.output - assert CONNECT_URL in result.output - assert "When the connection is complete" in result.output - assert "fastapi cloud integrations resources list" in " ".join( - result.output.split() - ) - assert APP_ID not in result.output + assert result.output == snapshot("""\ +connect resource + +🔌 Opened the integration setup for API in your browser. + https://dashboard.fastapicloud.com/acme/apps/api/integrations/connect + +💡 When the connection is complete, run fastapi cloud integrations resources + list to view it.\ +""") @pytest.mark.respx @@ -170,11 +171,15 @@ def test_connect_resource_no_open_prints_url_without_opening_browser( assert result.exit_code == 0 mock_launch.assert_not_called() - assert "Open the integration setup for API in your browser:" in result.output - assert CONNECT_URL in result.output - assert f"fastapi cloud integrations resources list --app-id {APP_ID}" in " ".join( - result.output.split() - ) + assert result.output == snapshot("""\ +connect resource + +🔌 Open the integration setup for API in your browser: + https://dashboard.fastapicloud.com/acme/apps/api/integrations/connect + +💡 When the connection is complete, run fastapi cloud integrations resources + list --app-id 00000000-0000-4000-8000-000000000001 to view it.\ +""") def test_connect_resource_json_requires_app_without_linked_app( diff --git a/tests/integrations/resources/test_disconnect.py b/tests/integrations/resources/test_disconnect.py index 671470e1..21f633b3 100644 --- a/tests/integrations/resources/test_disconnect.py +++ b/tests/integrations/resources/test_disconnect.py @@ -5,13 +5,13 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() APP_ID = "00000000-0000-4000-8000-000000000001" LINKED_APP_ID = "00000000-0000-4000-8000-000000000002" @@ -226,21 +226,27 @@ def test_disconnect_resource_allows_interactive_selection( result = runner.invoke(app, ["integrations", "resources", "disconnect"]) assert result.exit_code == 0 - assert result.output.index( - "Select the resource to disconnect:" - ) < result.output.index("The managed environment variables") - normalized_output = " ".join(result.output.split()) - assert ( - "The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be " - "removed from the app." in normalized_output - ) - assert "The Neon resource itself will not be deleted." in normalized_output - assert "production (Neon)" in result.output - assert "cache (Redis Cloud)" in result.output + assert result.output == snapshot("""\ +disconnect resource + + Select the resource to disconnect: + ● production (Neon) + ○ cache (Redis Cloud) + + Select the resource to disconnect: production (Neon) + +💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be + removed from the app. The Neon resource itself will not be deleted. + + Disconnect production? + ● Yes ○ No - assert "Disconnected production from the app." in result.output - assert "Removed managed environment variables:" in result.output - assert "DATABASE_URL, DATABASE_PASSWORD" in result.output + Disconnect production? Yes + +🔌 Disconnected production from the app. + + Removed managed environment variables: DATABASE_URL, DATABASE_PASSWORD.\ +""") @pytest.mark.respx @@ -272,14 +278,19 @@ def test_disconnect_resource_prompts_for_confirmation_when_id_is_provided( ) assert result.exit_code == 0 - normalized_output = " ".join(result.output.split()) - assert ( - "Managed environment variables will be removed from the app." - in normalized_output - ) - assert "The Neon resource itself will not be deleted." in normalized_output - assert "Disconnect production?" in normalized_output - assert "Disconnected production from the app." in result.output + assert result.output == snapshot("""\ +disconnect resource + +💡 Managed environment variables will be removed from the app. The Neon + resource itself will not be deleted. + + Disconnect production? + ● Yes ○ No + + Disconnect production? Yes + +🔌 Disconnected production from the app.\ +""") @pytest.mark.respx @@ -308,7 +319,22 @@ def test_disconnect_resource_can_be_cancelled( ) assert result.exit_code == 0 - assert "Disconnection cancelled." in result.output + assert result.output == snapshot("""\ +disconnect resource + +💡 The managed environment variables DATABASE_URL, DATABASE_PASSWORD will be + removed from the app. The Neon resource itself will not be deleted. + + Disconnect production? + ● Yes ○ No + + Disconnect production? + ○ Yes ● No + + Disconnect production? No + + Disconnection cancelled.\ +""") @pytest.mark.respx @@ -326,7 +352,11 @@ def test_disconnect_resource_selector_handles_empty_list( ) assert result.exit_code == 0 - assert "No connected resources found." in result.output + assert result.output == snapshot("""\ +disconnect resource + +No connected resources found.\ +""") @pytest.mark.respx diff --git a/tests/integrations/resources/test_get.py b/tests/integrations/resources/test_get.py index ce3234ac..7171647a 100644 --- a/tests/integrations/resources/test_get.py +++ b/tests/integrations/resources/test_get.py @@ -1,17 +1,19 @@ import json +from datetime import datetime, timezone from pathlib import Path from typing import Literal import pytest import respx +import time_machine from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config -from tests.utils import changing_dir +from tests.utils import SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() DatabaseProvider = Literal["neon", "redis", "supabase"] @@ -212,6 +214,7 @@ def test_gets_resource_json_requires_app_without_linked_app( @pytest.mark.respx +@time_machine.travel(datetime(2026, 9, 1, 10, tzinfo=timezone.utc), tick=False) def test_gets_database_resource_in_human_output( logged_in_cli: None, respx_mock: respx.MockRouter, @@ -226,21 +229,24 @@ def test_gets_database_resource_in_human_output( ) assert result.exit_code == 0 - assert "connected resource" in result.output - assert "production" in result.output - assert f"id {RESOURCE_ID}" in result.output - assert "provider Neon" in result.output - assert "database app_db" in result.output - assert f"console {CONSOLE_URL}" in result.output - assert "environment variables DATABASE_URL" in result.output - assert "DATABASE_PASSWORD" in result.output - assert "connected" in result.output - assert "last updated" in result.output - assert INTEGRATION_ID not in result.output - assert "unused-project-id" not in result.output + assert result.output == snapshot("""\ +connected resource + +🔌 production + + id 00000000-0000-4000-8000-000000000004 + provider Neon + database app_db + console https://provider.example/resource + environment variables DATABASE_URL + DATABASE_PASSWORD + connected 14 days ago + last updated 12 days ago\ +""") @pytest.mark.respx +@time_machine.travel(datetime(2026, 9, 1, 10, tzinfo=timezone.utc), tick=False) def test_gets_logfire_resource_in_human_output_without_environment_variables( logged_in_cli: None, respx_mock: respx.MockRouter, @@ -258,10 +264,20 @@ def test_gets_logfire_resource_in_human_output_without_environment_variables( ) assert result.exit_code == 0 - assert "provider Logfire" in result.output - assert "project fastapi-api" in result.output - assert "organization acme" in result.output - assert "environment variables -" in result.output + assert result.output == snapshot("""\ +connected resource + +🔌 observability + + id 00000000-0000-4000-8000-000000000004 + provider Logfire + project fastapi-api + organization acme + console https://provider.example/resource + environment variables - + connected 14 days ago + last updated 12 days ago\ +""") @pytest.mark.respx diff --git a/tests/integrations/resources/test_list.py b/tests/integrations/resources/test_list.py index 40770a2e..1f79c736 100644 --- a/tests/integrations/resources/test_list.py +++ b/tests/integrations/resources/test_list.py @@ -5,13 +5,13 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.utils.apps import AppConfig, write_app_config -from tests.utils import changing_dir +from tests.utils import SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() Provider = Literal["neon", "redis", "supabase", "logfire"] @@ -203,15 +203,16 @@ def test_lists_resources_in_human_output( ) assert result.exit_code == 0 - assert "connected resources" in result.output - assert "Name" in result.output - assert "Provider" in result.output - assert "Resource ID" in result.output - assert "Neon" in result.output - assert "Redis Cloud" in result.output - assert "Supabase" in result.output - assert "Logfire" in result.output - assert "00000000-0000-4000-8000-000000000010" in result.output + assert result.output == snapshot("""\ +connected resources + +Name Provider Resource ID + +primary Neon 00000000-0000-4000-8000-000000000010 +cache Redis Cloud 00000000-0000-4000-8000-000000000011 +analytics Supabase 00000000-0000-4000-8000-000000000012 +observability Logfire 00000000-0000-4000-8000-000000000013\ +""") @pytest.mark.respx @@ -229,7 +230,11 @@ def test_lists_resources_in_human_output_empty( ) assert result.exit_code == 0 - assert "No connected resources found." in result.output + assert result.output == snapshot("""\ +connected resources + +No connected resources found.\ +""") @pytest.mark.respx diff --git a/tests/test_cli_apps.py b/tests/test_cli_apps.py index bdc30e48..fc46f77d 100644 --- a/tests/test_cli_apps.py +++ b/tests/test_cli_apps.py @@ -7,7 +7,7 @@ import respx import time_machine from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from fastapi_cloud_cli.commands.apps.list import ( @@ -17,9 +17,9 @@ from fastapi_cloud_cli.config import Settings from fastapi_cloud_cli.utils.apps import AppConfig from tests.conftest import ConfiguredApp -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() def test_creates_app_json_returns_not_logged_in_when_logged_out( @@ -185,9 +185,16 @@ def test_creates_app_prompts_for_team_when_team_id_is_missing( result = runner.invoke(app, ["apps", "create", "--name", "API", "--no-link"]) assert result.exit_code == 0 - assert "Select the team:" in result.output - assert "Acme" in result.output - assert "Created app API" in result.output + assert result.output == snapshot("""\ +Select the team: +Filter: + +● Acme + +Select the team: Acme + +Created app API\ +""") assert not (tmp_path / ".fastapicloud" / "cloud.json").exists() @@ -435,8 +442,10 @@ def test_updates_linked_app_directory_in_human_output( result = runner.invoke(app, ["apps", "update", "--directory", "src"]) assert result.exit_code == 0 - assert "Updated app API" in result.output - assert "Directory: src" in result.output + assert result.output == snapshot("""\ +Updated app API +Directory: src\ +""") def test_updates_app_json_returns_missing_required_input_without_update_flags( @@ -704,8 +713,11 @@ def test_gets_app_human_returns_not_logged_in_when_logged_out( ) assert result.exit_code == 1 - assert "No credentials found." in result.output - assert "fastapi cloud login" in result.output + assert result.output == snapshot("""\ +✗ error: No credentials found. + + hint: Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.\ +""") @pytest.mark.respx @@ -737,9 +749,20 @@ def test_lists_apps_prompts_for_team_when_team_id_is_missing( result = runner.invoke(app, ["apps", "list"]) assert result.exit_code == 0 - assert "Select the team:" in result.output - assert "Acme" in result.output - assert "API" in result.output + assert result.output == snapshot("""\ +Select the team: +Filter: + +● Acme + +Select the team: Acme + +apps + +Name ID + +API 00000000-0000-4000-8000-000000000002\ +""") assert apps_route.calls.last.request.url.params["team_id"] == team["id"] @@ -755,8 +778,11 @@ def test_lists_apps_returns_missing_required_input_when_no_teams( result = runner.invoke(app, ["apps", "list"]) assert result.exit_code == 1 - assert "No teams found." in result.output - assert "Create a team before listing apps." in result.output + assert result.output == snapshot("""\ +✗ error: No teams found. + + hint: Create a team before listing apps.\ +""") @pytest.mark.respx @@ -809,7 +835,6 @@ def test_gets_app_in_human_output( "directory": "backend", "url": "https://api.fastapicloud.app", } - dashboard_url = "https://dashboard.fastapicloud.com/strawberry/apps/api" respx_mock.get(f"/apps/{app_data['id']}").mock( return_value=Response(200, json=app_data) ) @@ -820,13 +845,16 @@ def test_gets_app_in_human_output( result = runner.invoke(app, ["apps", "get", app_data["id"]]) assert result.exit_code == 0 - assert "📦 API" in result.output - assert "slug api" in result.output - assert "directory backend" in result.output - assert "url https://api.fastapicloud.app" in result.output - assert f"dashboard {dashboard_url}" in result.output - assert f"id {app_data['id']}" in result.output - assert f"team id {app_data['team_id']}" in result.output + assert result.output == snapshot("""\ +📦 API + + id 00000000-0000-4000-8000-000000000002 + slug api + directory backend + url https://api.fastapicloud.app + dashboard https://dashboard.fastapicloud.com/strawberry/apps/api + team id 00000000-0000-4000-8000-000000000001\ +""") @pytest.mark.respx @@ -945,9 +973,13 @@ def test_lists_apps_in_human_output( result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) assert result.exit_code == 0 - assert "apps" in result.output - assert "Name ID" in result.output - assert "API 00000000-0000-4000-8000-000000000002" in result.output + assert result.output == snapshot("""\ +apps + +Name ID + +API 00000000-0000-4000-8000-000000000002\ +""") @pytest.mark.respx @@ -970,7 +1002,11 @@ def test_lists_apps_in_human_output_empty( result = runner.invoke(app, ["apps", "list", "--team-id", team_id]) assert result.exit_code == 0 - assert "No apps found." in result.output + assert result.output == snapshot("""\ +apps + +No apps found.\ +""") def test_lists_apps_human_returns_not_logged_in_when_logged_out( @@ -979,5 +1015,8 @@ def test_lists_apps_human_returns_not_logged_in_when_logged_out( result = runner.invoke(app, ["apps", "list", "--team-id", "team-1"]) assert result.exit_code == 1 - assert "No credentials found." in result.output - assert "fastapi cloud login" in result.output + assert result.output == snapshot("""\ +✗ error: No credentials found. + + hint: Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.\ +""") diff --git a/tests/test_cli_deployments.py b/tests/test_cli_deployments.py index 2690a773..417dd764 100644 --- a/tests/test_cli_deployments.py +++ b/tests/test_cli_deployments.py @@ -7,14 +7,14 @@ import respx import time_machine from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.api import StreamLogError, TooManyRetriesError from fastapi_cloud_cli.cli import cloud_app as app from tests.conftest import ConfiguredApp -from tests.utils import build_logs_response, changing_dir +from tests.utils import SnapshotCliRunner, build_logs_response, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() @pytest.mark.respx @@ -123,13 +123,13 @@ def test_lists_deployments_human_output_shows_id_status_and_created( result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) assert result.exit_code == 0 - assert "Status" in result.output - assert "Created" in result.output - assert "00000000-0000-4000-8000-000000000003 success 2 hours ago" in result.output - assert "Slug" not in result.output - assert "URL" not in result.output - assert "api-20260522" not in result.output - assert "https://api.fastapicloud.app" not in result.output + assert result.output == snapshot("""\ +deployments + +ID Status Created + +00000000-0000-4000-8000-000000000003 success 2 hours ago\ +""") @pytest.mark.respx @@ -146,8 +146,11 @@ def test_lists_deployments_in_human_output_empty( result = runner.invoke(app, ["deployments", "list", "--app-id", app_id]) assert result.exit_code == 0 - assert "deployments" in result.output - assert "No deployments found." in result.output + assert result.output == snapshot("""\ +deployments + +No deployments found.\ +""") def test_lists_deployments_json_returns_not_logged_in_when_logged_out( @@ -325,15 +328,18 @@ def test_gets_deployment_in_human_output( ) assert result.exit_code == 0 - assert "deployment" in result.output - assert f"🚀 {deployment['id']}" in result.output - assert f"app id {app_id}" in result.output - assert "slug api-20260522" in result.output - assert "status success" in result.output - assert "created 2 hours ago" in result.output - assert "url https://api.fastapicloud.app" in result.output - assert "dashboard https://dashboard.example.com/d/api-20260522" in result.output - assert "2026-05-22T10:00:00Z" not in result.output + assert result.output == snapshot("""\ +deployment + +🚀 00000000-0000-4000-8000-000000000003 + + app id 00000000-0000-4000-8000-000000000002 + slug api-20260522 + status success + created 2 hours ago + url https://api.fastapicloud.app + dashboard https://dashboard.example.com/d/api-20260522\ +""") @pytest.mark.respx diff --git a/tests/test_cli_teams.py b/tests/test_cli_teams.py index 93ed3bf2..3c7dddf2 100644 --- a/tests/test_cli_teams.py +++ b/tests/test_cli_teams.py @@ -3,11 +3,12 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app +from tests.utils import SnapshotCliRunner -runner = CliRunner() +runner = SnapshotCliRunner() @pytest.mark.respx @@ -94,11 +95,13 @@ def test_lists_teams_in_human_output( result = runner.invoke(app, ["teams", "list"]) assert result.exit_code == 0 - assert "teams" in result.output - assert "Name ID" in result.output - assert "Slug" not in result.output - assert "acme" not in result.output - assert "Acme 00000000-0000-4000-8000-000000000001" in result.output + assert result.output == snapshot("""\ +teams + +Name ID + +Acme 00000000-0000-4000-8000-000000000001\ +""") @pytest.mark.respx @@ -116,7 +119,11 @@ def test_lists_teams_in_human_output_empty( result = runner.invoke(app, ["teams", "list"]) assert result.exit_code == 0 - assert "No teams found." in result.output + assert result.output == snapshot("""\ +teams + +No teams found.\ +""") def test_lists_teams_human_returns_not_logged_in_when_logged_out( @@ -125,8 +132,11 @@ def test_lists_teams_human_returns_not_logged_in_when_logged_out( result = runner.invoke(app, ["teams", "list"]) assert result.exit_code == 1 - assert "No credentials found." in result.output - assert "fastapi cloud login" in result.output + assert result.output == snapshot("""\ +✗ error: No credentials found. + + hint: Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.\ +""") def test_gets_team_human_returns_not_logged_in_when_logged_out( @@ -138,8 +148,11 @@ def test_gets_team_human_returns_not_logged_in_when_logged_out( ) assert result.exit_code == 1 - assert "No credentials found." in result.output - assert "fastapi cloud login" in result.output + assert result.output == snapshot("""\ +✗ error: No credentials found. + + hint: Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.\ +""") @pytest.mark.respx @@ -171,17 +184,18 @@ def test_gets_team_in_human_output( "slug": "acme", "name": "Acme", } - dashboard_url = "https://dashboard.fastapicloud.com/acme/apps" respx_mock.get(f"/teams/{team['id']}").mock(return_value=Response(200, json=team)) result = runner.invoke(app, ["teams", "get", team["id"]]) assert result.exit_code == 0 - assert "🏢 Acme" in result.output - assert f"id {team['id']}" in result.output - assert "slug acme" in result.output - assert f"url {dashboard_url}" in result.output - assert "Team:" not in result.output + assert result.output == snapshot("""\ +🏢 Acme + + id 00000000-0000-4000-8000-000000000001 + slug acme + url https://dashboard.fastapicloud.com/acme/apps\ +""") @pytest.mark.respx diff --git a/tests/test_cli_tokens.py b/tests/test_cli_tokens.py index 7669f935..23579005 100644 --- a/tests/test_cli_tokens.py +++ b/tests/test_cli_tokens.py @@ -5,13 +5,13 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from tests.conftest import ConfiguredApp -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() def test_creates_token_json_returns_not_logged_in_when_logged_out( @@ -659,10 +659,11 @@ def test_lists_tokens_in_human_output_empty( result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) assert result.exit_code == 0 - assert "deploy tokens" in result.output - assert "No deploy tokens found." in result.output - assert "Name" not in result.output - assert "Expiration" not in result.output + assert result.output == snapshot("""\ +deploy tokens + +No deploy tokens found.\ +""") @pytest.mark.respx @@ -692,11 +693,10 @@ def test_lists_tokens_human_output_without_secret_values( result = runner.invoke(app, ["tokens", "list", "--app-id", app_id]) assert result.exit_code == 0 - assert "deploy tokens" in result.output - assert "Name" in result.output - assert "Expiration" in result.output - assert "GitHub Actions" in result.output - assert "2027-05-22" in result.output - assert "expires 2027-05-22" not in result.output - assert token_id in result.output - assert "fcp_secret_token_value" not in result.output + assert result.output == snapshot("""\ +deploy tokens + +Name Expiration ID + +GitHub Actions 2027-05-22 00000000-0000-4000-8000-000000000004\ +""") diff --git a/tests/test_env_delete.py b/tests/test_env_delete.py index 57f84995..56e0b9fc 100644 --- a/tests/test_env_delete.py +++ b/tests/test_env_delete.py @@ -5,20 +5,16 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() assets_path = Path(__file__).parent / "assets" -def _has_environment_variables_title(output: str) -> bool: - return any(line.strip() == "environment variables" for line in output.splitlines()) - - @pytest.fixture def configured_app(tmp_path: Path) -> Path: app_id = "123" @@ -120,9 +116,16 @@ def test_shows_message_when_it_deletes( result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) assert result.exit_code == 0 - assert "Delete SOME_VAR?" in result.output - assert _has_environment_variables_title(result.output) - assert "Environment variable SOME_VAR deleted" in result.output + assert result.output == snapshot("""\ +Delete SOME_VAR? +● Yes ○ No + +Delete SOME_VAR? Yes + +environment variables + +Environment variable SOME_VAR deleted.\ +""") @pytest.mark.respx @@ -219,16 +222,17 @@ def test_shows_selector_for_environment_variables( result = runner.invoke(app, ["env", "delete"]) assert result.exit_code == 0 - assert _has_environment_variables_title(result.output) - selector_line = next( - line - for line in result.output.splitlines() - if line.strip() == "Select the environment variable to delete:" - ) - assert selector_line.startswith(" Select the environment variable to delete:") - assert "Select the environment variable to delete" in result.output + assert result.output == snapshot("""\ +environment variables - assert "Environment variable SECRET_KEY deleted" in result.output +Select the environment variable to delete: +● SECRET_KEY +○ API_KEY + +Select the environment variable to delete: SECRET_KEY + +Environment variable SECRET_KEY deleted.\ +""") @pytest.mark.respx @@ -243,8 +247,11 @@ def test_shows_message_if_no_environment_variable( result = runner.invoke(app, ["env", "delete"]) assert result.exit_code == 0 - assert _has_environment_variables_title(result.output) - assert "No environment variables found." in result.output + assert result.output == snapshot("""\ +environment variables + +No environment variables found.\ +""") def test_delete_json_returns_missing_required_input_without_name( @@ -305,5 +312,15 @@ def test_shows_message_when_deletion_is_cancelled( result = runner.invoke(app, ["env", "delete", "SOME_VAR"]) assert result.exit_code == 0 - assert "Delete SOME_VAR?" in result.output - assert "Deletion cancelled." in result.output + assert result.output == snapshot("""\ +Delete SOME_VAR? +● Yes ○ No + +Delete SOME_VAR? +○ Yes ● No + +Delete SOME_VAR? No +environment variables + +Deletion cancelled.\ +""") diff --git a/tests/test_env_get.py b/tests/test_env_get.py index 86c8cb3b..691fe3fa 100644 --- a/tests/test_env_get.py +++ b/tests/test_env_get.py @@ -4,13 +4,13 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from tests.conftest import ConfiguredApp -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: @@ -32,7 +32,11 @@ def test_shows_message_if_no_environment_variables( result = runner.invoke(app, ["env", "get"]) assert result.exit_code == 0 - assert "No environment variables found." in result.output + assert result.output == snapshot("""\ +environment variables + +No environment variables found.\ +""") @pytest.mark.respx @@ -58,9 +62,12 @@ def test_gets_environment_variable_in_human_output_with_environment_variables_ta result = runner.invoke(app, ["env", "get", "LOG_LEVEL", "--app-id", app_id]) assert result.exit_code == 0 - assert "environment variables" in result.output - assert "LOG_LEVEL" in result.output - assert "info" in result.output + assert result.output == snapshot("""\ +environment variables + +name: LOG_LEVEL +value: info\ +""") @pytest.mark.respx @@ -85,9 +92,12 @@ def test_gets_secret_environment_variable_in_human_output( result = runner.invoke(app, ["env", "get", "DATABASE_URL", "--app-id", app_id]) assert result.exit_code == 0 - assert "environment variables" in result.output - assert "DATABASE_URL" in result.output - assert "[secret]" in result.output + assert result.output == snapshot("""\ +environment variables + +name: DATABASE_URL +value: [secret]\ +""") @pytest.mark.respx @@ -119,10 +129,18 @@ def test_get_prompts_for_environment_variable_name( result = runner.invoke(app, ["env", "get", "--app-id", app_id]) assert result.exit_code == 0 - assert "environment variables" in result.output - assert "Select the environment variable to get:" in result.output - assert "DEMO" in result.output - assert "VALUE" in result.output + assert result.output == snapshot("""\ +environment variables + +Select the environment variable to get: +● DEMO +○ OTHER + +Select the environment variable to get: DEMO + +name: DEMO +value: VALUE\ +""") def test_get_json_returns_missing_required_input_without_name( diff --git a/tests/test_env_list.py b/tests/test_env_list.py index 045f0138..f7e87400 100644 --- a/tests/test_env_list.py +++ b/tests/test_env_list.py @@ -6,21 +6,17 @@ import respx import time_machine from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app from tests.conftest import ConfiguredApp -from tests.utils import changing_dir +from tests.utils import SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() assets_path = Path(__file__).parent / "assets" -def _normalize_output(output: str) -> str: - return "\n".join(line.rstrip() for line in output.strip("\n").splitlines()) - - def test_shows_a_message_if_not_logged_in(logged_out_cli: None) -> None: result = runner.invoke(app, ["env", "list"]) @@ -81,7 +77,11 @@ def test_shows_a_message_if_no_env_variables( result = runner.invoke(app, ["env", "list"]) assert result.exit_code == 0 - assert "No environment variables found." in result.output + assert result.output == snapshot("""\ +environment variables + +No environment variables found.\ +""") @pytest.mark.respx @@ -104,8 +104,14 @@ def test_shows_environment_variables_names( result = runner.invoke(app, ["env", "list"]) assert result.exit_code == 0 - assert "SECRET_KEY" in result.output - assert "API_KEY" in result.output + assert result.output == snapshot("""\ +environment variables + +Key Value Last updated + +SECRET_KEY 123 - +API_KEY 456 -\ +""") @pytest.mark.respx @@ -208,14 +214,14 @@ def test_shows_environment_variables_in_compact_table( result = runner.invoke(app, ["env", "list"]) assert result.exit_code == 0 - output = _normalize_output(result.output) + assert result.output == snapshot("""\ +environment variables - assert "APP_URL" in output - assert "https://fastapicloud.com" in output - assert "12 days ago" in output - assert "SENTRY_ENVIRONMENT" in output - assert "production" in output - assert "2 months ago" in output +Key Value Last updated + +APP_URL https://fastapicloud.com 12 days ago +SENTRY_ENVIRONMENT production 2 months ago\ +""") @pytest.mark.respx @@ -249,17 +255,18 @@ def test_truncates_values_and_marks_secrets_in_compact_table( result = runner.invoke(app, ["env", "list"]) assert result.exit_code == 0 - output = _normalize_output(result.output) - assert "LONG_VALUE" in output - assert "1234567890123456789012345678901234567..." in output - assert "2 months ago" in output - assert "SECRET_KEY" in output - assert "[secret]" in output - assert "1 month ago" in output - assert long_value not in result.output + assert result.output == snapshot("""\ +environment variables + +Key Value Last updated + +LONG_VALUE 1234567890123456789012345678901234567... 2 months ago +SECRET_KEY [secret] 1 month ago\ +""") @pytest.mark.respx +@time_machine.travel(datetime(2026, 8, 13, 12, tzinfo=timezone.utc), tick=False) def test_shows_secret_environment_variables_without_value( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: ConfiguredApp ) -> None: @@ -285,4 +292,10 @@ def test_shows_secret_environment_variables_without_value( result = runner.invoke(app, ["env", "list"]) assert result.exit_code == 0 - assert "SECRET_KEY" in result.output + assert result.output == snapshot("""\ +environment variables + +Key Value Last updated + +SECRET_KEY [secret] 7 months ago\ +""") diff --git a/tests/test_env_set.py b/tests/test_env_set.py index 7dab4b8e..30603c81 100644 --- a/tests/test_env_set.py +++ b/tests/test_env_set.py @@ -5,12 +5,12 @@ import pytest import respx from httpx import Response -from typer.testing import CliRunner +from inline_snapshot import snapshot from fastapi_cloud_cli.cli import cloud_app as app -from tests.utils import Keys, changing_dir +from tests.utils import Keys, SnapshotCliRunner, changing_dir -runner = CliRunner() +runner = SnapshotCliRunner() assets_path = Path(__file__).parent / "assets" @@ -74,18 +74,22 @@ def test_shows_message_when_it_sets( result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret"]) assert result.exit_code == 0 - assert "Environment variable SOME_VAR set" in result.output + assert result.output == snapshot("""\ +environment variables + +Environment variable SOME_VAR set.\ +""") @pytest.mark.respx def test_asks_for_name_and_value( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - steps = [*"SOME_VAR", Keys.ENTER, *"secret", Keys.ENTER] + steps = [*"API", Keys.ENTER, *"secret", Keys.ENTER] respx_mock.post( "/apps/123/environment-variables/", - json={"name": "SOME_VAR", "value": "secret", "is_secret": False}, + json={"name": "API", "value": "secret", "is_secret": False}, ).mock(return_value=Response(200)) with ( @@ -95,21 +99,58 @@ def test_asks_for_name_and_value( result = runner.invoke(app, ["env", "set"]) assert result.exit_code == 0 + assert result.output == snapshot("""\ +environment variables + +Enter the name of the environment variable to set: + + +Enter the name of the environment variable to set: +A + +Enter the name of the environment variable to set: +AP + +Enter the name of the environment variable to set: +API + +Enter the name of the environment variable to set: API +Enter the value of the environment variable: + + +Enter the value of the environment variable: +s + +Enter the value of the environment variable: +se + +Enter the value of the environment variable: +sec + +Enter the value of the environment variable: +secr + +Enter the value of the environment variable: +secre + +Enter the value of the environment variable: +secret + +Enter the value of the environment variable: secret - assert "Enter the name of the environment variable" in result.output - assert "Enter the value of the environment variable" in result.output - assert "Environment variable SOME_VAR set" in result.output +Environment variable API set.\ +""") @pytest.mark.respx def test_asks_for_name_and_value_for_secret( logged_in_cli: None, respx_mock: respx.MockRouter, configured_app: Path ) -> None: - steps = [*"SOME_VAR", Keys.ENTER, *"secret", Keys.ENTER] + steps = [*"API", Keys.ENTER, *"secret", Keys.ENTER] respx_mock.post( "/apps/123/environment-variables/", - json={"name": "SOME_VAR", "value": "secret", "is_secret": True}, + json={"name": "API", "value": "secret", "is_secret": True}, ).mock(return_value=Response(200)) with ( @@ -119,12 +160,47 @@ def test_asks_for_name_and_value_for_secret( result = runner.invoke(app, ["env", "set", "--secret"]) assert result.exit_code == 0 + assert result.output == snapshot("""\ +environment variables + +Enter the name of the secret to set: + + +Enter the name of the secret to set: +A + +Enter the name of the secret to set: +AP + +Enter the name of the secret to set: +API + +Enter the name of the secret to set: API +Enter the secret value: + - assert "Enter the name of the secret" in result.output - assert "Enter the secret value" in result.output - assert "Secret environment variable SOME_VAR set" in result.output +Enter the secret value: +* - assert "*" * 6 in result.output +Enter the secret value: +** + +Enter the secret value: +*** + +Enter the secret value: +**** + +Enter the secret value: +***** + +Enter the secret value: +****** + +Enter the secret value: ****** + +Secret environment variable API set.\ +""") @pytest.mark.respx @@ -140,7 +216,11 @@ def test_sets_secret_flag( result = runner.invoke(app, ["env", "set", "SOME_VAR", "secret", "--secret"]) assert result.exit_code == 0 - assert "Secret environment variable SOME_VAR set" in result.output + assert result.output == snapshot("""\ +environment variables + +Secret environment variable SOME_VAR set.\ +""") @pytest.mark.respx diff --git a/tests/utils.py b/tests/utils.py index efb34357..9aef242f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,11 +2,16 @@ import json import os import sys -from collections.abc import Generator +from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from pathlib import Path +from textwrap import dedent from typing import Any +from rich.console import detect_legacy_windows +from typer import Typer +from typer.testing import CliRunner, Result + @contextmanager def changing_dir(directory: str | Path) -> Generator[None, None, None]: @@ -23,6 +28,54 @@ def build_logs_response(*logs: dict[str, Any]) -> str: return "\n".join(json.dumps(log) for log in logs) +class _SnapshotResult(Result): + @property + def output(self) -> str: + output = dedent(super().output.replace("\u200b", "")).strip() + return "\n".join(line.rstrip() for line in output.splitlines()) + + +class SnapshotCliRunner(CliRunner): + """Return normalized CLI output suitable for inline snapshots.""" + + terminal_width = 80 + + def invoke( + self, + app: Typer, + args: str | Sequence[str] | None = None, + input: bytes | str | None = None, + env: Mapping[str, str | None] | None = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: Any, + ) -> _SnapshotResult: + env = { + **(env or {}), + # Rich reserves the final column on legacy Windows terminals. + "COLUMNS": str(self.terminal_width + int(detect_legacy_windows())), + } + result = super().invoke( + app, + args, + input=input, + env=env, + catch_exceptions=catch_exceptions, + color=color, + **extra, + ) + return _SnapshotResult( + runner=result.runner, + stdout_bytes=result.stdout_bytes, + stderr_bytes=result.stderr_bytes, + output_bytes=result.output_bytes, + return_value=result.return_value, + exit_code=result.exit_code, + exception=result.exception, + exc_info=result.exc_info, + ) + + if sys.platform == "win32": class Keys: diff --git a/uv.lock b/uv.lock index ef91a976..46622daa 100644 --- a/uv.lock +++ b/uv.lock @@ -90,6 +90,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -274,13 +283,22 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "fastapi-cloud-cli" source = { editable = "." } @@ -305,6 +323,7 @@ standard = [ [package.dev-dependencies] dev = [ { name = "coverage", extra = ["toml"] }, + { name = "inline-snapshot" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, @@ -339,6 +358,7 @@ provides-extras = ["standard"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.2,<8.0" }, + { name = "inline-snapshot", specifier = ">=0.35.4" }, { name = "mypy", specifier = "==2.3.1" }, { name = "prek", specifier = ">=0.2.24,<1.0.0" }, { name = "pytest", specifier = ">=7.0.0,<10.0.0" }, @@ -596,6 +616,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inline-snapshot" +version = "0.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pytest" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/46/3c459f0350e88d6a5ca58a4792a7215c3b0990cf9bb6a2b37ac8d74a2504/inline_snapshot-0.35.4.tar.gz", hash = "sha256:82432a58b7ea77a172515f5024cd26fdea8dc0f873c7a4251e4e0471a914db14", size = 2537089, upload-time = "2026-08-11T07:07:56.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/b3/dc18eb603de18daf5cacc27bb4c5e80c5403d9ca3eade7342fe4228652bf/inline_snapshot-0.35.4-py3-none-any.whl", hash = "sha256:0ad221c1bc6fe4ce249f7c1c4131284099ecac1a5c1023aa7b1a95921c07265d", size = 95725, upload-time = "2026-08-11T07:07:54.24Z" }, +] + [[package]] name = "librt" version = "0.13.0"