Skip to content

Commit f59ddd7

Browse files
authored
✅ Refactor tests (#62)
1 parent 342dcd3 commit f59ddd7

File tree

12 files changed

+417
-22
lines changed

12 files changed

+417
-22
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,12 +375,12 @@ def _wait_for_deployment(
375375
raise typer.Exit(1)
376376

377377
if time_elapsed > 30:
378-
messages = cycle(LONG_WAIT_MESSAGES)
378+
messages = cycle(LONG_WAIT_MESSAGES) # pragma: no cover
379379

380380
if (time.monotonic() - last_message_changed_at) > 2:
381-
progress.title = next(messages)
381+
progress.title = next(messages) # pragma: no cover
382382

383-
last_message_changed_at = time.monotonic()
383+
last_message_changed_at = time.monotonic() # pragma: no cover
384384

385385

386386
def _setup_environment_variables(toolkit: RichToolkit, app_id: str) -> None:

src/fastapi_cloud_cli/commands/env.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,9 @@ def delete(
161161
{"name": env_var.name, "value": env_var.name}
162162
for env_var in environment_variables.data
163163
],
164-
default=None,
165164
)
166165

167-
if not name:
168-
return
166+
assert name
169167
else:
170168
if not validate_environment_variable_name(name):
171169
toolkit.print(

src/fastapi_cloud_cli/commands/whoami.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ def whoami() -> Any:
1717
return
1818

1919
with APIClient() as client:
20-
with Progress(title="⚡Fetching profile", transient=True) as progress:
20+
with Progress(title="⚡ Fetching profile", transient=True) as progress:
2121
with handle_http_errors(progress, message=""):
2222
response = client.get("/users/me")
2323
response.raise_for_status()
2424

2525
data = response.json()
2626

27-
print(f"⚡[bold]{data['email']}[/bold]")
27+
print(f"⚡ [bold]{data['email']}[/bold]")

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def handle_http_errors(
8585

8686
# Handle validation errors from Pydantic models, this should make it easier to debug :)
8787
if isinstance(e, HTTPStatusError) and e.response.status_code == 422:
88-
logger.debug(e.response.json())
88+
logger.debug(e.response.json()) # pragma: no cover
8989

9090
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
9191
message = "The specified token is not valid. Use `fastapi login` to generate a new token."

tests/conftest.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,17 @@ def setup_terminal() -> None:
2525

2626

2727
@pytest.fixture
28-
def logged_in_cli() -> Generator[None, None, None]:
29-
with patch("fastapi_cloud_cli.utils.auth.get_auth_token", return_value=True):
30-
yield
28+
def logged_in_cli(temp_auth_config: Path) -> Generator[None, None, None]:
29+
temp_auth_config.write_text('{"access_token": "test_token_12345"}')
30+
31+
yield
3132

3233

3334
@pytest.fixture
34-
def logged_out_cli() -> Generator[None, None, None]:
35-
with patch("fastapi_cloud_cli.utils.auth.get_auth_token", return_value=None):
36-
yield
35+
def logged_out_cli(temp_auth_config: Path) -> Generator[None, None, None]:
36+
assert not temp_auth_config.exists()
37+
38+
yield
3739

3840

3941
@dataclass
@@ -54,3 +56,13 @@ def configured_app(tmp_path: Path) -> ConfiguredApp:
5456
config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}')
5557

5658
return ConfiguredApp(app_id=app_id, team_id=team_id, path=tmp_path)
59+
60+
61+
@pytest.fixture
62+
def temp_auth_config(tmp_path: Path) -> Generator[Path, None, None]:
63+
"""Provides a temporary auth config setup for testing file operations."""
64+
65+
with patch(
66+
"fastapi_cloud_cli.utils.config.get_config_folder", return_value=tmp_path
67+
):
68+
yield tmp_path / "auth.json"

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import subprocess
2+
import sys
3+
4+
5+
def test_script() -> None:
6+
result = subprocess.run(
7+
[sys.executable, "-m", "coverage", "run", "-m", "fastapi_cloud_cli", "--help"],
8+
capture_output=True,
9+
encoding="utf-8",
10+
)
11+
assert "Usage" in result.stdout

tests/test_cli_deploy.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,149 @@ def test_does_not_duplicate_entry_in_git_ignore(
652652
_deploy_without_waiting(respx_mock, tmp_path)
653653

654654
assert git_ignore_path.read_text() == ".fastapicloud\n"
655+
656+
657+
@pytest.mark.respx(base_url=settings.base_api_url)
658+
def test_creates_environment_variables_during_app_setup(
659+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
660+
) -> None:
661+
steps = [
662+
Keys.ENTER, # Setup and deploy
663+
Keys.ENTER, # Select team
664+
Keys.ENTER, # Create new app
665+
*"demo", # App name
666+
Keys.ENTER,
667+
Keys.ENTER, # Setup environment variables (Yes)
668+
*"API_KEY", # Environment variable name
669+
Keys.ENTER,
670+
*"secret123", # Environment variable value
671+
Keys.ENTER,
672+
Keys.ENTER, # Empty key to finish
673+
Keys.CTRL_C, # Exit before deployment
674+
]
675+
676+
team = _get_random_team()
677+
app_data = _get_random_app(team_id=team["id"])
678+
679+
respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
680+
681+
respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
682+
return_value=Response(201, json=app_data)
683+
)
684+
685+
env_vars_request = respx_mock.patch(
686+
f"/apps/{app_data['id']}/environment-variables/", json={"API_KEY": "secret123"}
687+
).mock(return_value=Response(200))
688+
689+
with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
690+
mock_getchar.side_effect = steps
691+
692+
result = runner.invoke(app, ["deploy"])
693+
694+
assert result.exit_code == 1
695+
assert env_vars_request.called
696+
assert "Environment variables set up successfully!" in result.output
697+
698+
699+
@pytest.mark.respx(base_url=settings.base_api_url)
700+
def test_rejects_invalid_environment_variable_names(
701+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
702+
) -> None:
703+
steps = [
704+
Keys.ENTER, # Setup and deploy
705+
Keys.ENTER, # Select team
706+
Keys.ENTER, # Create new app
707+
*"demo", # App name
708+
Keys.ENTER,
709+
Keys.ENTER, # Setup environment variables (Yes)
710+
*"123-invalid", # Invalid environment variable name (starts with digit, contains hyphen)
711+
Keys.ENTER,
712+
*"VALID_KEY", # Valid environment variable name
713+
Keys.ENTER,
714+
*"value123", # Environment variable value
715+
Keys.ENTER,
716+
Keys.ENTER, # Empty key to finish
717+
Keys.CTRL_C, # Exit before deployment
718+
]
719+
720+
team = _get_random_team()
721+
app_data = _get_random_app(team_id=team["id"])
722+
723+
respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
724+
725+
respx_mock.post("/apps/", json={"name": "demo", "team_id": team["id"]}).mock(
726+
return_value=Response(201, json=app_data)
727+
)
728+
729+
env_vars_request = respx_mock.patch(
730+
f"/apps/{app_data['id']}/environment-variables/", json={"VALID_KEY": "value123"}
731+
).mock(return_value=Response(200))
732+
733+
with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
734+
mock_getchar.side_effect = steps
735+
736+
result = runner.invoke(app, ["deploy"])
737+
738+
assert result.exit_code == 1
739+
assert env_vars_request.called
740+
assert "Invalid environment variable name." in result.output
741+
assert "Environment variables set up successfully!" in result.output
742+
743+
744+
@pytest.mark.respx(base_url=settings.base_api_url)
745+
def test_shows_error_for_invalid_waitlist_form_data(
746+
logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
747+
) -> None:
748+
steps = [
749+
750+
Keys.ENTER,
751+
Keys.ENTER, # Choose to provide more information
752+
Keys.CTRL_C, # Interrupt to avoid infinite loop
753+
]
754+
755+
with changing_dir(tmp_path), patch(
756+
"rich_toolkit.menu.click.getchar"
757+
) as mock_getchar, patch("rich_toolkit.form.Form.run") as mock_form_run:
758+
mock_getchar.side_effect = steps
759+
# Simulate form returning data with invalid email field to trigger ValidationError
760+
mock_form_run.return_value = {
761+
"email": "invalid-email-format",
762+
"name": "John Doe",
763+
}
764+
765+
result = runner.invoke(app, ["deploy"])
766+
767+
assert result.exit_code == 1
768+
assert "Invalid form data. Please try again." in result.output
769+
770+
771+
@pytest.mark.respx(base_url=settings.base_api_url)
772+
def test_shows_no_apps_found_message_when_team_has_no_apps(
773+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
774+
) -> None:
775+
steps = [
776+
Keys.ENTER, # Setup and deploy
777+
Keys.ENTER, # Select team
778+
Keys.RIGHT_ARROW, # Choose existing app (No)
779+
Keys.ENTER,
780+
]
781+
782+
team = _get_random_team()
783+
784+
respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]}))
785+
786+
# Mock empty apps list for the team
787+
respx_mock.get("/apps/", params={"team_id": team["id"]}).mock(
788+
return_value=Response(200, json={"data": []})
789+
)
790+
791+
with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
792+
mock_getchar.side_effect = steps
793+
794+
result = runner.invoke(app, ["deploy"])
795+
796+
assert result.exit_code == 1
797+
assert (
798+
"No apps found in this team. You can create a new app instead."
799+
in result.output
800+
)

tests/test_cli_login.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pathlib import Path
22
from unittest.mock import patch
33

4+
import httpx
45
import pytest
56
import respx
67
from httpx import Response
@@ -33,7 +34,7 @@ def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) ->
3334

3435

3536
@pytest.mark.respx(base_url=settings.base_api_url)
36-
def test_full_login(respx_mock: respx.MockRouter) -> None:
37+
def test_full_login(respx_mock: respx.MockRouter, temp_auth_config: Path) -> None:
3738
with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
3839
respx_mock.post(
3940
"/login/device/authorization", data={"client_id": settings.client_id}
@@ -55,11 +56,108 @@ def test_full_login(respx_mock: respx.MockRouter) -> None:
5556
"client_id": settings.client_id,
5657
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
5758
},
58-
).mock(return_value=Response(200, json={"access_token": "1234"}))
59+
).mock(return_value=Response(200, json={"access_token": "test_token_1234"}))
60+
61+
# Verify no auth file exists before login
62+
assert not temp_auth_config.exists()
5963

6064
result = runner.invoke(app, ["login"])
6165

6266
assert result.exit_code == 0
6367
assert mock_open.called
6468
assert mock_open.call_args.args == ("http://test.com",)
6569
assert "Now you are logged in!" in result.output
70+
71+
# Verify auth file was created with correct content
72+
assert temp_auth_config.exists()
73+
assert '"access_token":"test_token_1234"' in temp_auth_config.read_text()
74+
75+
76+
@pytest.mark.respx(base_url=settings.base_api_url)
77+
def test_fetch_access_token_success_immediately(respx_mock: respx.MockRouter) -> None:
78+
from fastapi_cloud_cli.commands.login import _fetch_access_token
79+
from fastapi_cloud_cli.utils.api import APIClient
80+
81+
respx_mock.post(
82+
"/login/device/token",
83+
data={
84+
"device_code": "test_device_code",
85+
"client_id": settings.client_id,
86+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
87+
},
88+
).mock(return_value=Response(200, json={"access_token": "test_token_success"}))
89+
90+
with APIClient() as client:
91+
access_token = _fetch_access_token(client, "test_device_code", 5)
92+
93+
assert access_token == "test_token_success"
94+
95+
96+
@pytest.mark.respx(base_url=settings.base_api_url)
97+
def test_fetch_access_token_authorization_pending_then_success(
98+
respx_mock: respx.MockRouter,
99+
) -> None:
100+
from fastapi_cloud_cli.commands.login import _fetch_access_token
101+
from fastapi_cloud_cli.utils.api import APIClient
102+
103+
# First call returns authorization pending, second call succeeds
104+
respx_mock.post(
105+
"/login/device/token",
106+
data={
107+
"device_code": "test_device_code",
108+
"client_id": settings.client_id,
109+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
110+
},
111+
).mock(
112+
side_effect=[
113+
Response(400, json={"error": "authorization_pending"}),
114+
Response(200, json={"access_token": "test_token_after_pending"}),
115+
]
116+
)
117+
118+
with patch("fastapi_cloud_cli.commands.login.time.sleep") as mock_sleep:
119+
with APIClient() as client:
120+
access_token = _fetch_access_token(client, "test_device_code", 3)
121+
122+
assert access_token == "test_token_after_pending"
123+
mock_sleep.assert_called_once_with(3)
124+
125+
126+
@pytest.mark.respx(base_url=settings.base_api_url)
127+
def test_fetch_access_token_handles_400_error_not_authorization_pending(
128+
respx_mock: respx.MockRouter,
129+
) -> None:
130+
from fastapi_cloud_cli.commands.login import _fetch_access_token
131+
from fastapi_cloud_cli.utils.api import APIClient
132+
133+
respx_mock.post(
134+
"/login/device/token",
135+
data={
136+
"device_code": "test_device_code",
137+
"client_id": settings.client_id,
138+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
139+
},
140+
).mock(return_value=Response(400, json={"error": "access_denied"}))
141+
142+
with APIClient() as client:
143+
with pytest.raises(httpx.HTTPStatusError):
144+
_fetch_access_token(client, "test_device_code", 5)
145+
146+
147+
@pytest.mark.respx(base_url=settings.base_api_url)
148+
def test_fetch_access_token_handles_500_error(respx_mock: respx.MockRouter) -> None:
149+
from fastapi_cloud_cli.commands.login import _fetch_access_token
150+
from fastapi_cloud_cli.utils.api import APIClient
151+
152+
respx_mock.post(
153+
"/login/device/token",
154+
data={
155+
"device_code": "test_device_code",
156+
"client_id": settings.client_id,
157+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
158+
},
159+
).mock(return_value=Response(500))
160+
161+
with APIClient() as client:
162+
with pytest.raises(httpx.HTTPStatusError):
163+
_fetch_access_token(client, "test_device_code", 5)

0 commit comments

Comments
 (0)