Coverage for tests/test_cli_deploy.py: 100%
1128 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1import itertools 1abcdefg
2import json 1abcdefg
3import random 1abcdefg
4import re 1abcdefg
5import string 1abcdefg
6from pathlib import Path 1abcdefg
7from typing import TypedDict 1abcdefg
8from unittest.mock import call, patch 1abcdefg
10import httpx 1abcdefg
11import pytest 1abcdefg
12import respx 1abcdefg
13import typer 1abcdefg
14from httpx import Response 1abcdefg
15from inline_snapshot import snapshot 1abcdefg
16from rich_toolkit.progress import Progress 1abcdefg
17from typer.testing import CliRunner, Result 1abcdefg
19from fastapi_cloud_cli.api import ( 1abcdefg
20 APIClient,
21 DeploymentStatus,
22 StreamLogError,
23 TooManyRetriesError,
24)
25from fastapi_cloud_cli.cli import app 1abcdefg
26from fastapi_cloud_cli.commands.deploy import wait 1abcdefg
27from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse 1abcdefg
28from fastapi_cloud_cli.config import Settings 1abcdefg
29from fastapi_cloud_cli.utils.cli import get_rich_toolkit 1abcdefg
30from tests.conftest import ConfiguredApp 1abcdefg
31from tests.utils import ( 1abcdefg
32 Keys,
33 SnapshotCliRunner,
34 build_logs_response,
35 changing_dir,
36 create_jwt_token,
37)
39runner = CliRunner() 1abcdefg
41assets_path = Path(__file__).parent / "assets" 1abcdefg
44@pytest.fixture(autouse=True) 1abcdefg
45def app_source(tmp_path: Path) -> Path: 1abcdefg
46 # Deploy refuses to upload an empty folder; give every test an app file.
47 main_path = tmp_path / "main.py" 1abcdefg
48 main_path.write_text("from fastapi import FastAPI\n\napp = FastAPI()\n") 1abcdefg
49 return main_path 1abcdefg
52def _get_random_team(name: str | None = None) -> dict[str, str]: 1abcdefg
53 name = name or "".join(random.choices(string.ascii_lowercase, k=10)) 2OeIcYbncmeWdBdGb- ndHbjbZbcb. !c9dv w {dIbkblb|d_d7e+ Q CdXdYd8eWc[bJcPe!dMd0b/ od1bJb]bdb: ;c?dz A ne2bmbnboe}d=e; 6 Nd#d$d9eXc^bKcQe%dOd3b= pd4bKb_beb? =c@dB C pe5bobpbqe~d?e@ 7 Pd'd(d!eYc`bLcRe)dQd6b[ qd7bLb{bfb] ?c[dD E re8bqbrbseae@e^ 8 Rd*d+dSeMc9bocteZdDdMb_ rdNbsb!bgb` #c,dx y beObtbubce`d#e, R Ed0d1d$eZc|bNcTe-dSd#b{ sd$bPb}bhb| @c]dF G ue%bvbwbvede[e} 9 Td.d/d%e0c~bOcUe:dUd'b~ td(bQbacibab[c^dH I we)bxbybxeee]ebb! Vd;d=d
54 slug = "".join(random.choices(string.ascii_lowercase, k=10)) 2OeIcYbncmeWdBdGb- ndHbjbZbcb. !c9dv w {dIbkblb|d_d7e+ Q CdXdYd8eWc[bJcPe!dMd0b/ od1bJb]bdb: ;c?dz A ne2bmbnboe}d=e; 6 Nd#d$d9eXc^bKcQe%dOd3b= pd4bKb_beb? =c@dB C pe5bobpbqe~d?e@ 7 Pd'd(d!eYc`bLcRe)dQd6b[ qd7bLb{bfb] ?c[dD E re8bqbrbseae@e^ 8 Rd*d+dSeMc9bocteZdDdMb_ rdNbsb!bgb` #c,dx y beObtbubce`d#e, R Ed0d1d$eZc|bNcTe-dSd#b{ sd$bPb}bhb| @c]dF G ue%bvbwbvede[e} 9 Td.d/d%e0c~bOcUe:dUd'b~ td(bQbacibab[c^dH I we)bxbybxeee]ebb! Vd;d=d
55 id = "".join(random.choices(string.digits, k=10)) 2OeIcYbncmeWdBdGb- ndHbjbZbcb. !c9dv w {dIbkblb|d_d7e+ Q CdXdYd8eWc[bJcPe!dMd0b/ od1bJb]bdb: ;c?dz A ne2bmbnboe}d=e; 6 Nd#d$d9eXc^bKcQe%dOd3b= pd4bKb_beb? =c@dB C pe5bobpbqe~d?e@ 7 Pd'd(d!eYc`bLcRe)dQd6b[ qd7bLb{bfb] ?c[dD E re8bqbrbseae@e^ 8 Rd*d+dSeMc9bocteZdDdMb_ rdNbsb!bgb` #c,dx y beObtbubce`d#e, R Ed0d1d$eZc|bNcTe-dSd#b{ sd$bPb}bhb| @c]dF G ue%bvbwbvede[e} 9 Td.d/d%e0c~bOcUe:dUd'b~ td(bQbacibab[c^dH I we)bxbybxeee]ebb! Vd;d=d
57 return {"name": name, "slug": slug, "id": id} 2OeIcYbncmeWdBdGb- ndHbjbZbcb. !c9dv w {dIbkblb|d_d7e+ Q CdXdYd8eWc[bJcPe!dMd0b/ od1bJb]bdb: ;c?dz A ne2bmbnboe}d=e; 6 Nd#d$d9eXc^bKcQe%dOd3b= pd4bKb_beb? =c@dB C pe5bobpbqe~d?e@ 7 Pd'd(d!eYc`bLcRe)dQd6b[ qd7bLb{bfb] ?c[dD E re8bqbrbseae@e^ 8 Rd*d+dSeMc9bocteZdDdMb_ rdNbsb!bgb` #c,dx y beObtbubce`d#e, R Ed0d1d$eZc|bNcTe-dSd#b{ sd$bPb}bhb| @c]dF G ue%bvbwbvede[e} 9 Td.d/d%e0c~bOcUe:dUd'b~ td(bQbacibab[c^dH I we)bxbybxeee]ebb! Vd;d=d
60class RandomApp(TypedDict): 1abcdefg
61 name: str 1abcdefg
62 slug: str 1abcdefg
63 id: str 1abcdefg
64 team_id: str 1abcdefg
65 directory: str | None 1abcdefg
68def _get_random_app( 1abcdefg
69 *,
70 slug: str | None = None,
71 team_id: str | None = None,
72 directory: str | None = None,
73) -> RandomApp:
74 name = "".join(random.choices(string.ascii_lowercase, k=10)) 2IcYbncWdBdGb8c*bpcbcyefeRbFd1czegd$c- ]c^cqc2dudndHbjbZbcb. !cS T J h o v +b# w Ibkblb+ Q CdXdYdzb,bWc[bJc!dMd0b%cccrcscAegeSbGd2cBehd'c/ _c`ctc3dvdod1bJb]bdb: ;cU V K i p z dc$ A 2bmbnb; 6 Nd#d$dAb-bXc^bKc%dOd3b(cecucvcCeheTbHd3cDeid)c= {c|cwc4dwdpd4bKb_beb? =cW X L j q B fc% C 5bobpb@ 7 Pd'd(dBb.bYc`bLc)dQd6b*cgcxcycEeieUbId4cFejd+c[ }c~czc5dxdqd7bLb{bfb] ?cY Z M k r D hc' E 8bqbrb^ 8 Rd*d+dCb/bMc9bocZdDdMb9c:bAcicGejeVbJd5cHekd,c_ adbdBc6dydrdNbsb!bgb` #c0 1 N l s x ;b( y Obtbub, R Ed0d1dDb=bZc|bNc-dSd#b-cjcCcDcIekeWbKd6cJeld.c{ cdddEc7dzdsd$bPb}bhb| @c2 3 O m t F kc) G %bvbwb} 9 Td.d/dEb?b0c~bOc:dUd'b/clcFcGcKeleXbLd7cLemd:c~ edfdHc8dAdtd(bQbacibab[c4 5 P n u H mc* I )bxbybbb! Vd;d=dFb@b
75 slug = slug or "".join(random.choices(string.ascii_lowercase, k=10)) 2IcYbncWdBdGb8c*bpcbcyefeRbFd1czegd$c- ]c^cqc2dudndHbjbZbcb. !cS T J h o v +b# w Ibkblb+ Q CdXdYdzb,bWc[bJc!dMd0b%cccrcscAegeSbGd2cBehd'c/ _c`ctc3dvdod1bJb]bdb: ;cU V K i p z dc$ A 2bmbnb; 6 Nd#d$dAb-bXc^bKc%dOd3b(cecucvcCeheTbHd3cDeid)c= {c|cwc4dwdpd4bKb_beb? =cW X L j q B fc% C 5bobpb@ 7 Pd'd(dBb.bYc`bLc)dQd6b*cgcxcycEeieUbId4cFejd+c[ }c~czc5dxdqd7bLb{bfb] ?cY Z M k r D hc' E 8bqbrb^ 8 Rd*d+dCb/bMc9bocZdDdMb9c:bAcicGejeVbJd5cHekd,c_ adbdBc6dydrdNbsb!bgb` #c0 1 N l s x ;b( y Obtbub, R Ed0d1dDb=bZc|bNc-dSd#b-cjcCcDcIekeWbKd6cJeld.c{ cdddEc7dzdsd$bPb}bhb| @c2 3 O m t F kc) G %bvbwb} 9 Td.d/dEb?b0c~bOc:dUd'b/clcFcGcKeleXbLd7cLemd:c~ edfdHc8dAdtd(bQbacibab[c4 5 P n u H mc* I )bxbybbb! Vd;d=dFb@b
76 id = "".join(random.choices(string.digits, k=10)) 2IcYbncWdBdGb8c*bpcbcyefeRbFd1czegd$c- ]c^cqc2dudndHbjbZbcb. !cS T J h o v +b# w Ibkblb+ Q CdXdYdzb,bWc[bJc!dMd0b%cccrcscAegeSbGd2cBehd'c/ _c`ctc3dvdod1bJb]bdb: ;cU V K i p z dc$ A 2bmbnb; 6 Nd#d$dAb-bXc^bKc%dOd3b(cecucvcCeheTbHd3cDeid)c= {c|cwc4dwdpd4bKb_beb? =cW X L j q B fc% C 5bobpb@ 7 Pd'd(dBb.bYc`bLc)dQd6b*cgcxcycEeieUbId4cFejd+c[ }c~czc5dxdqd7bLb{bfb] ?cY Z M k r D hc' E 8bqbrb^ 8 Rd*d+dCb/bMc9bocZdDdMb9c:bAcicGejeVbJd5cHekd,c_ adbdBc6dydrdNbsb!bgb` #c0 1 N l s x ;b( y Obtbub, R Ed0d1dDb=bZc|bNc-dSd#b-cjcCcDcIekeWbKd6cJeld.c{ cdddEc7dzdsd$bPb}bhb| @c2 3 O m t F kc) G %bvbwb} 9 Td.d/dEb?b0c~bOc:dUd'b/clcFcGcKeleXbLd7cLemd:c~ edfdHc8dAdtd(bQbacibab[c4 5 P n u H mc* I )bxbybbb! Vd;d=dFb@b
77 team_id = team_id or "".join(random.choices(string.digits, k=10)) 2IcYbncWdBdGb8c*bpcbcyefeRbFd1czegd$c- ]c^cqc2dudndHbjbZbcb. !cS T J h o v +b# w Ibkblb+ Q CdXdYdzb,bWc[bJc!dMd0b%cccrcscAegeSbGd2cBehd'c/ _c`ctc3dvdod1bJb]bdb: ;cU V K i p z dc$ A 2bmbnb; 6 Nd#d$dAb-bXc^bKc%dOd3b(cecucvcCeheTbHd3cDeid)c= {c|cwc4dwdpd4bKb_beb? =cW X L j q B fc% C 5bobpb@ 7 Pd'd(dBb.bYc`bLc)dQd6b*cgcxcycEeieUbId4cFejd+c[ }c~czc5dxdqd7bLb{bfb] ?cY Z M k r D hc' E 8bqbrb^ 8 Rd*d+dCb/bMc9bocZdDdMb9c:bAcicGejeVbJd5cHekd,c_ adbdBc6dydrdNbsb!bgb` #c0 1 N l s x ;b( y Obtbub, R Ed0d1dDb=bZc|bNc-dSd#b-cjcCcDcIekeWbKd6cJeld.c{ cdddEc7dzdsd$bPb}bhb| @c2 3 O m t F kc) G %bvbwb} 9 Td.d/dEb?b0c~bOc:dUd'b/clcFcGcKeleXbLd7cLemd:c~ edfdHc8dAdtd(bQbacibab[c4 5 P n u H mc* I )bxbybbb! Vd;d=dFb@b
79 return { 2IcYbncWdBdGb8c*bpcbcyefeRbFd1czegd$c- ]c^cqc2dudndHbjbZbcb. !cS T J h o v +b# w Ibkblb+ Q CdXdYdzb,bWc[bJc!dMd0b%cccrcscAegeSbGd2cBehd'c/ _c`ctc3dvdod1bJb]bdb: ;cU V K i p z dc$ A 2bmbnb; 6 Nd#d$dAb-bXc^bKc%dOd3b(cecucvcCeheTbHd3cDeid)c= {c|cwc4dwdpd4bKb_beb? =cW X L j q B fc% C 5bobpb@ 7 Pd'd(dBb.bYc`bLc)dQd6b*cgcxcycEeieUbId4cFejd+c[ }c~czc5dxdqd7bLb{bfb] ?cY Z M k r D hc' E 8bqbrb^ 8 Rd*d+dCb/bMc9bocZdDdMb9c:bAcicGejeVbJd5cHekd,c_ adbdBc6dydrdNbsb!bgb` #c0 1 N l s x ;b( y Obtbub, R Ed0d1dDb=bZc|bNc-dSd#b-cjcCcDcIekeWbKd6cJeld.c{ cdddEc7dzdsd$bPb}bhb| @c2 3 O m t F kc) G %bvbwb} 9 Td.d/dEb?b0c~bOc:dUd'b/clcFcGcKeleXbLd7cLemd:c~ edfdHc8dAdtd(bQbacibab[c4 5 P n u H mc* I )bxbybbb! Vd;d=dFb@b
80 "name": name,
81 "slug": slug,
82 "id": id,
83 "team_id": team_id,
84 "directory": directory,
85 }
88def _get_random_deployment( 1abcdefg
89 *,
90 app_id: str | None = None,
91 status: str = "waiting_upload",
92) -> dict[str, str]:
93 id = "".join(random.choices(string.digits, k=10)) 2IcYbncGb8c*bpcbcRb1c$c- ]c^cqcudHbjbZbcb. S T J h o v +b# w Ibkblb+ Q zb,bWc[bJc0b%cccrcscSb2c'c/ _c`ctcvd1bJb]bdb: U V K i p z dc$ A 2bmbnb; 6 Ab-bXc^bKc3b(cecucvcTb3c)c= {c|cwcwd4bKb_beb? W X L j q B fc% C 5bobpb@ 7 Bb.bYc`bLc6b*cgcxcycUb4c+c[ }c~czcxd7bLb{bfb] Y Z M k r D hc' E 8bqbrb^ 8 Cb/bMc9bocMb9c:bAcicVb5c,c_ adbdBcydNbsb!bgb` 0 1 N l s x ;b( y Obtbub, R Db=bZc|bNc#b-cjcCcDcWb6c.c{ cdddEczd$bPb}bhb| 2 3 O m t F kc) G %bvbwb} 9 Eb?b0c~bOc'b/clcFcGcXb7c:c~ edfdHcAd(bQbacibab4 5 P n u H mc* I )bxbybbb! Fb@b
94 slug = "".join(random.choices(string.ascii_lowercase, k=10)) 2IcYbncGb8c*bpcbcRb1c$c- ]c^cqcudHbjbZbcb. S T J h o v +b# w Ibkblb+ Q zb,bWc[bJc0b%cccrcscSb2c'c/ _c`ctcvd1bJb]bdb: U V K i p z dc$ A 2bmbnb; 6 Ab-bXc^bKc3b(cecucvcTb3c)c= {c|cwcwd4bKb_beb? W X L j q B fc% C 5bobpb@ 7 Bb.bYc`bLc6b*cgcxcycUb4c+c[ }c~czcxd7bLb{bfb] Y Z M k r D hc' E 8bqbrb^ 8 Cb/bMc9bocMb9c:bAcicVb5c,c_ adbdBcydNbsb!bgb` 0 1 N l s x ;b( y Obtbub, R Db=bZc|bNc#b-cjcCcDcWb6c.c{ cdddEczd$bPb}bhb| 2 3 O m t F kc) G %bvbwb} 9 Eb?b0c~bOc'b/clcFcGcXb7c:c~ edfdHcAd(bQbacibab4 5 P n u H mc* I )bxbybbb! Fb@b
95 app_id = app_id or "".join(random.choices(string.digits, k=10)) 2IcYbncGb8c*bpcbcRb1c$c- ]c^cqcudHbjbZbcb. S T J h o v +b# w Ibkblb+ Q zb,bWc[bJc0b%cccrcscSb2c'c/ _c`ctcvd1bJb]bdb: U V K i p z dc$ A 2bmbnb; 6 Ab-bXc^bKc3b(cecucvcTb3c)c= {c|cwcwd4bKb_beb? W X L j q B fc% C 5bobpb@ 7 Bb.bYc`bLc6b*cgcxcycUb4c+c[ }c~czcxd7bLb{bfb] Y Z M k r D hc' E 8bqbrb^ 8 Cb/bMc9bocMb9c:bAcicVb5c,c_ adbdBcydNbsb!bgb` 0 1 N l s x ;b( y Obtbub, R Db=bZc|bNc#b-cjcCcDcWb6c.c{ cdddEczd$bPb}bhb| 2 3 O m t F kc) G %bvbwb} 9 Eb?b0c~bOc'b/clcFcGcXb7c:c~ edfdHcAd(bQbacibab4 5 P n u H mc* I )bxbybbb! Fb@b
97 return { 2IcYbncGb8c*bpcbcRb1c$c- ]c^cqcudHbjbZbcb. S T J h o v +b# w Ibkblb+ Q zb,bWc[bJc0b%cccrcscSb2c'c/ _c`ctcvd1bJb]bdb: U V K i p z dc$ A 2bmbnb; 6 Ab-bXc^bKc3b(cecucvcTb3c)c= {c|cwcwd4bKb_beb? W X L j q B fc% C 5bobpb@ 7 Bb.bYc`bLc6b*cgcxcycUb4c+c[ }c~czcxd7bLb{bfb] Y Z M k r D hc' E 8bqbrb^ 8 Cb/bMc9bocMb9c:bAcicVb5c,c_ adbdBcydNbsb!bgb` 0 1 N l s x ;b( y Obtbub, R Db=bZc|bNc#b-cjcCcDcWb6c.c{ cdddEczd$bPb}bhb| 2 3 O m t F kc) G %bvbwb} 9 Eb?b0c~bOc'b/clcFcGcXb7c:c~ edfdHcAd(bQbacibab4 5 P n u H mc* I )bxbybbb! Fb@b
98 "id": id,
99 "app_id": app_id,
100 "slug": slug,
101 "status": status,
102 "url": "http://test.com",
103 "dashboard_url": "http://test.com",
104 "created_at": "2026-09-10T12:00:00Z",
105 }
108@pytest.mark.respx 1abcdefg
109def test_starts_login_when_not_logged_in( 1abcdefg
110 logged_out_cli: None,
111 tmp_path: Path,
112 respx_mock: respx.MockRouter,
113 settings: Settings,
114) -> None:
115 respx_mock.post( 2MeVeWeXeNeYeZe
116 "/login/device/authorization", data={"client_id": settings.client_id}
117 ).mock(
118 return_value=Response(
119 200,
120 json={
121 "verification_uri_complete": "http://test.com",
122 "verification_uri": "http://test.com",
123 "user_code": "1234",
124 "device_code": "5678",
125 },
126 )
127 )
128 respx_mock.post( 2MeVeWeXeNeYeZe
129 "/login/device/token",
130 data={
131 "device_code": "5678",
132 "client_id": settings.client_id,
133 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
134 },
135 ).mock(return_value=Response(200, json={"access_token": "test_token_1234"}))
136 respx_mock.get("/teams/").mock(return_value=Response(500)) 2MeVeWeXeNeYeZe
138 with ( 2MeNe
139 changing_dir(tmp_path),
140 patch("rich_toolkit.container.getchar") as mock_getchar,
141 patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_launch,
142 ):
143 mock_getchar.side_effect = [Keys.ENTER] 2MeVeWeXeNeYeZe
145 result = runner.invoke(app, ["deploy"]) 2MeVeWeXeNeYeZe
147 assert "Welcome to FastAPI Cloud!" in result.output 2MeVeWeXeNeYeZe
148 assert "You need to be logged in to deploy to FastAPI Cloud." in result.output 2MeVeWeXeNeYeZe
149 assert "Do you want to log in now?" in result.output 2MeVeWeXeNeYeZe
150 assert "Login to FastAPI Cloud" not in result.output 2MeVeWeXeNeYeZe
151 assert "Now you are logged in!" in result.output 2MeVeWeXeNeYeZe
152 assert mock_launch.called 2MeVeWeXeNeYeZe
155def test_cancels_deploy_when_user_declines_login( 1abcdefg
156 logged_out_cli: None, tmp_path: Path
157) -> None:
158 with ( 2'e(e
159 changing_dir(tmp_path),
160 patch("rich_toolkit.container.getchar") as mock_getchar,
161 patch(
162 "fastapi_cloud_cli.commands.deploy.command._interactive_login"
163 ) as mock_login,
164 ):
165 mock_getchar.side_effect = [Keys.RIGHT_ARROW, Keys.ENTER] 2'e^e_e`e(e{e|e
166 result = runner.invoke(app, ["deploy"]) 2'e^e_e`e(e{e|e
168 assert result.exit_code == 0 2'e^e_e`e(e{e|e
169 assert "Welcome to FastAPI Cloud!" in result.output 2'e^e_e`e(e{e|e
170 assert "You need to be logged in to deploy to FastAPI Cloud." in result.output 2'e^e_e`e(e{e|e
171 assert "Do you want to log in now?" in result.output 2'e^e_e`e(e{e|e
172 assert "Deployment cancelled." in result.output 2'e^e_e`e(e{e|e
173 assert "Login to FastAPI Cloud" not in result.output 2'e^e_e`e(e{e|e
174 mock_login.assert_not_called() 2'e^e_e`e(e{e|e
177def test_shows_login_prompt_when_token_is_expired( 1abcdefg
178 temp_auth_config: Path, tmp_path: Path
179) -> None:
180 expired_token = create_jwt_token({"sub": "test_user", "exp": 0}) 2)e}e~eaf*ebfcf
181 temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}') 2)e}e~eaf*ebfcf
183 with ( 2)e*e
184 changing_dir(tmp_path),
185 patch("rich_toolkit.container.getchar") as mock_getchar,
186 patch(
187 "fastapi_cloud_cli.commands.deploy.command._interactive_login"
188 ) as mock_login,
189 ):
190 mock_getchar.side_effect = [Keys.ENTER] 2)e}e~eaf*ebfcf
191 mock_login.side_effect = typer.Exit(0) 2)e}e~eaf*ebfcf
192 result = runner.invoke(app, ["deploy"]) 2)e}e~eaf*ebfcf
194 assert "Welcome to FastAPI Cloud!" in result.output 2)e}e~eaf*ebfcf
195 assert "Your session has expired. Please log in again." in result.output 2)e}e~eaf*ebfcf
196 assert "Do you want to log in now?" in result.output 2)e}e~eaf*ebfcf
197 assert mock_login.called 2)e}e~eaf*ebfcf
200def test_fails_with_clear_error_when_running_on_ci_without_token( 1abcdefg
201 logged_out_cli: None, tmp_path: Path
202) -> None:
203 with changing_dir(tmp_path): 2dfefffgfhfifjf
204 result = runner.invoke(app, ["deploy"], env={"CI": "true"}) 2dfefffgfhfifjf
206 assert result.exit_code == 1 2dfefffgfhfifjf
207 assert result.exception is not None 2dfefffgfhfifjf
208 assert not isinstance(result.exception, OSError) 2dfefffgfhfifjf
209 assert "FASTAPI_CLOUD_TOKEN is required to deploy from CI." in result.output 2dfefffgfhfifjf
210 assert "fastapi cloud setup-ci" in result.output 2dfefffgfhfifjf
213@pytest.mark.respx 1abcdefg
214def test_deploy_json_uses_configured_app_and_skips_waiting_by_default( 1abcdefg
215 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
216) -> None:
217 app_data = _get_random_app() 21c2c3c4c5c6c7c
218 app_id = app_data["id"] 21c2c3c4c5c6c7c
219 team_id = "some-team-id" 21c2c3c4c5c6c7c
220 deployment_data = _get_random_deployment(app_id=app_id) 21c2c3c4c5c6c7c
221 uploaded_deployment_data = {**deployment_data, "status": "ready_for_build"} 21c2c3c4c5c6c7c
223 config_path = tmp_path / ".fastapicloud" / "cloud.json" 21c2c3c4c5c6c7c
224 config_path.parent.mkdir(parents=True, exist_ok=True) 21c2c3c4c5c6c7c
225 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 21c2c3c4c5c6c7c
227 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 21c2c3c4c5c6c7c
228 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 21c2c3c4c5c6c7c
229 return_value=Response(201, json=deployment_data)
230 )
231 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 21c2c3c4c5c6c7c
232 return_value=Response(
233 200,
234 json={"url": "http://test.com", "fields": {"key": "value"}},
235 )
236 )
237 respx_mock.post("http://test.com", data={"key": "value"}).mock( 21c2c3c4c5c6c7c
238 return_value=Response(200)
239 )
240 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 21c2c3c4c5c6c7c
241 return_value=Response(200, json=uploaded_deployment_data)
242 )
244 with changing_dir(tmp_path): 21c2c3c4c5c6c7c
245 result = runner.invoke(app, ["deploy", "--json"]) 21c2c3c4c5c6c7c
247 assert result.exit_code == 0 21c2c3c4c5c6c7c
248 assert json.loads(result.stdout) == { 21c2c3c4c5c6c7c
249 "data": {
250 "deployment_id": deployment_data["id"],
251 "app_id": app_id,
252 "slug": deployment_data["slug"],
253 "status": uploaded_deployment_data["status"],
254 "dashboard_url": deployment_data["dashboard_url"],
255 "url": deployment_data["url"],
256 },
257 "hint": (
258 "Check deployment status in the FastAPI Cloud dashboard: "
259 f"{deployment_data['dashboard_url']}"
260 ),
261 }
264def test_deploy_json_returns_missing_required_input_without_app_context( 1abcdefg
265 logged_in_cli: None, tmp_path: Path
266) -> None:
267 with changing_dir(tmp_path): 20f1f2f3f4f5f6f
268 result = runner.invoke(app, ["deploy", "--json"]) 20f1f2f3f4f5f6f
270 assert result.exit_code == 1 20f1f2f3f4f5f6f
271 assert json.loads(result.stdout) == { 20f1f2f3f4f5f6f
272 "error": {
273 "code": "missing_required_input",
274 "message": "App ID is required.",
275 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
276 }
277 }
280def test_deploy_json_returns_not_logged_in_without_prompt( 1abcdefg
281 logged_out_cli: None, tmp_path: Path
282) -> None:
283 with changing_dir(tmp_path): 27f8f9f!f#f$f%f
284 result = runner.invoke(app, ["deploy", "--json"]) 27f8f9f!f#f$f%f
286 assert result.exit_code == 1 27f8f9f!f#f$f%f
287 assert json.loads(result.stdout) == { 27f8f9f!f#f$f%f
288 "error": {
289 "code": "not_logged_in",
290 "message": "No credentials found.",
291 "hint": "Run `fastapi cloud login` or set FASTAPI_CLOUD_TOKEN.",
292 }
293 }
296@pytest.mark.respx 1abcdefg
297def test_deploy_json_includes_large_file_warnings( 1abcdefg
298 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
299) -> None:
300 app_data = _get_random_app() 2RbSbTbUbVbWbXb
301 app_id = app_data["id"] 2RbSbTbUbVbWbXb
302 team_id = "some-team-id" 2RbSbTbUbVbWbXb
303 deployment_data = _get_random_deployment( 2RbSbTbUbVbWbXb
304 app_id=app_id,
305 status="ready_for_build",
306 )
308 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2RbSbTbUbVbWbXb
309 config_path.parent.mkdir(parents=True, exist_ok=True) 2RbSbTbUbVbWbXb
310 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2RbSbTbUbVbWbXb
311 _create_file(tmp_path / "model.bin", 12 * 1024 * 1024) 2RbSbTbUbVbWbXb
313 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2RbSbTbUbVbWbXb
314 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2RbSbTbUbVbWbXb
315 return_value=Response(201, json=deployment_data)
316 )
317 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2RbSbTbUbVbWbXb
318 return_value=Response(
319 200,
320 json={"url": "http://test.com", "fields": {"key": "value"}},
321 )
322 )
323 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2RbSbTbUbVbWbXb
324 return_value=Response(200)
325 )
326 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2RbSbTbUbVbWbXb
327 return_value=Response(
328 200, json={**deployment_data, "status": "ready_for_build"}
329 )
330 )
332 with changing_dir(tmp_path): 2RbSbTbUbVbWbXb
333 result = runner.invoke(app, ["deploy", "--json"]) 2RbSbTbUbVbWbXb
335 assert result.exit_code == 0 2RbSbTbUbVbWbXb
336 assert json.loads(result.stdout)["warnings"] == [ 2RbSbTbUbVbWbXb
337 {
338 "code": "large_files",
339 "message": "1 uploaded file is larger than 10 MB.",
340 "files": [{"path": "model.bin", "size_bytes": 12 * 1024 * 1024}],
341 }
342 ]
345@pytest.mark.respx 1abcdefg
346def test_shows_error_when_trying_to_get_teams( 1abcdefg
347 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
348) -> None:
349 steps = [Keys.ENTER] 2kfvfwfxflfyfzf
351 respx_mock.get("/teams/").mock(return_value=Response(500)) 2kfvfwfxflfyfzf
353 with ( 2kflf
354 changing_dir(tmp_path),
355 patch("rich_toolkit.container.getchar") as mock_getchar,
356 ):
357 mock_getchar.side_effect = steps 2kfvfwfxflfyfzf
359 result = runner.invoke(app, ["deploy"]) 2kfvfwfxflfyfzf
361 assert result.exit_code == 1 2kfvfwfxflfyfzf
363 assert "Error fetching teams. Please try again later" in result.output 2kfvfwfxflfyfzf
366@pytest.mark.respx 1abcdefg
367def test_handles_invalid_auth( 1abcdefg
368 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
369) -> None:
370 steps = [Keys.ENTER] 2mfAfBfCfnfDfEf
372 respx_mock.get("/teams/").mock(return_value=Response(401)) 2mfAfBfCfnfDfEf
374 with ( 2mfnf
375 changing_dir(tmp_path),
376 patch("rich_toolkit.container.getchar") as mock_getchar,
377 ):
378 mock_getchar.side_effect = steps 2mfAfBfCfnfDfEf
380 result = runner.invoke(app, ["deploy"]) 2mfAfBfCfnfDfEf
382 assert result.exit_code == 1 2mfAfBfCfnfDfEf
384 assert "The specified token is not valid" in result.output 2mfAfBfCfnfDfEf
387@pytest.mark.respx 1abcdefg
388def test_shows_teams( 1abcdefg
389 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
390) -> None:
391 steps = [Keys.ENTER, Keys.CTRL_C] 2_d}d~dae`ddeee
393 team_1 = _get_random_team() 2_d}d~dae`ddeee
394 team_2 = _get_random_team() 2_d}d~dae`ddeee
396 respx_mock.get("/teams/").mock( 2_d}d~dae`ddeee
397 return_value=Response(
398 200,
399 json={"data": [team_1, team_2]},
400 )
401 )
403 with ( 2_d`d
404 changing_dir(tmp_path),
405 patch("rich_toolkit.container.getchar") as mock_getchar,
406 ):
407 mock_getchar.side_effect = steps 2_d}d~dae`ddeee
409 result = runner.invoke(app, ["deploy"]) 2_d}d~dae`ddeee
411 assert result.exit_code == 1 2_d}d~dae`ddeee
413 assert team_1["name"] in result.output 2_d}d~dae`ddeee
414 assert team_2["name"] in result.output 2_d}d~dae`ddeee
417@pytest.mark.respx 1abcdefg
418def test_filter_teams( 1abcdefg
419 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
420) -> None:
421 steps = [*"al", Keys.ENTER, Keys.CTRL_C] 29d?d@d[d,d]d^d
423 team_1 = _get_random_team(name="Alpha Team") 29d?d@d[d,d]d^d
424 team_2 = _get_random_team(name="Beta Team") 29d?d@d[d,d]d^d
426 respx_mock.get("/teams/").mock( 29d?d@d[d,d]d^d
427 return_value=Response(
428 200,
429 json={"data": [team_1, team_2]},
430 )
431 )
433 with ( 29d,d
434 changing_dir(tmp_path),
435 patch("rich_toolkit.container.getchar") as mock_getchar,
436 ):
437 mock_getchar.side_effect = steps 29d?d@d[d,d]d^d
439 result = runner.invoke(app, ["deploy"]) 29d?d@d[d,d]d^d
441 assert result.exit_code == 1 29d?d@d[d,d]d^d
443 assert "Filter: al" in result.output 29d?d@d[d,d]d^d
445 # Truncate part of the output before "Filter: al"
446 filer_pos = result.output.rfind("Filter: al") 29d?d@d[d,d]d^d
447 last_output = result.output[filer_pos:] 29d?d@d[d,d]d^d
449 assert team_1["name"] in last_output 29d?d@d[d,d]d^d
450 assert team_2["name"] not in last_output 29d?d@d[d,d]d^d
453@pytest.mark.respx 1abcdefg
454def test_asks_for_app_name_after_team( 1abcdefg
455 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
456) -> None:
457 steps = [Keys.ENTER, Keys.ENTER, Keys.ENTER, Keys.CTRL_C] 2Oe8e9e!eSe$e%e
459 respx_mock.get("/teams/").mock( 2Oe8e9e!eSe$e%e
460 return_value=Response(
461 200,
462 json={"data": [_get_random_team(), _get_random_team()]},
463 )
464 )
466 with ( 2OeSe
467 changing_dir(tmp_path),
468 patch("rich_toolkit.container.getchar") as mock_getchar,
469 ):
470 mock_getchar.side_effect = steps 2Oe8e9e!eSe$e%e
472 result = runner.invoke(app, ["deploy"]) 2Oe8e9e!eSe$e%e
474 assert result.exit_code == 1 2Oe8e9e!eSe$e%e
476 assert "What's your app name?" in result.output 2Oe8e9e!eSe$e%e
479@pytest.mark.respx 1abcdefg
480def test_filter_apps( 1abcdefg
481 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
482) -> None:
483 steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"an", Keys.ENTER, Keys.CTRL_C] 2!c;c=c?c#c@c[c
485 team = _get_random_team() 2!c;c=c?c#c@c[c
487 respx_mock.get("/teams/").mock( 2!c;c=c?c#c@c[c
488 return_value=Response(
489 200,
490 json={"data": [team]},
491 )
492 )
494 app_1 = _get_random_app(team_id=team["id"], slug="My App") 2!c;c=c?c#c@c[c
495 app_2 = _get_random_app(team_id=team["id"], slug="Another App") 2!c;c=c?c#c@c[c
497 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2!c;c=c?c#c@c[c
498 return_value=Response(200, json={"data": [app_1, app_2]})
499 )
501 with ( 2!c#c
502 changing_dir(tmp_path),
503 patch("rich_toolkit.container.getchar") as mock_getchar,
504 ):
505 mock_getchar.side_effect = steps 2!c;c=c?c#c@c[c
507 result = runner.invoke(app, ["deploy"]) 2!c;c=c?c#c@c[c
509 assert result.exit_code == 1 2!c;c=c?c#c@c[c
511 assert "Filter: an" in result.output 2!c;c=c?c#c@c[c
513 # Truncate part of the output before "Filter: an"
514 filer_pos = result.output.rfind("Filter: an") 2!c;c=c?c#c@c[c
515 last_output = result.output[filer_pos:] 2!c;c=c?c#c@c[c
517 assert app_1["slug"] not in last_output 2!c;c=c?c#c@c[c
518 assert app_2["slug"] in last_output 2!c;c=c?c#c@c[c
521@pytest.mark.respx 1abcdefg
522def test_creates_app_on_backend( 1abcdefg
523 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
524) -> None:
525 steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER, Keys.ENTER] 2Wd!d%d)dZd-d:d
527 team = _get_random_team() 2Wd!d%d)dZd-d:d
529 respx_mock.get("/teams/").mock( 2Wd!d%d)dZd-d:d
530 return_value=Response(
531 200,
532 json={"data": [team]},
533 )
534 )
536 respx_mock.post( 2Wd!d%d)dZd-d:d
537 "/apps/", json={"name": "demo", "team_id": team["id"], "directory": None}
538 ).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"])))
540 with ( 2WdZd
541 changing_dir(tmp_path),
542 patch("rich_toolkit.container.getchar") as mock_getchar,
543 ):
544 mock_getchar.side_effect = steps 2Wd!d%d)dZd-d:d
546 result = runner.invoke(app, ["deploy"]) 2Wd!d%d)dZd-d:d
548 assert result.exit_code == 1 2Wd!d%d)dZd-d:d
550 assert "App created successfully" in result.output 2Wd!d%d)dZd-d:d
553@pytest.mark.respx 1abcdefg
554def test_shows_api_message_when_create_app_is_forbidden( 1abcdefg
555 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
556) -> None:
557 steps = [Keys.ENTER, Keys.ENTER, *"demo", Keys.ENTER, Keys.ENTER, Keys.ENTER] 2{dneperebeuewe
558 team = _get_random_team() 2{dneperebeuewe
560 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2{dneperebeuewe
561 respx_mock.post( 2{dneperebeuewe
562 "/apps/", json={"name": "demo", "team_id": team["id"], "directory": None}
563 ).mock(
564 return_value=Response(
565 403,
566 json={"detail": "App limit reached"},
567 )
568 )
570 with ( 2{dbe
571 changing_dir(tmp_path),
572 patch("rich_toolkit.container.getchar") as mock_getchar,
573 ):
574 mock_getchar.side_effect = steps 2{dneperebeuewe
576 result = runner.invoke(app, ["deploy"]) 2{dneperebeuewe
578 assert result.exit_code == 1 2{dneperebeuewe
579 assert "App limit reached" in result.output 2{dneperebeuewe
582@pytest.mark.respx 1abcdefg
583def test_creates_app_with_directory( 1abcdefg
584 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
585) -> None:
586 steps = [ 2BdMdOdQdDdSdUd
587 Keys.ENTER,
588 Keys.ENTER,
589 *"demo",
590 Keys.ENTER,
591 *"src",
592 Keys.ENTER,
593 Keys.ENTER,
594 ]
596 team = _get_random_team() 2BdMdOdQdDdSdUd
598 respx_mock.get("/teams/").mock( 2BdMdOdQdDdSdUd
599 return_value=Response(
600 200,
601 json={"data": [team]},
602 )
603 )
605 respx_mock.post( 2BdMdOdQdDdSdUd
606 "/apps/", json={"name": "demo", "team_id": team["id"], "directory": "src"}
607 ).mock(return_value=Response(201, json=_get_random_app(team_id=team["id"])))
609 with ( 2BdDd
610 changing_dir(tmp_path),
611 patch("rich_toolkit.container.getchar") as mock_getchar,
612 ):
613 mock_getchar.side_effect = steps 2BdMdOdQdDdSdUd
615 result = runner.invoke(app, ["deploy"]) 2BdMdOdQdDdSdUd
617 assert result.exit_code == 1 2BdMdOdQdDdSdUd
619 assert "App created successfully" in result.output 2BdMdOdQdDdSdUd
620 assert "Directory where your app's pyproject.toml file lives" in result.output 2BdMdOdQdDdSdUd
621 assert "Directory: src" in result.output 2BdMdOdQdDdSdUd
624@pytest.mark.respx 1abcdefg
625@pytest.mark.parametrize( 1abcdefg
626 "directory,expected_error",
627 [
628 ("~/src", "cannot start with '~'"),
629 ("/absolute/path", "must be a relative path, not absolute"),
630 ("src/../etc", "cannot contain '..' path segments"),
631 ("src/@app", "contains invalid characters"),
632 ],
633)
634def test_shows_validation_error_for_invalid_directory( 1abcdefg
635 logged_in_cli: None,
636 tmp_path: Path,
637 respx_mock: respx.MockRouter,
638 directory: str,
639 expected_error: str,
640) -> None:
641 steps = [ 27e=e?e@e#e[e]e
642 Keys.ENTER, # Select team
643 Keys.ENTER, # Confirm new app
644 *"demo",
645 Keys.ENTER, # App name
646 *directory,
647 Keys.ENTER, # Submit invalid directory -> validation error shown
648 Keys.CTRL_C, # Cancel
649 ]
651 respx_mock.get("/teams/").mock( 27e=e?e@e#e[e]e
652 return_value=Response(
653 200,
654 json={"data": [_get_random_team()]},
655 )
656 )
658 with ( 27e#e
659 changing_dir(tmp_path),
660 patch("rich_toolkit.container.getchar") as mock_getchar,
661 ):
662 mock_getchar.side_effect = steps 27e=e?e@e#e[e]e
664 result = runner.invoke(app, ["deploy"]) 27e=e?e@e#e[e]e
666 assert expected_error in result.output 27e=e?e@e#e[e]e
669@pytest.mark.respx 1abcdefg
670def test_cancels_deployment_when_user_selects_no( 1abcdefg
671 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
672) -> None:
673 steps = [ 2mePeQeReteTeUe
674 Keys.ENTER,
675 Keys.ENTER,
676 *"demo",
677 Keys.ENTER,
678 Keys.ENTER,
679 Keys.DOWN_ARROW,
680 Keys.ENTER,
681 ]
683 team = _get_random_team() 2mePeQeReteTeUe
685 respx_mock.get("/teams/").mock( 2mePeQeReteTeUe
686 return_value=Response(
687 200,
688 json={"data": [team]},
689 )
690 )
692 with ( 2mete
693 changing_dir(tmp_path),
694 patch("rich_toolkit.container.getchar") as mock_getchar,
695 ):
696 mock_getchar.side_effect = steps 2mePeQeReteTeUe
698 result = runner.invoke(app, ["deploy"]) 2mePeQeReteTeUe
700 assert result.exit_code == 0 2mePeQeReteTeUe
701 assert "Deployment cancelled." in result.output 2mePeQeReteTeUe
704@pytest.mark.respx 1abcdefg
705def test_uses_existing_app( 1abcdefg
706 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
707) -> None:
708 steps = [Keys.ENTER, Keys.RIGHT_ARROW, Keys.ENTER, *"demo", Keys.ENTER] 2CdNdPdRdEdTdVd
710 team = _get_random_team() 2CdNdPdRdEdTdVd
712 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2CdNdPdRdEdTdVd
714 app_data = _get_random_app(team_id=team["id"]) 2CdNdPdRdEdTdVd
716 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2CdNdPdRdEdTdVd
717 return_value=Response(200, json={"data": [app_data]})
718 )
720 with ( 2CdEd
721 changing_dir(tmp_path),
722 patch("rich_toolkit.container.getchar") as mock_getchar,
723 ):
724 mock_getchar.side_effect = steps 2CdNdPdRdEdTdVd
726 result = runner.invoke(app, ["deploy"]) 2CdNdPdRdEdTdVd
728 assert result.exit_code == 1 2CdNdPdRdEdTdVd
730 assert "Select the app you want to deploy to:" in result.output 2CdNdPdRdEdTdVd
731 assert app_data["slug"] in result.output 2CdNdPdRdEdTdVd
734@pytest.mark.respx 1abcdefg
735def test_uses_existing_app_with_directory( 1abcdefg
736 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
737) -> None:
738 steps = [ 2Yd$d(d+d1d/d=d
739 Keys.ENTER, # Select team
740 Keys.RIGHT_ARROW, # Choose existing app (No)
741 Keys.ENTER,
742 Keys.ENTER, # Select app from list
743 Keys.ENTER, # Accept pre-filled directory
744 Keys.DOWN_ARROW, # Cancel deployment
745 Keys.ENTER,
746 ]
748 team = _get_random_team() 2Yd$d(d+d1d/d=d
750 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2Yd$d(d+d1d/d=d
752 app_data = _get_random_app(team_id=team["id"], directory="backend") 2Yd$d(d+d1d/d=d
754 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2Yd$d(d+d1d/d=d
755 return_value=Response(200, json={"data": [app_data]})
756 )
758 with ( 2Yd1d
759 changing_dir(tmp_path),
760 patch("rich_toolkit.container.getchar") as mock_getchar,
761 ):
762 mock_getchar.side_effect = steps 2Yd$d(d+d1d/d=d
764 result = runner.invoke(app, ["deploy"]) 2Yd$d(d+d1d/d=d
766 assert "Directory: backend" in result.output 2Yd$d(d+d1d/d=d
769@pytest.mark.respx 1abcdefg
770def test_uses_existing_app_and_changes_directory( 1abcdefg
771 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
772) -> None:
773 steps = [ 2Xd#d'd*d0d.d;d
774 Keys.ENTER, # Select team
775 Keys.RIGHT_ARROW, # Choose existing app (No)
776 Keys.ENTER,
777 Keys.ENTER, # Select app from list
778 *([Keys.BACKSPACE] * len("backend")), # Clear pre-filled directory
779 *"src",
780 Keys.ENTER, # Submit new directory
781 Keys.DOWN_ARROW, # Cancel deployment
782 Keys.ENTER,
783 ]
785 team = _get_random_team() 2Xd#d'd*d0d.d;d
787 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2Xd#d'd*d0d.d;d
789 app_data = _get_random_app(team_id=team["id"], directory="backend") 2Xd#d'd*d0d.d;d
791 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2Xd#d'd*d0d.d;d
792 return_value=Response(200, json={"data": [app_data]})
793 )
795 with ( 2Xd0d
796 changing_dir(tmp_path),
797 patch("rich_toolkit.container.getchar") as mock_getchar,
798 ):
799 mock_getchar.side_effect = steps 2Xd#d'd*d0d.d;d
801 result = runner.invoke(app, ["deploy"]) 2Xd#d'd*d0d.d;d
803 assert "Directory: src" in result.output 2Xd#d'd*d0d.d;d
806@pytest.mark.respx 1abcdefg
807def test_updates_app_directory_via_api_when_changed( 1abcdefg
808 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
809) -> None:
810 steps = [ 2+ ; @ ^ , } bb
811 Keys.ENTER, # Select team
812 Keys.RIGHT_ARROW, # Choose existing app (No)
813 Keys.ENTER,
814 Keys.ENTER, # Select app from list
815 *([Keys.BACKSPACE] * len("backend")), # Clear pre-filled directory
816 *"src",
817 Keys.ENTER, # Submit new directory
818 Keys.ENTER, # Confirm deployment
819 ]
821 team = _get_random_team() 2+ ; @ ^ , } bb
823 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2+ ; @ ^ , } bb
825 app_data = _get_random_app(team_id=team["id"], directory="backend") 2+ ; @ ^ , } bb
827 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2+ ; @ ^ , } bb
828 return_value=Response(200, json={"data": [app_data]})
829 )
831 updated_app_data = {**app_data, "directory": "src"} 2+ ; @ ^ , } bb
832 (tmp_path / "src").mkdir() 2+ ; @ ^ , } bb
834 patch_route = respx_mock.patch( 2+ ; @ ^ , } bb
835 f"/apps/{app_data['id']}", json={"directory": "src"}
836 ).mock(return_value=Response(200, json=updated_app_data))
838 respx_mock.get(f"/apps/{app_data['id']}").mock( 2+ ; @ ^ , } bb
839 return_value=Response(200, json=updated_app_data)
840 )
842 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2+ ; @ ^ , } bb
844 respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( 2+ ; @ ^ , } bb
845 return_value=Response(201, json=deployment_data)
846 )
847 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2+ ; @ ^ , } bb
848 return_value=Response(
849 200,
850 json={"url": "http://test.com", "fields": {"key": "value"}},
851 )
852 )
853 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2+ ; @ ^ , } bb
854 return_value=Response(
855 200, json={**deployment_data, "status": "ready_for_build"}
856 )
857 )
858 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2+ ; @ ^ , } bb
859 return_value=Response(200)
860 )
861 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2+ ; @ ^ , } bb
862 return_value=Response(
863 200,
864 content=build_logs_response(
865 {"type": "message", "message": "Building...", "id": "1"},
866 {"type": "complete"},
867 ),
868 )
869 )
871 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2+ ; @ ^ , } bb
872 return_value=Response(200, json={**deployment_data, "status": "success"})
873 )
875 with ( 1+,
876 changing_dir(tmp_path),
877 patch("rich_toolkit.container.getchar") as mock_getchar,
878 ):
879 mock_getchar.side_effect = steps 2+ ; @ ^ , } bb
881 result = runner.invoke(app, ["deploy"]) 2+ ; @ ^ , } bb
883 assert result.exit_code == 0 2+ ; @ ^ , } bb
884 assert patch_route.called 2+ ; @ ^ , } bb
885 assert "App directory updated" in result.output 2+ ; @ ^ , } bb
888@pytest.mark.respx 1abcdefg
889def test_does_not_update_app_directory_when_unchanged( 1abcdefg
890 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
891) -> None:
892 steps = [ 2jbJbKbLbsbPbQb
893 Keys.ENTER, # Select team
894 Keys.RIGHT_ARROW, # Choose existing app (No)
895 Keys.ENTER,
896 Keys.ENTER, # Select app from list
897 Keys.ENTER, # Accept pre-filled directory (unchanged)
898 Keys.ENTER, # Confirm deployment
899 ]
900 (tmp_path / "backend").mkdir() 2jbJbKbLbsbPbQb
902 team = _get_random_team() 2jbJbKbLbsbPbQb
904 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2jbJbKbLbsbPbQb
906 app_data = _get_random_app(team_id=team["id"], directory="backend") 2jbJbKbLbsbPbQb
908 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2jbJbKbLbsbPbQb
909 return_value=Response(200, json={"data": [app_data]})
910 )
912 respx_mock.get(f"/apps/{app_data['id']}").mock( 2jbJbKbLbsbPbQb
913 return_value=Response(200, json=app_data)
914 )
916 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2jbJbKbLbsbPbQb
918 respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( 2jbJbKbLbsbPbQb
919 return_value=Response(201, json=deployment_data)
920 )
921 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2jbJbKbLbsbPbQb
922 return_value=Response(
923 200,
924 json={"url": "http://test.com", "fields": {"key": "value"}},
925 )
926 )
927 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2jbJbKbLbsbPbQb
928 return_value=Response(
929 200, json={**deployment_data, "status": "ready_for_build"}
930 )
931 )
932 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2jbJbKbLbsbPbQb
933 return_value=Response(200)
934 )
935 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2jbJbKbLbsbPbQb
936 return_value=Response(
937 200,
938 content=build_logs_response(
939 {"type": "message", "message": "Building...", "id": "1"},
940 {"type": "complete"},
941 ),
942 )
943 )
945 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2jbJbKbLbsbPbQb
946 return_value=Response(200, json={**deployment_data, "status": "success"})
947 )
949 with ( 2jbsb
950 changing_dir(tmp_path),
951 patch("rich_toolkit.container.getchar") as mock_getchar,
952 ):
953 mock_getchar.side_effect = steps 2jbJbKbLbsbPbQb
955 result = runner.invoke(app, ["deploy"]) 2jbJbKbLbsbPbQb
957 assert result.exit_code == 0 2jbJbKbLbsbPbQb
958 assert "App directory updated" not in result.output 2jbJbKbLbsbPbQb
961@pytest.mark.respx 1abcdefg
962def test_exits_successfully_when_deployment_is_done( 1abcdefg
963 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
964) -> None:
965 steps = [ 2Zb]b_b{b!b}bac
966 Keys.ENTER,
967 Keys.ENTER,
968 *"demo",
969 Keys.ENTER,
970 Keys.ENTER,
971 Keys.ENTER,
972 ]
974 team_data = _get_random_team() 2Zb]b_b{b!b}bac
975 app_data = _get_random_app(team_id=team_data["id"]) 2Zb]b_b{b!b}bac
977 respx_mock.get("/teams/").mock( 2Zb]b_b{b!b}bac
978 return_value=Response(200, json={"data": [team_data]})
979 )
981 respx_mock.post( 2Zb]b_b{b!b}bac
982 "/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
983 ).mock(return_value=Response(201, json=app_data))
985 respx_mock.get(f"/apps/{app_data['id']}").mock( 2Zb]b_b{b!b}bac
986 return_value=Response(200, json=app_data)
987 )
989 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2Zb]b_b{b!b}bac
991 respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( 2Zb]b_b{b!b}bac
992 return_value=Response(201, json=deployment_data)
993 )
994 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2Zb]b_b{b!b}bac
995 return_value=Response(
996 200,
997 json={
998 "url": "http://test.com",
999 "fields": {"key": "value"},
1000 },
1001 )
1002 )
1004 respx_mock.post( 2Zb]b_b{b!b}bac
1005 f"/deployments/{deployment_data['id']}/upload-complete",
1006 ).mock(
1007 return_value=Response(
1008 200, json={**deployment_data, "status": "ready_for_build"}
1009 )
1010 )
1012 respx_mock.post( 2Zb]b_b{b!b}bac
1013 "http://test.com",
1014 data={"key": "value"},
1015 ).mock(return_value=Response(200))
1017 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2Zb]b_b{b!b}bac
1018 return_value=Response(
1019 200,
1020 content=build_logs_response(
1021 {"type": "message", "message": "Building...", "id": "1"},
1022 {"type": "complete"},
1023 ),
1024 )
1025 )
1027 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2Zb]b_b{b!b}bac
1028 return_value=Response(200, json={**deployment_data, "status": "success"})
1029 )
1031 with ( 2Zb!b
1032 changing_dir(tmp_path),
1033 patch("rich_toolkit.container.getchar") as mock_getchar,
1034 ):
1035 mock_getchar.side_effect = steps 2Zb]b_b{b!b}bac
1037 result = runner.invoke(app, ["deploy"]) 2Zb]b_b{b!b}bac
1039 assert result.exit_code == 0 2Zb]b_b{b!b}bac
1042@pytest.mark.respx 1abcdefg
1043def test_exits_successfully_when_deployment_is_done_when_app_is_configured( 1abcdefg
1044 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1045) -> None:
1046 app_data = _get_random_app() 2cbdbebfbgbhbib
1047 team_data = _get_random_team() 2cbdbebfbgbhbib
1048 app_id = app_data["id"] 2cbdbebfbgbhbib
1049 team_id = team_data["id"] 2cbdbebfbgbhbib
1050 deployment_data = _get_random_deployment(app_id=app_id) 2cbdbebfbgbhbib
1052 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2cbdbebfbgbhbib
1054 config_path.parent.mkdir(parents=True, exist_ok=True) 2cbdbebfbgbhbib
1055 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2cbdbebfbgbhbib
1057 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2cbdbebfbgbhbib
1059 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2cbdbebfbgbhbib
1060 return_value=Response(201, json=deployment_data)
1061 )
1063 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2cbdbebfbgbhbib
1064 return_value=Response(
1065 200,
1066 json={"url": "http://test.com", "fields": {"key": "value"}},
1067 )
1068 )
1070 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2cbdbebfbgbhbib
1071 return_value=Response(200)
1072 )
1074 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2cbdbebfbgbhbib
1075 return_value=Response(
1076 200,
1077 content=build_logs_response(
1078 {"type": "message", "message": "Building...", "id": "1"},
1079 {"type": "message", "message": "All good!", "id": "2"},
1080 {"type": "complete"},
1081 ),
1082 )
1083 )
1085 respx_mock.post( 2cbdbebfbgbhbib
1086 f"/deployments/{deployment_data['id']}/upload-complete",
1087 ).mock(
1088 return_value=Response(
1089 200, json={**deployment_data, "status": "ready_for_build"}
1090 )
1091 )
1093 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2cbdbebfbgbhbib
1094 return_value=Response(200, json={**deployment_data, "status": "success"})
1095 )
1097 with changing_dir(tmp_path): 2cbdbebfbgbhbib
1098 result = runner.invoke(app, ["deploy"]) 2cbdbebfbgbhbib
1100 assert result.exit_code == 0 2cbdbebfbgbhbib
1102 # check that logs are shown
1103 assert "All good!" in result.output 2cbdbebfbgbhbib
1105 # check that the app URL is shown
1106 assert deployment_data["url"] in result.output 2cbdbebfbgbhbib
1109@pytest.mark.respx 1abcdefg
1110def test_exits_with_error_when_deployment_fails_to_build( 1abcdefg
1111 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1112) -> None:
1113 app_data = _get_random_app() 2. : ? ] ` | ab
1114 team_data = _get_random_team() 2. : ? ] ` | ab
1115 app_id = app_data["id"] 2. : ? ] ` | ab
1116 team_id = team_data["id"] 2. : ? ] ` | ab
1117 deployment_data = _get_random_deployment(app_id=app_id) 2. : ? ] ` | ab
1119 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2. : ? ] ` | ab
1121 config_path.parent.mkdir(parents=True, exist_ok=True) 2. : ? ] ` | ab
1122 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2. : ? ] ` | ab
1124 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2. : ? ] ` | ab
1126 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2. : ? ] ` | ab
1127 return_value=Response(201, json=deployment_data)
1128 )
1130 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2. : ? ] ` | ab
1131 return_value=Response(
1132 200,
1133 json={"url": "http://test.com", "fields": {"key": "value"}},
1134 )
1135 )
1137 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2. : ? ] ` | ab
1138 return_value=Response(200)
1139 )
1141 respx_mock.get(f"/deployments/{deployment_data['id']}").respond( 2. : ? ] ` | ab
1142 200,
1143 json={**deployment_data, "status": "building_image_failed", "failure": None},
1144 )
1145 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2. : ? ] ` | ab
1146 return_value=Response(
1147 200,
1148 content=build_logs_response(
1149 {"type": "message", "message": "Original build failure", "id": "1"},
1150 {"type": "failed", "id": "2"},
1151 ),
1152 )
1153 )
1155 respx_mock.post( 2. : ? ] ` | ab
1156 f"/deployments/{deployment_data['id']}/upload-complete",
1157 ).mock(
1158 return_value=Response(
1159 200, json={**deployment_data, "status": "ready_for_build"}
1160 )
1161 )
1163 with changing_dir(tmp_path): 2. : ? ] ` | ab
1164 result = runner.invoke(app, ["deploy"]) 2. : ? ] ` | ab
1166 assert result.exit_code == 1 2. : ? ] ` | ab
1168 assert "Original build failure" in result.output 2. : ? ] ` | ab
1169 assert "Oh no! Something went wrong" in result.output 2. : ? ] ` | ab
1170 assert deployment_data["dashboard_url"] in result.output 2. : ? ] ` | ab
1173@pytest.mark.respx 1abcdefg
1174def test_shows_error_when_deployment_build_fails( 1abcdefg
1175 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1176) -> None:
1177 app_data = _get_random_app() 2lbnbpbrbubwbyb
1178 team_data = _get_random_team() 2lbnbpbrbubwbyb
1179 app_id = app_data["id"] 2lbnbpbrbubwbyb
1180 team_id = team_data["id"] 2lbnbpbrbubwbyb
1181 deployment_data = _get_random_deployment(app_id=app_id) 2lbnbpbrbubwbyb
1183 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2lbnbpbrbubwbyb
1185 config_path.parent.mkdir(parents=True, exist_ok=True) 2lbnbpbrbubwbyb
1186 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2lbnbpbrbubwbyb
1188 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2lbnbpbrbubwbyb
1190 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2lbnbpbrbubwbyb
1191 return_value=Response(201, json=deployment_data)
1192 )
1194 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2lbnbpbrbubwbyb
1195 return_value=Response(
1196 200,
1197 json={"url": "http://test.com", "fields": {"key": "value"}},
1198 )
1199 )
1201 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2lbnbpbrbubwbyb
1202 return_value=Response(200)
1203 )
1205 respx_mock.get(f"/deployments/{deployment_data['id']}").respond(503) 2lbnbpbrbubwbyb
1206 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2lbnbpbrbubwbyb
1207 return_value=Response(
1208 200,
1209 json={"type": "failed"},
1210 )
1211 )
1213 respx_mock.post( 2lbnbpbrbubwbyb
1214 f"/deployments/{deployment_data['id']}/upload-complete",
1215 ).mock(
1216 return_value=Response(
1217 200, json={**deployment_data, "status": "ready_for_build"}
1218 )
1219 )
1221 with changing_dir(tmp_path): 2lbnbpbrbubwbyb
1222 result = runner.invoke(app, ["deploy"]) 2lbnbpbrbubwbyb
1224 assert "Something went wrong" in result.stdout 2lbnbpbrbubwbyb
1226 assert result.exit_code == 1 2lbnbpbrbubwbyb
1229@pytest.mark.respx 1abcdefg
1230def test_shows_error_when_app_does_not_exist( 1abcdefg
1231 logged_in_cli: None, configured_app: ConfiguredApp, respx_mock: respx.MockRouter
1232) -> None:
1233 respx_mock.get(f"/apps/{configured_app.app_id}").mock(return_value=Response(404)) 2OfPfQfRfSfTfUf
1235 with changing_dir(configured_app.path): 2OfPfQfRfSfTfUf
1236 result = runner.invoke(app, ["deploy"]) 2OfPfQfRfSfTfUf
1238 assert result.exit_code == 1 2OfPfQfRfSfTfUf
1240 assert "App not found" in result.output 2OfPfQfRfSfTfUf
1243def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Result: 1abcdefg
1244 steps = [ 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1245 Keys.ENTER,
1246 Keys.ENTER,
1247 *"demo",
1248 Keys.ENTER,
1249 Keys.ENTER,
1250 Keys.ENTER,
1251 ]
1253 team_data = _get_random_team() 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1254 app_data = _get_random_app(team_id=team_data["id"]) 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1255 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1257 respx_mock.get("/teams/").mock( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1258 return_value=Response(
1259 200,
1260 json={"data": [team_data]},
1261 )
1262 )
1264 respx_mock.post( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1265 "/apps/", json={"name": "demo", "team_id": team_data["id"], "directory": None}
1266 ).mock(return_value=Response(201, json=app_data))
1268 respx_mock.get(f"/apps/{app_data['id']}").mock( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1269 return_value=Response(200, json=app_data)
1270 )
1272 respx_mock.post(f"/apps/{app_data['id']}/deployments/").mock( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1273 return_value=Response(201, json=deployment_data)
1274 )
1275 respx_mock.post( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1276 f"/deployments/{deployment_data['id']}/upload",
1277 ).mock(
1278 return_value=Response(
1279 200,
1280 json={
1281 "url": "http://test.com",
1282 "fields": {"key": "value"},
1283 },
1284 )
1285 )
1287 respx_mock.post( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1288 f"/deployments/{deployment_data['id']}/upload-complete",
1289 ).mock(
1290 return_value=Response(
1291 200, json={**deployment_data, "status": "ready_for_build"}
1292 )
1293 )
1295 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1296 return_value=Response(200)
1297 )
1299 with ( 2YbGbHb9bMbNb
1300 changing_dir(tmp_path),
1301 patch("rich_toolkit.container.getchar") as mock_getchar,
1302 ):
1303 mock_getchar.side_effect = steps 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1305 return runner.invoke(app, ["deploy", "--no-wait"]) 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b
1308@pytest.mark.respx 1abcdefg
1309def test_can_skip_waiting( 1abcdefg
1310 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1311) -> None:
1312 result = _deploy_without_waiting(respx_mock, tmp_path) 2Yb[b^b`b9b|b~b
1314 assert result.exit_code == 0 2Yb[b^b`b9b|b~b
1316 assert "Check the status of your deployment at" in result.output 2Yb[b^b`b9b|b~b
1319@pytest.mark.respx 1abcdefg
1320def test_creates_config_folder_and_creates_git_ignore( 1abcdefg
1321 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1322) -> None:
1323 _deploy_without_waiting(respx_mock, tmp_path) 2Gb0b3b6bMb#b'b
1325 assert (tmp_path / ".fastapicloud" / "cloud.json").exists() 2Gb0b3b6bMb#b'b
1326 assert (tmp_path / ".fastapicloud" / "README.md").exists() 2Gb0b3b6bMb#b'b
1327 assert (tmp_path / ".fastapicloud" / ".gitignore").read_text() == "*" 2Gb0b3b6bMb#b'b
1330@pytest.mark.respx 1abcdefg
1331def test_does_not_duplicate_entry_in_git_ignore( 1abcdefg
1332 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1333) -> None:
1334 git_ignore_path = tmp_path / ".gitignore" 2Hb1b4b7bNb$b(b
1335 git_ignore_path.write_text(".fastapicloud\n") 2Hb1b4b7bNb$b(b
1337 _deploy_without_waiting(respx_mock, tmp_path) 2Hb1b4b7bNb$b(b
1339 assert git_ignore_path.read_text() == ".fastapicloud\n" 2Hb1b4b7bNb$b(b
1342@pytest.mark.respx 1abcdefg
1343def test_shows_no_apps_found_message_when_team_has_no_apps( 1abcdefg
1344 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1345) -> None:
1346 steps = [ 2|doeqesecevexe
1347 Keys.ENTER, # Select team
1348 Keys.RIGHT_ARROW, # Choose existing app (No)
1349 Keys.ENTER,
1350 ]
1352 team = _get_random_team() 2|doeqesecevexe
1354 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2|doeqesecevexe
1356 # Mock empty apps list for the team
1357 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2|doeqesecevexe
1358 return_value=Response(200, json={"data": []})
1359 )
1361 with ( 2|dce
1362 changing_dir(tmp_path),
1363 patch("rich_toolkit.container.getchar") as mock_getchar,
1364 ):
1365 mock_getchar.side_effect = steps 2|doeqesecevexe
1367 result = runner.invoke(app, ["deploy"]) 2|doeqesecevexe
1369 assert result.exit_code == 1 2|doeqesecevexe
1370 assert ( 2|doeqesecevexe
1371 "No apps found in this team. You can create a new app instead."
1372 in result.output
1373 )
1376@pytest.mark.parametrize( 1abcdefg
1377 "error",
1378 [StreamLogError("stream error"), TooManyRetriesError(), TimeoutError()],
1379)
1380@pytest.mark.respx 1abcdefg
1381def test_shows_error_message_on_build_exception( 1abcdefg
1382 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter, error: Exception
1383) -> None:
1384 app_data = _get_random_app() 2Ib2b5b8bOb%b)b
1385 team_data = _get_random_team() 2Ib2b5b8bOb%b)b
1386 app_id = app_data["id"] 2Ib2b5b8bOb%b)b
1387 team_id = team_data["id"] 2Ib2b5b8bOb%b)b
1388 deployment_data = _get_random_deployment(app_id=app_id) 2Ib2b5b8bOb%b)b
1390 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2Ib2b5b8bOb%b)b
1391 config_path.parent.mkdir(parents=True, exist_ok=True) 2Ib2b5b8bOb%b)b
1392 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2Ib2b5b8bOb%b)b
1394 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2Ib2b5b8bOb%b)b
1395 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2Ib2b5b8bOb%b)b
1396 return_value=Response(201, json=deployment_data)
1397 )
1398 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2Ib2b5b8bOb%b)b
1399 return_value=Response(
1400 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1401 )
1402 )
1403 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2Ib2b5b8bOb%b)b
1404 return_value=Response(200)
1405 )
1406 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2Ib2b5b8bOb%b)b
1407 return_value=Response(
1408 200, json={**deployment_data, "status": "ready_for_build"}
1409 )
1410 )
1412 with ( 2IbOb
1413 changing_dir(tmp_path),
1414 patch(
1415 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
1416 side_effect=error,
1417 ),
1418 ):
1419 result = runner.invoke(app, ["deploy"]) 2Ib2b5b8bOb%b)b
1421 assert result.exit_code == 1 2Ib2b5b8bOb%b)b
1422 assert "Unable to stream build logs" in result.output 2Ib2b5b8bOb%b)b
1423 assert deployment_data["dashboard_url"] in result.output 2Ib2b5b8bOb%b)b
1426@pytest.mark.respx 1abcdefg
1427def test_shows_error_message_on_build_log_http_error( 1abcdefg
1428 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1429) -> None:
1430 app_data = _get_random_app() 2kbmbobqbtbvbxb
1431 team_data = _get_random_team() 2kbmbobqbtbvbxb
1432 app_id = app_data["id"] 2kbmbobqbtbvbxb
1433 team_id = team_data["id"] 2kbmbobqbtbvbxb
1434 deployment_data = _get_random_deployment(app_id=app_id) 2kbmbobqbtbvbxb
1436 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2kbmbobqbtbvbxb
1437 config_path.parent.mkdir(parents=True, exist_ok=True) 2kbmbobqbtbvbxb
1438 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2kbmbobqbtbvbxb
1440 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2kbmbobqbtbvbxb
1441 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2kbmbobqbtbvbxb
1442 return_value=Response(201, json=deployment_data)
1443 )
1444 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2kbmbobqbtbvbxb
1445 return_value=Response(
1446 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1447 )
1448 )
1449 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2kbmbobqbtbvbxb
1450 return_value=Response(200)
1451 )
1452 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2kbmbobqbtbvbxb
1453 return_value=Response(
1454 200, json={**deployment_data, "status": "ready_for_build"}
1455 )
1456 )
1458 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2kbmbobqbtbvbxb
1459 return_value=Response(500, text="Internal Server Error")
1460 )
1462 with changing_dir(tmp_path), patch("time.sleep"): 2kbmbobqbtbvbxb
1463 result = runner.invoke(app, ["deploy"]) 2kbmbobqbtbvbxb
1465 assert result.exit_code == 1 2kbmbobqbtbvbxb
1466 assert "Unable to stream build logs" in result.output 2kbmbobqbtbvbxb
1467 assert deployment_data["dashboard_url"] in result.output 2kbmbobqbtbvbxb
1470@pytest.mark.respx 1abcdefg
1471def test_short_wait_messages( 1abcdefg
1472 logged_in_cli: None,
1473 tmp_path: Path,
1474 respx_mock: respx.MockRouter,
1475) -> None:
1476 app_data = _get_random_app() 1wACEyGI
1477 team_data = _get_random_team() 1wACEyGI
1478 app_id = app_data["id"] 1wACEyGI
1479 team_id = team_data["id"] 1wACEyGI
1480 deployment_data = _get_random_deployment(app_id=app_id) 1wACEyGI
1482 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1wACEyGI
1483 config_path.parent.mkdir(parents=True, exist_ok=True) 1wACEyGI
1484 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 1wACEyGI
1486 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1wACEyGI
1487 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 1wACEyGI
1488 return_value=Response(201, json=deployment_data)
1489 )
1490 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 1wACEyGI
1491 return_value=Response(
1492 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1493 )
1494 )
1495 respx_mock.post("http://test.com", data={"key": "value"}).mock( 1wACEyGI
1496 return_value=Response(200)
1497 )
1498 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 1wACEyGI
1499 return_value=Response(
1500 200, json={**deployment_data, "status": "ready_for_build"}
1501 )
1502 )
1504 # Each build-log request advances the fake monotonic clock so the elapsed
1505 # time determines which message pool is used.
1506 clock = [0.0] 1wACEyGI
1508 def build_logs_handler(request: httpx.Request, route: respx.Route) -> Response: 1wACEyGI
1509 if route.call_count <= 2: 1wACEyGI
1510 clock[0] += 3 1wACEyGI
1511 return Response( 1wACEyGI
1512 200,
1513 content=build_logs_response(
1514 {
1515 "type": "message",
1516 "message": f"Step {route.call_count}",
1517 "id": str(route.call_count),
1518 },
1519 {"type": "timeout"},
1520 ),
1521 )
1522 else:
1523 return Response( 1wACEyGI
1524 200,
1525 content=build_logs_response(
1526 {"type": "complete"},
1527 ),
1528 )
1530 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 1wACEyGI
1531 side_effect=build_logs_handler
1532 )
1534 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1wACEyGI
1535 return_value=Response(200, json={**deployment_data, "status": "success"})
1536 )
1538 with ( 1wy
1539 changing_dir(tmp_path),
1540 patch("time.sleep"),
1541 patch("time.monotonic", side_effect=lambda: clock[0]),
1542 patch.object(wait, "cycle", wraps=itertools.cycle) as cycle_spy,
1543 ):
1544 result = runner.invoke(app, ["deploy"]) 1wACEyGI
1546 assert result.exit_code == 0 1wACEyGI
1547 assert "Ready the chicken!" in result.output 1wACEyGI
1549 # This is a short wait, so LONG_WAIT_MESSAGES should not be accessed by the
1550 # `cycle` function.
1551 assert call(wait.LONG_WAIT_MESSAGES) not in cycle_spy.call_args_list 1wACEyGI
1554@pytest.mark.respx 1abcdefg
1555def test_long_wait_messages( 1abcdefg
1556 logged_in_cli: None,
1557 tmp_path: Path,
1558 respx_mock: respx.MockRouter,
1559) -> None:
1560 app_data = _get_random_app() 1vzBDxFH
1561 team_data = _get_random_team() 1vzBDxFH
1562 app_id = app_data["id"] 1vzBDxFH
1563 team_id = team_data["id"] 1vzBDxFH
1564 deployment_data = _get_random_deployment(app_id=app_id) 1vzBDxFH
1566 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1vzBDxFH
1567 config_path.parent.mkdir(parents=True, exist_ok=True) 1vzBDxFH
1568 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 1vzBDxFH
1570 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1vzBDxFH
1571 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 1vzBDxFH
1572 return_value=Response(201, json=deployment_data)
1573 )
1574 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 1vzBDxFH
1575 return_value=Response(
1576 200, json={"url": "http://test.com", "fields": {"key": "value"}}
1577 )
1578 )
1579 respx_mock.post("http://test.com", data={"key": "value"}).mock( 1vzBDxFH
1580 return_value=Response(200)
1581 )
1582 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 1vzBDxFH
1583 return_value=Response(
1584 200, json={**deployment_data, "status": "ready_for_build"}
1585 )
1586 )
1588 # Each build-log request advances the fake monotonic clock so the elapsed
1589 # time determines which message pool is used.
1590 clock = [0.0] 1vzBDxFH
1592 def build_logs_handler(request: httpx.Request, route: respx.Route) -> Response: 1vzBDxFH
1593 if route.call_count <= 2: 1vzBDxFH
1594 clock[0] += 35 1vzBDxFH
1595 return Response( 1vzBDxFH
1596 200,
1597 content=build_logs_response(
1598 {
1599 "type": "message",
1600 "message": f"Step {route.call_count}",
1601 "id": str(route.call_count),
1602 },
1603 {"type": "timeout"},
1604 ),
1605 )
1606 else:
1607 return Response( 1vzBDxFH
1608 200,
1609 content=build_logs_response(
1610 {"type": "complete"},
1611 ),
1612 )
1614 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 1vzBDxFH
1615 side_effect=build_logs_handler
1616 )
1618 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1vzBDxFH
1619 return_value=Response(200, json={**deployment_data, "status": "success"})
1620 )
1622 with ( 1vx
1623 changing_dir(tmp_path),
1624 patch("time.sleep"),
1625 patch("time.monotonic", side_effect=lambda: clock[0]),
1626 patch.object(wait, "cycle", wraps=itertools.cycle) as cycle_spy,
1627 ):
1628 result = runner.invoke(app, ["deploy"]) 1vzBDxFH
1630 assert result.exit_code == 0 1vzBDxFH
1631 assert "Ready the chicken!" in result.output 1vzBDxFH
1633 # This is a long wait, so LONG_WAIT_MESSAGES should be accessed by the `cycle`
1634 # function.
1635 assert call(wait.LONG_WAIT_MESSAGES) in cycle_spy.call_args_list 1vzBDxFH
1638@pytest.mark.respx 1abcdefg
1639def test_calls_upload_cancelled_when_user_interrupts( 1abcdefg
1640 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1641) -> None:
1642 app_data = _get_random_app() 2IcWcXcYcMcZc0c
1643 team_data = _get_random_team() 2IcWcXcYcMcZc0c
1644 app_id = app_data["id"] 2IcWcXcYcMcZc0c
1645 team_id = team_data["id"] 2IcWcXcYcMcZc0c
1646 deployment_data = _get_random_deployment(app_id=app_id) 2IcWcXcYcMcZc0c
1648 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2IcWcXcYcMcZc0c
1649 config_path.parent.mkdir(parents=True, exist_ok=True) 2IcWcXcYcMcZc0c
1650 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2IcWcXcYcMcZc0c
1652 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2IcWcXcYcMcZc0c
1653 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2IcWcXcYcMcZc0c
1654 return_value=Response(201, json=deployment_data)
1655 )
1657 upload_cancelled_route = respx_mock.post( 2IcWcXcYcMcZc0c
1658 f"/deployments/{deployment_data['id']}/upload-cancelled"
1659 ).mock(return_value=Response(200))
1661 with ( 2IcMc
1662 changing_dir(tmp_path),
1663 patch(
1664 "fastapi_cloud_cli.commands.deploy.command._upload_deployment",
1665 side_effect=KeyboardInterrupt(),
1666 ),
1667 ):
1668 runner.invoke(app, ["deploy"]) 2IcWcXcYcMcZc0c
1670 assert upload_cancelled_route.called 2IcWcXcYcMcZc0c
1673@pytest.mark.respx 1abcdefg
1674def test_cancel_upload_swallows_exceptions( 1abcdefg
1675 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1676) -> None:
1677 app_data = _get_random_app() 2ncJcKcLcocNcOc
1678 team_data = _get_random_team() 2ncJcKcLcocNcOc
1679 app_id = app_data["id"] 2ncJcKcLcocNcOc
1680 team_id = team_data["id"] 2ncJcKcLcocNcOc
1681 deployment_data = _get_random_deployment(app_id=app_id) 2ncJcKcLcocNcOc
1683 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2ncJcKcLcocNcOc
1684 config_path.parent.mkdir(parents=True, exist_ok=True) 2ncJcKcLcocNcOc
1685 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2ncJcKcLcocNcOc
1687 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2ncJcKcLcocNcOc
1688 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2ncJcKcLcocNcOc
1689 return_value=Response(201, json=deployment_data)
1690 )
1692 upload_cancelled_route = respx_mock.post( 2ncJcKcLcocNcOc
1693 f"/deployments/{deployment_data['id']}/upload-cancelled"
1694 ).mock(return_value=Response(500))
1696 with ( 2ncoc
1697 changing_dir(tmp_path),
1698 patch(
1699 "fastapi_cloud_cli.commands.deploy.command._upload_deployment",
1700 side_effect=KeyboardInterrupt(),
1701 ),
1702 ):
1703 result = runner.invoke(app, ["deploy"]) 2ncJcKcLcocNcOc
1705 assert upload_cancelled_route.called 2ncJcKcLcocNcOc
1706 assert "HTTPStatusError" not in result.output 2ncJcKcLcocNcOc
1709def _mock_deploy_until_upload( 1abcdefg
1710 respx_mock: respx.MockRouter,
1711 tmp_path: Path,
1712 app_data: RandomApp,
1713 deployment_data: dict[str, str],
1714 upload_response: Response,
1715) -> None:
1716 app_id = app_data["id"] 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1718 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1719 config_path.parent.mkdir(parents=True, exist_ok=True) 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1720 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1722 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1723 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1724 return_value=Response(201, json=deployment_data)
1725 )
1726 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1727 return_value=Response(
1728 200,
1729 json={"url": "http://test.com", "fields": {"key": "value"}},
1730 )
1731 )
1732 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2Pc$cQc'cRc)cSc+cTc,cUc.cVc:c
1733 return_value=upload_response
1734 )
1737def _mock_deploy_until_deployment_creation( 1abcdefg
1738 respx_mock: respx.MockRouter,
1739 tmp_path: Path,
1740 app_data: RandomApp,
1741 creation_response: Response,
1742) -> respx.Route:
1743 app_id = app_data["id"] 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1745 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1746 config_path.parent.mkdir(parents=True, exist_ok=True) 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1747 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1749 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1751 return respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2FdgdGdhdHdidIdjdJdkdKdldLdmd
1752 return_value=creation_response
1753 )
1756@pytest.mark.respx 1abcdefg
1757def test_deploy_shows_error_when_creation_rejects_archive_size( 1abcdefg
1758 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1759) -> None:
1760 app_data = _get_random_app() 2gdhdidjdkdldmd
1762 # no upload routes are mocked, any upload attempt would fail the test
1763 create_deployment_route = _mock_deploy_until_deployment_creation( 2gdhdidjdkdldmd
1764 respx_mock,
1765 tmp_path,
1766 app_data,
1767 Response(
1768 413,
1769 json={
1770 "detail": "App source code exceeds the maximum allowed size of 1000.0 MB"
1771 },
1772 ),
1773 )
1775 with changing_dir(tmp_path): 2gdhdidjdkdldmd
1776 result = runner.invoke(app, ["deploy"]) 2gdhdidjdkdldmd
1778 output = " ".join(result.output.split()) 2gdhdidjdkdldmd
1780 assert result.exit_code == 1 2gdhdidjdkdldmd
1781 assert "App source code exceeds the maximum allowed size of 1000.0 MB" in output 2gdhdidjdkdldmd
1782 assert ".fastapicloudignore" in output 2gdhdidjdkdldmd
1783 assert "Something went wrong" not in output 2gdhdidjdkdldmd
1785 request_body = json.loads(create_deployment_route.calls.last.request.content) 2gdhdidjdkdldmd
1786 assert request_body["archive_size_bytes"] > 0 2gdhdidjdkdldmd
1789@pytest.mark.respx 1abcdefg
1790def test_deploy_json_shows_error_when_creation_rejects_archive_size( 1abcdefg
1791 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1792) -> None:
1793 app_data = _get_random_app() 2FdGdHdIdJdKdLd
1795 _mock_deploy_until_deployment_creation( 2FdGdHdIdJdKdLd
1796 respx_mock,
1797 tmp_path,
1798 app_data,
1799 Response(
1800 413,
1801 json={
1802 "detail": "App source code exceeds the maximum allowed size of 1000.0 MB"
1803 },
1804 ),
1805 )
1807 with changing_dir(tmp_path): 2FdGdHdIdJdKdLd
1808 result = runner.invoke(app, ["deploy", "--json"]) 2FdGdHdIdJdKdLd
1810 assert result.exit_code == 1 2FdGdHdIdJdKdLd
1812 error = json.loads(result.stdout)["error"] 2FdGdHdIdJdKdLd
1813 assert error["code"] == "invalid_input" 2FdGdHdIdJdKdLd
1814 assert ( 2FdGdHdIdJdKdLd
1815 error["message"]
1816 == "App source code exceeds the maximum allowed size of 1000.0 MB"
1817 )
1818 assert error["hint"] == ( 2FdGdHdIdJdKdLd
1819 "You can exclude files from the deployment with a .fastapicloudignore file."
1820 )
1823@pytest.mark.respx 1abcdefg
1824def test_deploy_shows_error_when_upload_fails( 1abcdefg
1825 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1826) -> None:
1827 app_data = _get_random_app() 2$c'c)c+c,c.c:c
1828 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2$c'c)c+c,c.c:c
1830 _mock_deploy_until_upload( 2$c'c)c+c,c.c:c
1831 respx_mock,
1832 tmp_path,
1833 app_data,
1834 deployment_data,
1835 Response(400, text="not an xml body"),
1836 )
1838 with changing_dir(tmp_path): 2$c'c)c+c,c.c:c
1839 result = runner.invoke(app, ["deploy"]) 2$c'c)c+c,c.c:c
1841 assert result.exit_code == 1 2$c'c)c+c,c.c:c
1842 assert "Something went wrong" in result.output 2$c'c)c+c,c.c:c
1845@pytest.mark.respx 1abcdefg
1846def test_deploy_successfully_with_token( 1abcdefg
1847 logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1848) -> None:
1849 app_data = _get_random_app() 1-/=[_{~
1850 team_data = _get_random_team() 1-/=[_{~
1851 app_id = app_data["id"] 1-/=[_{~
1852 team_id = team_data["id"] 1-/=[_{~
1853 deployment_data = _get_random_deployment(app_id=app_id) 1-/=[_{~
1855 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1-/=[_{~
1857 config_path.parent.mkdir(parents=True, exist_ok=True) 1-/=[_{~
1858 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 1-/=[_{~
1860 respx_mock.get(f"/apps/{app_id}", headers={"Authorization": "Bearer hello"}).mock( 1-/=[_{~
1861 return_value=Response(200, json=app_data)
1862 )
1864 respx_mock.post( 1-/=[_{~
1865 f"/apps/{app_id}/deployments/", headers={"Authorization": "Bearer hello"}
1866 ).mock(return_value=Response(201, json=deployment_data))
1868 respx_mock.post( 1-/=[_{~
1869 f"/deployments/{deployment_data['id']}/upload",
1870 headers={"Authorization": "Bearer hello"},
1871 ).mock(
1872 return_value=Response(
1873 200,
1874 json={"url": "http://test.com", "fields": {"key": "value"}},
1875 )
1876 )
1878 respx_mock.post("http://test.com", data={"key": "value"}).mock( 1-/=[_{~
1879 return_value=Response(200)
1880 )
1882 respx_mock.get( 1-/=[_{~
1883 f"/deployments/{deployment_data['id']}/build-logs",
1884 headers={"Authorization": "Bearer hello"},
1885 ).mock(
1886 return_value=Response(
1887 200,
1888 content=build_logs_response(
1889 {"type": "message", "message": "Building...", "id": "1"},
1890 {"type": "message", "message": "All good!", "id": "2"},
1891 {"type": "complete"},
1892 ),
1893 )
1894 )
1896 respx_mock.post( 1-/=[_{~
1897 f"/deployments/{deployment_data['id']}/upload-complete",
1898 headers={"Authorization": "Bearer hello"},
1899 ).mock(
1900 return_value=Response(
1901 200, json={**deployment_data, "status": "ready_for_build"}
1902 )
1903 )
1905 respx_mock.get( 1-/=[_{~
1906 f"/deployments/{deployment_data['id']}",
1907 headers={"Authorization": "Bearer hello"},
1908 ).mock(return_value=Response(200, json={**deployment_data, "status": "success"}))
1910 with changing_dir(tmp_path): 1-/=[_{~
1911 result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) 1-/=[_{~
1913 assert result.exit_code == 0 1-/=[_{~
1915 # check that logs are shown
1916 assert "All good!" in result.output 1-/=[_{~
1917 assert ( 1-/=[_{~
1918 "Using token from FASTAPI_CLOUD_TOKEN environment variable" in result.output
1919 )
1921 # check that the app URL is shown
1922 assert deployment_data["url"] in result.output 1-/=[_{~
1925@pytest.mark.respx 1abcdefg
1926def test_deploy_with_token_fails( 1abcdefg
1927 logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
1928) -> None:
1929 app_data = _get_random_app() 2ndodpdqdrdsdtd
1930 team_data = _get_random_team() 2ndodpdqdrdsdtd
1931 app_id = app_data["id"] 2ndodpdqdrdsdtd
1932 team_id = team_data["id"] 2ndodpdqdrdsdtd
1934 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2ndodpdqdrdsdtd
1936 config_path.parent.mkdir(parents=True, exist_ok=True) 2ndodpdqdrdsdtd
1937 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2ndodpdqdrdsdtd
1939 respx_mock.get(f"/apps/{app_id}", headers={"Authorization": "Bearer hello"}).mock( 2ndodpdqdrdsdtd
1940 return_value=Response(401, json=app_data)
1941 )
1943 with changing_dir(tmp_path): 2ndodpdqdrdsdtd
1944 result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) 2ndodpdqdrdsdtd
1946 assert result.exit_code == 1 2ndodpdqdrdsdtd
1948 assert ( 2ndodpdqdrdsdtd
1949 "The specified token is not valid. Make sure to use a valid token."
1950 in result.output
1951 )
1954@pytest.mark.parametrize( 1abcdefg
1955 ("size", "expected_msgs"),
1956 [
1957 (
1958 100,
1959 [
1960 r"\(\d+ bytes\)", # e.g. "(123 bytes)"
1961 r"\(\d+ bytes of \d+ bytes\)", # e.g. "(123 bytes of 456 bytes)"
1962 ],
1963 ),
1964 (
1965 10 * 1024,
1966 [
1967 r"\(\d+\.\d+ KB\)", # e.g. "(1.23 KB)"
1968 r"\(\d+\.\d+ KB of \d+\.\d+ KB\)", # e.g. "(1.23 KB of 4.56 KB)"
1969 ],
1970 ),
1971 (
1972 10 * 1024 * 1024,
1973 [
1974 r"\(\d+\.\d+ MB\)", # e.g. "(1.23 MB)"
1975 r"\(\d+\.\d+ KB of \d+\.\d+ MB\)", # e.g. "(1.23 KB of 4.56 MB)"
1976 r"\(\d+\.\d+ MB of \d+\.\d+ MB\)", # e.g. "(1.23 MB of 4.56 MB)"
1977 ],
1978 ),
1979 ],
1980)
1981@pytest.mark.respx 1abcdefg
1982def test_upload_deployment_progress( 1abcdefg
1983 logged_in_cli: None,
1984 tmp_path: Path,
1985 respx_mock: respx.MockRouter,
1986 size: int,
1987 expected_msgs: list[str],
1988) -> None:
1989 app_data = _get_random_app() 1Q678R9!
1990 team_data = _get_random_team() 1Q678R9!
1991 app_id = app_data["id"] 1Q678R9!
1992 team_id = team_data["id"] 1Q678R9!
1993 deployment_data = _get_random_deployment(app_id=app_id) 1Q678R9!
1994 deployment_id = deployment_data["id"] 1Q678R9!
1996 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1Q678R9!
1997 config_path.parent.mkdir(parents=True, exist_ok=True) 1Q678R9!
1998 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 1Q678R9!
2000 (tmp_path / "file.bin").write_bytes(random.randbytes(size)) 1Q678R9!
2002 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 1Q678R9!
2003 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 1Q678R9!
2004 return_value=Response(201, json=deployment_data)
2005 )
2006 respx_mock.post(f"/deployments/{deployment_id}/upload").mock( 1Q678R9!
2007 return_value=Response(
2008 200,
2009 json={"url": "http://test.com", "fields": {"key": "value"}},
2010 )
2011 )
2012 respx_mock.post("http://test.com", data={"key": "value"}).mock( 1Q678R9!
2013 return_value=Response(200)
2014 )
2015 respx_mock.post(f"/deployments/{deployment_id}/upload-complete").mock( 1Q678R9!
2016 return_value=Response(
2017 200, json={**deployment_data, "status": "ready_for_build"}
2018 )
2019 )
2020 respx_mock.get(f"/deployments/{deployment_id}/build-logs").mock( 1Q678R9!
2021 return_value=Response(
2022 200,
2023 content=build_logs_response(
2024 {"type": "message", "message": "Building...", "id": "1"},
2025 {"type": "complete"},
2026 ),
2027 )
2028 )
2029 respx_mock.get(f"/deployments/{deployment_id}").mock( 1Q678R9!
2030 return_value=Response(200, json={**deployment_data, "status": "success"})
2031 )
2033 with ( 1QR
2034 changing_dir(tmp_path),
2035 patch.object(Progress, "log") as mock_progress,
2036 ):
2037 result = runner.invoke(app, ["deploy"]) 1Q678R9!
2038 assert result.exit_code == 0 1Q678R9!
2040 call_args = [ 1Q678R9!
2041 c.args[0] for c in mock_progress.call_args_list if isinstance(c.args[0], str)
2042 ]
2044 for expected_msg in expected_msgs: 1Q678R9!
2045 pattern = re.compile(f"Uploading deployment {expected_msg}\\.\\.\\.") 1Q678R9!
2046 assert any(pattern.match(arg) for arg in call_args), ( 1Q678R9!
2047 f"Expected message '{pattern.pattern}' not found in {call_args}"
2048 )
2051@pytest.mark.respx 1abcdefg
2052def test_deploy_with_app_id_arg( 1abcdefg
2053 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2054) -> None:
2055 app_data = _get_random_app() 2]c_c{c}cadcded
2056 app_id = app_data["id"] 2]c_c{c}cadcded
2057 deployment_data = _get_random_deployment(app_id=app_id) 2]c_c{c}cadcded
2059 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2]c_c{c}cadcded
2061 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2]c_c{c}cadcded
2062 return_value=Response(201, json=deployment_data)
2063 )
2065 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2]c_c{c}cadcded
2066 return_value=Response(
2067 200,
2068 json={"url": "http://test.com", "fields": {"key": "value"}},
2069 )
2070 )
2072 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2]c_c{c}cadcded
2073 return_value=Response(200)
2074 )
2076 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2]c_c{c}cadcded
2077 return_value=Response(
2078 200, json={**deployment_data, "status": "ready_for_build"}
2079 )
2080 )
2082 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2]c_c{c}cadcded
2083 return_value=Response(
2084 200,
2085 content=build_logs_response(
2086 {"type": "message", "message": "Building...", "id": "1"},
2087 {"type": "complete"},
2088 ),
2089 )
2090 )
2092 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2]c_c{c}cadcded
2093 return_value=Response(200, json={**deployment_data, "status": "success"})
2094 )
2096 with changing_dir(tmp_path): 2]c_c{c}cadcded
2097 result = runner.invoke(app, ["deploy", "--app-id", app_id]) 2]c_c{c}cadcded
2099 assert result.exit_code == 0 2]c_c{c}cadcded
2100 assert f"Deploying to app {app_id}" in result.output 2]c_c{c}cadcded
2103@pytest.mark.respx 1abcdefg
2104def test_deploy_with_app_id_from_env_var( 1abcdefg
2105 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2106) -> None:
2107 app_data = _get_random_app() 2^c`c|c~cbdddfd
2108 app_id = app_data["id"] 2^c`c|c~cbdddfd
2109 deployment_data = _get_random_deployment(app_id=app_id) 2^c`c|c~cbdddfd
2111 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2^c`c|c~cbdddfd
2113 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2^c`c|c~cbdddfd
2114 return_value=Response(201, json=deployment_data)
2115 )
2117 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2^c`c|c~cbdddfd
2118 return_value=Response(
2119 200,
2120 json={"url": "http://test.com", "fields": {"key": "value"}},
2121 )
2122 )
2124 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2^c`c|c~cbdddfd
2125 return_value=Response(200)
2126 )
2128 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2^c`c|c~cbdddfd
2129 return_value=Response(
2130 200, json={**deployment_data, "status": "ready_for_build"}
2131 )
2132 )
2134 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2^c`c|c~cbdddfd
2135 return_value=Response(
2136 200,
2137 content=build_logs_response(
2138 {"type": "message", "message": "Building...", "id": "1"},
2139 {"type": "complete"},
2140 ),
2141 )
2142 )
2144 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2^c`c|c~cbdddfd
2145 return_value=Response(200, json={**deployment_data, "status": "success"})
2146 )
2148 with changing_dir(tmp_path): 2^c`c|c~cbdddfd
2149 result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_APP_ID": app_id}) 2^c`c|c~cbdddfd
2151 assert result.exit_code == 0 2^c`c|c~cbdddfd
2152 assert f"Deploying to app {app_id}" in result.output 2^c`c|c~cbdddfd
2155@pytest.mark.respx 1abcdefg
2156def test_deploy_with_app_id_matching_local_config( 1abcdefg
2157 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2158) -> None:
2159 app_data = _get_random_app() 2qctcwczcBcEcHc
2160 app_id = app_data["id"] 2qctcwczcBcEcHc
2161 team_id = "some-team-id" 2qctcwczcBcEcHc
2162 deployment_data = _get_random_deployment(app_id=app_id) 2qctcwczcBcEcHc
2164 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2qctcwczcBcEcHc
2165 config_path.parent.mkdir(parents=True, exist_ok=True) 2qctcwczcBcEcHc
2166 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2qctcwczcBcEcHc
2168 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2qctcwczcBcEcHc
2170 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2qctcwczcBcEcHc
2171 return_value=Response(201, json=deployment_data)
2172 )
2174 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2qctcwczcBcEcHc
2175 return_value=Response(
2176 200,
2177 json={"url": "http://test.com", "fields": {"key": "value"}},
2178 )
2179 )
2181 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2qctcwczcBcEcHc
2182 return_value=Response(200)
2183 )
2185 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2qctcwczcBcEcHc
2186 return_value=Response(
2187 200, json={**deployment_data, "status": "ready_for_build"}
2188 )
2189 )
2191 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2qctcwczcBcEcHc
2192 return_value=Response(
2193 200,
2194 content=build_logs_response(
2195 {"type": "message", "message": "Building...", "id": "1"},
2196 {"type": "complete"},
2197 ),
2198 )
2199 )
2201 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2qctcwczcBcEcHc
2202 return_value=Response(200, json={**deployment_data, "status": "success"})
2203 )
2205 with changing_dir(tmp_path): 2qctcwczcBcEcHc
2206 result = runner.invoke(app, ["deploy", "--app-id", app_id]) 2qctcwczcBcEcHc
2208 assert result.exit_code == 0 2qctcwczcBcEcHc
2209 # Should NOT show mismatch warning
2210 assert "does not match" not in result.output 2qctcwczcBcEcHc
2211 assert f"Deploying to app {app_id}" in result.output 2qctcwczcBcEcHc
2214@pytest.mark.respx 1abcdefg
2215def test_deploy_with_app_id_mismatch_fails( 1abcdefg
2216 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2217) -> None:
2218 local_app_data = _get_random_app() 22d3d4d5d6d7d8d
2219 local_app_id = local_app_data["id"] 22d3d4d5d6d7d8d
2220 team_id = "some-team-id" 22d3d4d5d6d7d8d
2222 config_path = tmp_path / ".fastapicloud" / "cloud.json" 22d3d4d5d6d7d8d
2223 config_path.parent.mkdir(parents=True, exist_ok=True) 22d3d4d5d6d7d8d
2224 config_path.write_text(f'{{"app_id": "{local_app_id}", "team_id": "{team_id}"}}') 22d3d4d5d6d7d8d
2226 cli_app_id = "different-app-id" 22d3d4d5d6d7d8d
2228 with changing_dir(tmp_path): 22d3d4d5d6d7d8d
2229 result = runner.invoke(app, ["deploy", "--app-id", cli_app_id]) 22d3d4d5d6d7d8d
2231 assert result.exit_code == 1 22d3d4d5d6d7d8d
2232 assert "does not match" in result.output 22d3d4d5d6d7d8d
2233 assert "fastapi cloud unlink" in result.output 22d3d4d5d6d7d8d
2234 assert "FASTAPI_CLOUD_APP_ID" in result.output 22d3d4d5d6d7d8d
2237def test_deploy_json_with_app_id_mismatch_returns_invalid_input( 1abcdefg
2238 logged_in_cli: None, tmp_path: Path
2239) -> None:
2240 local_app_id = "local-app-id" 2+e,e-e.e/e:e;e
2241 team_id = "some-team-id" 2+e,e-e.e/e:e;e
2242 cli_app_id = "different-app-id" 2+e,e-e.e/e:e;e
2244 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2+e,e-e.e/e:e;e
2245 config_path.parent.mkdir(parents=True, exist_ok=True) 2+e,e-e.e/e:e;e
2246 config_path.write_text(f'{{"app_id": "{local_app_id}", "team_id": "{team_id}"}}') 2+e,e-e.e/e:e;e
2248 with changing_dir(tmp_path): 2+e,e-e.e/e:e;e
2249 result = runner.invoke(app, ["deploy", "--app-id", cli_app_id, "--json"]) 2+e,e-e.e/e:e;e
2251 assert result.exit_code == 1 2+e,e-e.e/e:e;e
2252 assert json.loads(result.stdout) == { 2+e,e-e.e/e:e;e
2253 "error": {
2254 "code": "invalid_input",
2255 "message": (
2256 f"Provided app ID ({cli_app_id}) does not match "
2257 f"the local config ({local_app_id})."
2258 ),
2259 "hint": (
2260 "Run `fastapi cloud unlink` to remove the local config, "
2261 "or remove --app-id / unset FASTAPI_CLOUD_APP_ID to use the configured app."
2262 ),
2263 }
2264 }
2267@pytest.mark.respx 1abcdefg
2268def test_deploy_with_app_id_arg_app_not_found( 1abcdefg
2269 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2270) -> None:
2271 app_id = "nonexistent-app-id" 2ofpfqfrfsftfuf
2273 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) 2ofpfqfrfsftfuf
2275 with changing_dir(tmp_path): 2ofpfqfrfsftfuf
2276 result = runner.invoke(app, ["deploy", "--app-id", app_id]) 2ofpfqfrfsftfuf
2278 assert result.exit_code == 1 2ofpfqfrfsftfuf
2279 assert "App not found" in result.output 2ofpfqfrfsftfuf
2280 # Should NOT show unlink tip when using --app-id
2281 assert "unlink" not in result.output 2ofpfqfrfsftfuf
2284@pytest.mark.respx 1abcdefg
2285def test_deploy_json_with_app_id_arg_app_not_found( 1abcdefg
2286 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2287) -> None:
2288 app_id = "nonexistent-app-id" 2FfGfHfIfJfKfLf
2290 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) 2FfGfHfIfJfKfLf
2292 with changing_dir(tmp_path): 2FfGfHfIfJfKfLf
2293 result = runner.invoke(app, ["deploy", "--app-id", app_id, "--json"]) 2FfGfHfIfJfKfLf
2295 assert result.exit_code == 1 2FfGfHfIfJfKfLf
2296 assert json.loads(result.stdout) == { 2FfGfHfIfJfKfLf
2297 "error": {
2298 "code": "not_found",
2299 "message": "App not found. Make sure you're logged in the correct account.",
2300 "hint": None,
2301 }
2302 }
2305def _setup_deployment_mocks( 1abcdefg
2306 respx_mock: respx.MockRouter,
2307 app_id: str,
2308 team_id: str,
2309 deployment_data: dict[str, str],
2310 tmp_path: Path,
2311) -> None:
2312 """Set up common deployment mocks for a configured app."""
2313 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2314 config_path.parent.mkdir(parents=True, exist_ok=True) 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2315 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2317 app_data = _get_random_app() 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2318 app_data["id"] = app_id 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2320 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2321 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2322 return_value=Response(201, json=deployment_data)
2323 )
2324 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2325 return_value=Response(
2326 200, json={"url": "http://test.com", "fields": {"key": "value"}}
2327 )
2328 )
2329 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2330 return_value=Response(200)
2331 )
2332 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2333 return_value=Response(
2334 200, json={**deployment_data, "status": "ready_for_build"}
2335 )
2336 )
2337 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2*bpcbcS T J h o +b# zb,bccrcscU V K i p dc$ Ab-becucvcW X L j q fc% Bb.bgcxcycY Z M k r hc' Cb/b:bAcic0 1 N l s ;b( Db=bjcCcDc2 3 O m t kc) Eb?blcFcGc4 5 P n u mc* Fb@b
2338 return_value=Response(
2339 200,
2340 content=build_logs_response(
2341 {"type": "message", "message": "Building...", "id": "1"},
2342 {"type": "complete"},
2343 ),
2344 )
2345 )
2348@pytest.mark.respx 1abcdefg
2349def test_verification_failure_after_build_complete( 1abcdefg
2350 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2351) -> None:
2352 app_data = _get_random_app() 2zbAbBbCbDbEbFb
2353 app_id = app_data["id"] 2zbAbBbCbDbEbFb
2354 team_id = "some-team-id" 2zbAbBbCbDbEbFb
2355 deployment_data = _get_random_deployment(app_id=app_id) 2zbAbBbCbDbEbFb
2357 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2zbAbBbCbDbEbFb
2359 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2zbAbBbCbDbEbFb
2360 return_value=Response(
2361 200, json={**deployment_data, "status": "verifying_failed"}
2362 )
2363 )
2365 with changing_dir(tmp_path): 2zbAbBbCbDbEbFb
2366 result = runner.invoke(app, ["deploy"]) 2zbAbBbCbDbEbFb
2368 assert result.exit_code == 1 2zbAbBbCbDbEbFb
2369 assert "Deployment failed" in result.output 2zbAbBbCbDbEbFb
2370 assert "Verification Failed" in result.output 2zbAbBbCbDbEbFb
2371 assert deployment_data["dashboard_url"] in result.output 2zbAbBbCbDbEbFb
2374@pytest.mark.respx 1abcdefg
2375def test_polling_with_intermediate_states( 1abcdefg
2376 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2377) -> None:
2378 app_data = _get_random_app() 1#$%'()*
2379 app_id = app_data["id"] 1#$%'()*
2380 team_id = "some-team-id" 1#$%'()*
2381 deployment_data = _get_random_deployment(app_id=app_id) 1#$%'()*
2383 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1#$%'()*
2385 call_count = 0 1#$%'()*
2387 def poll_handler(request: httpx.Request, route: respx.Route) -> Response: 1#$%'()*
2388 nonlocal call_count
2389 call_count += 1 1#$%'()*
2390 if call_count <= 2: 1#$%'()*
2391 return Response(200, json={**deployment_data, "status": "verifying"}) 1#$%'()*
2392 return Response(200, json={**deployment_data, "status": "success"}) 1#$%'()*
2394 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1#$%'()*
2395 side_effect=poll_handler
2396 )
2398 with changing_dir(tmp_path), patch("time.sleep"): 1#$%'()*
2399 result = runner.invoke(app, ["deploy"]) 1#$%'()*
2401 assert result.exit_code == 0 1#$%'()*
2402 assert deployment_data["url"] in result.output 1#$%'()*
2405@pytest.mark.respx 1abcdefg
2406def test_polling_timeout_shows_dashboard_link( 1abcdefg
2407 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2408) -> None:
2409 app_data = _get_random_app() 2+bdcfchc;bkcmc
2410 app_id = app_data["id"] 2+bdcfchc;bkcmc
2411 team_id = "some-team-id" 2+bdcfchc;bkcmc
2412 deployment_data = _get_random_deployment(app_id=app_id) 2+bdcfchc;bkcmc
2414 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2+bdcfchc;bkcmc
2416 with ( 2+b;b
2417 changing_dir(tmp_path),
2418 patch(
2419 "fastapi_cloud_cli.api.APIClient.poll_deployment_status",
2420 side_effect=TimeoutError("Deployment verification timed out"),
2421 ),
2422 ):
2423 result = runner.invoke(app, ["deploy"]) 2+bdcfchc;bkcmc
2425 assert result.exit_code == 3 2+bdcfchc;bkcmc
2426 assert "Could not confirm deployment status" in result.output 2+bdcfchc;bkcmc
2427 assert deployment_data["dashboard_url"] in result.output 2+bdcfchc;bkcmc
2430@pytest.mark.respx 1abcdefg
2431def test_verifying_skipped_treated_as_success( 1abcdefg
2432 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2433) -> None:
2434 app_data = _get_random_app() 2,b-b.b/b=b?b@b
2435 app_id = app_data["id"] 2,b-b.b/b=b?b@b
2436 team_id = "some-team-id" 2,b-b.b/b=b?b@b
2437 deployment_data = _get_random_deployment(app_id=app_id) 2,b-b.b/b=b?b@b
2439 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2,b-b.b/b=b?b@b
2441 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2,b-b.b/b=b?b@b
2442 return_value=Response(
2443 200, json={**deployment_data, "status": "verifying_skipped"}
2444 )
2445 )
2447 with changing_dir(tmp_path): 2,b-b.b/b=b?b@b
2448 result = runner.invoke(app, ["deploy"]) 2,b-b.b/b=b?b@b
2450 assert result.exit_code == 0 2,b-b.b/b=b?b@b
2451 assert deployment_data["url"] in result.output 2,b-b.b/b=b?b@b
2454@pytest.mark.respx 1abcdefg
2455def test_ctrl_c_during_verification_shows_cancelled( 1abcdefg
2456 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2457) -> None:
2458 app_data = _get_random_app() 2*bccecgc:bjclc
2459 app_id = app_data["id"] 2*bccecgc:bjclc
2460 team_id = "some-team-id" 2*bccecgc:bjclc
2461 deployment_data = _get_random_deployment(app_id=app_id) 2*bccecgc:bjclc
2463 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2*bccecgc:bjclc
2465 with ( 2*b:b
2466 changing_dir(tmp_path),
2467 patch(
2468 "fastapi_cloud_cli.api.APIClient.poll_deployment_status",
2469 side_effect=KeyboardInterrupt(),
2470 ),
2471 ):
2472 result = runner.invoke(app, ["deploy"]) 2*bccecgc:bjclc
2474 assert "🟡" in result.output 2*bccecgc:bjclc
2475 assert "Cancelled" in result.output 2*bccecgc:bjclc
2476 assert "✅" not in result.output 2*bccecgc:bjclc
2479@pytest.mark.respx 1abcdefg
2480def test_ctrl_c_during_build_streaming_shows_cancelled( 1abcdefg
2481 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2482) -> None:
2483 app_data = _get_random_app() 28c%c(c*c9c-c/c
2484 app_id = app_data["id"] 28c%c(c*c9c-c/c
2485 team_id = "some-team-id" 28c%c(c*c9c-c/c
2486 deployment_data = _get_random_deployment(app_id=app_id) 28c%c(c*c9c-c/c
2488 config_path = tmp_path / ".fastapicloud" / "cloud.json" 28c%c(c*c9c-c/c
2489 config_path.parent.mkdir(parents=True, exist_ok=True) 28c%c(c*c9c-c/c
2490 config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "{team_id}"}}') 28c%c(c*c9c-c/c
2492 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 28c%c(c*c9c-c/c
2493 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 28c%c(c*c9c-c/c
2494 return_value=Response(201, json=deployment_data)
2495 )
2496 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 28c%c(c*c9c-c/c
2497 return_value=Response(
2498 200, json={"url": "http://test.com", "fields": {"key": "value"}}
2499 )
2500 )
2501 respx_mock.post("http://test.com", data={"key": "value"}).mock( 28c%c(c*c9c-c/c
2502 return_value=Response(200)
2503 )
2504 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 28c%c(c*c9c-c/c
2505 return_value=Response(
2506 200, json={**deployment_data, "status": "ready_for_build"}
2507 )
2508 )
2510 with ( 28c9c
2511 changing_dir(tmp_path),
2512 patch(
2513 "fastapi_cloud_cli.api.APIClient.stream_build_logs",
2514 side_effect=KeyboardInterrupt(),
2515 ),
2516 ):
2517 result = runner.invoke(app, ["deploy"]) 28c%c(c*c9c-c/c
2519 assert "🟡" in result.output 28c%c(c*c9c-c/c
2520 assert "Cancelled." in result.output 28c%c(c*c9c-c/c
2523def _create_file(path: Path, size_bytes: int) -> None: 1abcdefg
2524 """Create a file of the given size."""
2525 path.parent.mkdir(parents=True, exist_ok=True) 2RbS T J h o SbU V K i p TbW X L j q UbY Z M k r Vb0 1 N l s Wb2 3 O m t Xb4 5 P n u
2526 with open(path, "wb") as f: 2RbS T J h o SbU V K i p TbW X L j q UbY Z M k r Vb0 1 N l s Wb2 3 O m t Xb4 5 P n u
2527 if size_bytes > 0: 2RbS T J h o SbU V K i p TbW X L j q UbY Z M k r Vb0 1 N l s Wb2 3 O m t Xb4 5 P n u
2528 f.seek(size_bytes - 1) 2RbS T J h o SbU V K i p TbW X L j q UbY Z M k r Vb0 1 N l s Wb2 3 O m t Xb4 5 P n u
2529 f.write(b"\0") 2RbS T J h o SbU V K i p TbW X L j q UbY Z M k r Vb0 1 N l s Wb2 3 O m t Xb4 5 P n u
2532@pytest.mark.respx 1abcdefg
2533def test_large_file_threshold_warning( 1abcdefg
2534 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2535) -> None:
2536 app_data = _get_random_app() 1opqrstu
2537 app_id = app_data["id"] 1opqrstu
2538 team_id = "some-team-id" 1opqrstu
2539 deployment_data = _get_random_deployment(app_id=app_id) 1opqrstu
2541 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1opqrstu
2542 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1opqrstu
2543 return_value=Response(200, json={**deployment_data, "status": "success"})
2544 )
2546 _create_file(tmp_path / "model.bin", 12 * 1024 * 1024) # 12 MB 1opqrstu
2547 _create_file(tmp_path / "data.csv", 10 * 1024 * 1024 + 1) # 10+ MB 1opqrstu
2549 with changing_dir(tmp_path): 1opqrstu
2550 result = runner.invoke(app, ["deploy"]) 1opqrstu
2552 assert result.exit_code == 0 1opqrstu
2553 assert "Some uploaded files are larger than 10 MB" in result.output 1opqrstu
2554 assert "model.bin" in result.output 1opqrstu
2555 assert "12 MB" in result.output 1opqrstu
2556 assert "data.csv" in result.output 1opqrstu
2557 assert "10 MB" in result.output 1opqrstu
2560@pytest.mark.respx 1abcdefg
2561def test_large_file_threshold_only_top_three_files_with_more_indicator( 1abcdefg
2562 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2563) -> None:
2564 app_data = _get_random_app() 1hijklmn
2565 app_id = app_data["id"] 1hijklmn
2566 team_id = "some-team-id" 1hijklmn
2567 deployment_data = _get_random_deployment(app_id=app_id) 1hijklmn
2569 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1hijklmn
2570 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1hijklmn
2571 return_value=Response(200, json={**deployment_data, "status": "success"})
2572 )
2574 _create_file(tmp_path / "huge.bin", 50 * 1024 * 1024) 1hijklmn
2575 _create_file(tmp_path / "big.bin", 40 * 1024 * 1024) 1hijklmn
2576 _create_file(tmp_path / "medium.bin", 30 * 1024 * 1024) 1hijklmn
2577 _create_file(tmp_path / "smaller.bin", 20 * 1024 * 1024) 1hijklmn
2578 _create_file(tmp_path / "smallest.bin", 15 * 1024 * 1024) 1hijklmn
2580 with changing_dir(tmp_path): 1hijklmn
2581 result = runner.invoke(app, ["deploy"]) 1hijklmn
2583 assert result.exit_code == 0 1hijklmn
2584 assert "huge.bin" in result.output 1hijklmn
2585 assert "big.bin" in result.output 1hijklmn
2586 assert "medium.bin" in result.output 1hijklmn
2587 assert "smaller.bin" not in result.output 1hijklmn
2588 assert "smallest.bin" not in result.output 1hijklmn
2589 assert "...and 2 more" in result.output 1hijklmn
2592@pytest.mark.respx 1abcdefg
2593def test_large_file_threshold_does_not_warn_when_no_large_files( 1abcdefg
2594 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2595) -> None:
2596 app_data = _get_random_app() 1JKLMNOP
2597 app_id = app_data["id"] 1JKLMNOP
2598 team_id = "some-team-id" 1JKLMNOP
2599 deployment_data = _get_random_deployment(app_id=app_id) 1JKLMNOP
2601 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1JKLMNOP
2602 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1JKLMNOP
2603 return_value=Response(200, json={**deployment_data, "status": "success"})
2604 )
2606 # Files are less or equal to 10 MB (default threshold), so no warning should be shown
2607 _create_file(tmp_path / "data.bin", 5 * 1024 * 1024) 1JKLMNOP
2608 _create_file(tmp_path / "data.bin", 10 * 1024 * 1024) 1JKLMNOP
2610 with changing_dir(tmp_path): 1JKLMNOP
2611 result = runner.invoke(app, ["deploy"]) 1JKLMNOP
2613 assert result.exit_code == 0 1JKLMNOP
2614 assert "Some uploaded files are larger than" not in result.output 1JKLMNOP
2615 assert "data.bin" not in result.output 1JKLMNOP
2618@pytest.mark.respx 1abcdefg
2619def test_large_file_threshold_custom_threshold( 1abcdefg
2620 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2621) -> None:
2622 app_data = _get_random_app() 1SUWY024
2623 app_id = app_data["id"] 1SUWY024
2624 team_id = "some-team-id" 1SUWY024
2625 deployment_data = _get_random_deployment(app_id=app_id) 1SUWY024
2627 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1SUWY024
2628 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1SUWY024
2629 return_value=Response(200, json={**deployment_data, "status": "success"})
2630 )
2632 # 5 MB file: above a 1 MB threshold, below the default 10 MB threshold
2633 _create_file(tmp_path / "data.bin", 5 * 1024 * 1024) 1SUWY024
2635 with changing_dir(tmp_path): 1SUWY024
2636 result = runner.invoke(app, ["deploy", "--large-file-threshold", "1"]) 1SUWY024
2638 assert result.exit_code == 0 1SUWY024
2639 assert "Some uploaded files are larger than 1 MB" in result.output 1SUWY024
2640 assert "data.bin" in result.output 1SUWY024
2643@pytest.mark.respx 1abcdefg
2644def test_large_file_threshold_custom_threshold_envvar( 1abcdefg
2645 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2646) -> None:
2647 app_data = _get_random_app() 1TVXZ135
2648 app_id = app_data["id"] 1TVXZ135
2649 team_id = "some-team-id" 1TVXZ135
2650 deployment_data = _get_random_deployment(app_id=app_id) 1TVXZ135
2652 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1TVXZ135
2653 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1TVXZ135
2654 return_value=Response(200, json={**deployment_data, "status": "success"})
2655 )
2657 # 5 MB file: above a 1 MB threshold, below the default 10 MB threshold
2658 _create_file(tmp_path / "data.bin", 5 * 1024 * 1024) 1TVXZ135
2660 with changing_dir(tmp_path): 1TVXZ135
2661 result = runner.invoke( 1TVXZ135
2662 app, ["deploy"], env={"FASTAPI_CLOUD_LARGE_FILE_THRESHOLD": "1"}
2663 )
2665 assert result.exit_code == 0 1TVXZ135
2666 assert "Some uploaded files are larger than 1 MB" in result.output 1TVXZ135
2667 assert "data.bin" in result.output 1TVXZ135
2670@pytest.mark.respx 1abcdefg
2671def test_invalid_large_file_threshold( 1abcdefg
2672 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
2673) -> None:
2674 with changing_dir(tmp_path): 2'f(f)f*f+f,f-f
2675 result = runner.invoke(app, ["deploy", "--large-file-threshold", "0"]) 2'f(f)f*f+f,f-f
2677 assert result.exit_code == 2 2'f(f)f*f+f,f-f
2678 assert "Invalid value for '--large-file-threshold'" in result.output 2'f(f)f*f+f,f-f
2681@pytest.mark.respx 1abcdefg
2682@pytest.mark.parametrize("ci", [False, True]) 1abcdefg
2683def test_deploy_shows_backend_build_failure_diagnostic( 1abcdefg
2684 logged_in_cli: None,
2685 tmp_path: Path,
2686 respx_mock: respx.MockRouter,
2687 ci: bool,
2688) -> None:
2689 app_data: RandomApp = { 2PcQcRcScTcUcVc
2690 "id": "123",
2691 "name": "demo",
2692 "slug": "demo",
2693 "team_id": "456",
2694 "directory": None,
2695 }
2696 deployment_data = { 2PcQcRcScTcUcVc
2697 "id": "789",
2698 "app_id": "123",
2699 "slug": "demo-build",
2700 "status": "waiting_upload",
2701 "url": "https://demo.fastapicloud.app",
2702 "dashboard_url": "https://dashboard.fastapicloud.com/demo-build",
2703 }
2704 _mock_deploy_until_upload( 2PcQcRcScTcUcVc
2705 respx_mock, tmp_path, app_data, deployment_data, Response(200)
2706 )
2707 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").respond( 2PcQcRcScTcUcVc
2708 200, json={**deployment_data, "status": "ready_for_build"}
2709 )
2710 respx_mock.get(f"/deployments/{deployment_data['id']}").respond( 2PcQcRcScTcUcVc
2711 200,
2712 json={
2713 **deployment_data,
2714 "created_at": "2026-09-10T12:00:00Z",
2715 "status": "building_image_failed",
2716 "failure": {
2717 "error_code": "uv_lockfile_outdated",
2718 "error_title": "Your lockfile is out of date",
2719 "error_message": "The lockfile does not match your project dependencies.",
2720 "error_hint": "Run `uv lock` and commit the updated lockfile.",
2721 },
2722 },
2723 )
2724 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").respond( 2PcQcRcScTcUcVc
2725 200,
2726 content=build_logs_response(
2727 {"type": "message", "id": "1", "message": "Installing dependencies"},
2728 {"type": "message", "id": "3", "message": "Cleaning up..."},
2729 {"type": "failed", "id": "4"},
2730 ),
2731 )
2733 with changing_dir(tmp_path): 2PcQcRcScTcUcVc
2734 result = SnapshotCliRunner().invoke(app, ["deploy"], env={"CI": str(ci)}) 2PcQcRcScTcUcVc
2736 assert result.exit_code == 1 2PcQcRcScTcUcVc
2737 assert ("Installing dependencies" in result.output) == ci 2PcQcRcScTcUcVc
2738 assert ("Cleaning up..." in result.output) == ci 2PcQcRcScTcUcVc
2739 assert result.output.count("Your lockfile is out of date") == 1 2PcQcRcScTcUcVc
2740 assert "The lockfile does not match your project dependencies." in result.output 2PcQcRcScTcUcVc
2741 assert "Run `uv lock` and commit the updated lockfile." in result.output 2PcQcRcScTcUcVc
2742 assert deployment_data["dashboard_url"] in result.output 2PcQcRcScTcUcVc
2743 assert "Something went wrong" not in result.output 2PcQcRcScTcUcVc
2744 assert "Building and pushing the app image failed" not in result.output 2PcQcRcScTcUcVc
2745 assert "error: Your lockfile is out of date" not in result.output 2PcQcRcScTcUcVc
2746 assert "Build failed" not in result.output 2PcQcRcScTcUcVc
2749@pytest.mark.respx 1abcdefg
2750def test_diagnosed_build_failure_replaces_log_panel( 1abcdefg
2751 logged_in_cli: None,
2752 respx_mock: respx.MockRouter,
2753) -> None:
2754 deployment = CreateDeploymentResponse( 20e1e2e3e4e5e6e
2755 id="789",
2756 app_id="123",
2757 slug="demo-build",
2758 status=DeploymentStatus.ready_for_build,
2759 url="https://demo.fastapicloud.app",
2760 dashboard_url="https://dashboard.fastapicloud.com/demo-build",
2761 )
2762 respx_mock.get(f"/deployments/{deployment.id}").respond( 20e1e2e3e4e5e6e
2763 200,
2764 json={
2765 **deployment.model_dump(mode="json"),
2766 "created_at": "2026-09-10T12:00:00Z",
2767 "status": "building_image_failed",
2768 "failure": {
2769 "error_code": "uv_lockfile_outdated",
2770 "error_title": "Your lockfile is out of date",
2771 "error_message": "The lockfile does not match your project dependencies.",
2772 "error_hint": "Run `uv lock` and commit the updated lockfile.",
2773 },
2774 },
2775 )
2776 respx_mock.get(f"/deployments/{deployment.id}/build-logs").respond( 20e1e2e3e4e5e6e
2777 200,
2778 content=build_logs_response(
2779 {
2780 "type": "message",
2781 "id": "1",
2782 "message": "The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.\n",
2783 },
2784 {"type": "message", "id": "2", "message": "\n"},
2785 {"type": "failed", "id": "5"},
2786 ),
2787 )
2788 command = typer.Typer() 20e1e2e3e4e5e6e
2790 @command.command() 20e1e2e3e4e5e6e
2791 def wait_for_build() -> None: 20e1e2e3e4e5e6e
2792 with get_rich_toolkit() as toolkit, APIClient() as client: 20e1e2e3e4e5e6e
2793 wait._wait_for_deployment(toolkit, client, deployment.app_id, deployment) 20e1e2e3e4e5e6e
2795 result = SnapshotCliRunner().invoke(command) 20e1e2e3e4e5e6e
2797 assert result.exit_code == 1 20e1e2e3e4e5e6e
2798 assert result.output == snapshot("""\ 20e1e2e3e4e5e6e
2799🚨 Your lockfile is out of date
2801 The lockfile does not match your project dependencies.
2803 hint: Run `uv lock` and commit the updated lockfile.
2805👀 Check out the logs at https://dashboard.fastapicloud.com/demo-build\
2806""")
2809def _mock_configured_app( 1abcdefg
2810 respx_mock: respx.MockRouter,
2811 configured_app: ConfiguredApp,
2812 **app_fields: str | None,
2813) -> None:
2814 app_data = {**_get_random_app(), **app_fields, "id": configured_app.app_id} 2yefezeudAegeBevdCeheDewdEeieFexdGejeHeydIekeJezdKeleLeAd
2815 respx_mock.get(f"/apps/{configured_app.app_id}").mock( 2yefezeudAegeBevdCeheDewdEeieFexdGejeHeydIekeJezdKeleLeAd
2816 return_value=Response(200, json=app_data)
2817 )
2820@pytest.mark.respx 1abcdefg
2821def test_deploy_fails_when_app_directory_is_missing( 1abcdefg
2822 logged_in_cli: None,
2823 configured_app: ConfiguredApp,
2824 respx_mock: respx.MockRouter,
2825) -> None:
2826 _mock_configured_app(respx_mock, configured_app, directory="backend") 2yeAeCeEeGeIeKe
2828 with changing_dir(configured_app.path): 2yeAeCeEeGeIeKe
2829 result = runner.invoke(app, ["deploy", "--json"]) 2yeAeCeEeGeIeKe
2831 assert result.exit_code == 1 2yeAeCeEeGeIeKe
2832 assert json.loads(result.stdout) == { 2yeAeCeEeGeIeKe
2833 "error": {
2834 "code": "invalid_input",
2835 "message": (
2836 f"The app directory `backend` doesn't exist in {configured_app.path}."
2837 ),
2838 "hint": (
2839 "Deploy from the folder that contains it, update the app directory "
2840 "with `fastapi cloud apps update --directory`, or use --force."
2841 ),
2842 }
2843 }
2846@pytest.mark.respx 1abcdefg
2847def test_deploy_points_linked_apps_to_the_repository_root( 1abcdefg
2848 logged_in_cli: None,
2849 configured_app: ConfiguredApp,
2850 respx_mock: respx.MockRouter,
2851) -> None:
2852 _mock_configured_app( 2zeBeDeFeHeJeLe
2853 respx_mock,
2854 configured_app,
2855 directory="backend",
2856 github_repository="acme/shop",
2857 )
2859 with changing_dir(configured_app.path): 2zeBeDeFeHeJeLe
2860 result = runner.invoke(app, ["deploy", "--json"]) 2zeBeDeFeHeJeLe
2862 assert result.exit_code == 1 2zeBeDeFeHeJeLe
2863 assert json.loads(result.stdout)["error"]["hint"] == ( 2zeBeDeFeHeJeLe
2864 "This app is connected to acme/shop and builds from `backend` in that "
2865 f"repository. Run `fastapi deploy --app-id {configured_app.app_id}` from "
2866 "the repository root, or use --force."
2867 )
2870@pytest.mark.respx 1abcdefg
2871def test_deploy_with_force_skips_the_app_directory_check( 1abcdefg
2872 logged_in_cli: None,
2873 configured_app: ConfiguredApp,
2874 respx_mock: respx.MockRouter,
2875) -> None:
2876 _mock_configured_app(respx_mock, configured_app, directory="backend") 2udvdwdxdydzdAd
2877 deployment_data = _get_random_deployment(app_id=configured_app.app_id) 2udvdwdxdydzdAd
2879 respx_mock.post(f"/apps/{configured_app.app_id}/deployments/").mock( 2udvdwdxdydzdAd
2880 return_value=Response(201, json=deployment_data)
2881 )
2882 respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( 2udvdwdxdydzdAd
2883 return_value=Response(
2884 200, json={"url": "http://test.com", "fields": {"key": "value"}}
2885 )
2886 )
2887 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2udvdwdxdydzdAd
2888 return_value=Response(200)
2889 )
2890 respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( 2udvdwdxdydzdAd
2891 return_value=Response(
2892 200, json={**deployment_data, "status": "ready_for_build"}
2893 )
2894 )
2896 with changing_dir(configured_app.path): 2udvdwdxdydzdAd
2897 result = runner.invoke(app, ["deploy", "--json", "--force"]) 2udvdwdxdydzdAd
2899 assert result.exit_code == 0 2udvdwdxdydzdAd
2900 assert json.loads(result.stdout)["data"]["deployment_id"] == deployment_data["id"] 2udvdwdxdydzdAd
2903@pytest.mark.respx 1abcdefg
2904def test_deploy_fails_when_there_is_nothing_to_upload( 1abcdefg
2905 logged_in_cli: None,
2906 configured_app: ConfiguredApp,
2907 respx_mock: respx.MockRouter,
2908 app_source: Path,
2909) -> None:
2910 # Only the local .fastapicloud link is left, and it isn't uploaded.
2911 app_source.unlink() 2fegeheiejekele
2912 _mock_configured_app(respx_mock, configured_app) 2fegeheiejekele
2914 with changing_dir(configured_app.path): 2fegeheiejekele
2915 result = runner.invoke(app, ["deploy", "--json"]) 2fegeheiejekele
2917 assert result.exit_code == 1 2fegeheiejekele
2918 assert json.loads(result.stdout)["error"] == { 2fegeheiejekele
2919 "code": "invalid_input",
2920 "message": f"Nothing to deploy: no files to upload in {configured_app.path}.",
2921 "hint": (
2922 "Check the path, and that .gitignore or .fastapicloudignore don't "
2923 "exclude your app's files."
2924 ),
2925 }
2928def test_deploy_on_ci_without_app_id_fails_instead_of_prompting( 1abcdefg
2929 logged_in_cli: None, tmp_path: Path
2930) -> None:
2931 with changing_dir(tmp_path): 2.f/f:f;f=f?f@f
2932 result = runner.invoke(app, ["deploy"], env={"CI": "true"}) 2.f/f:f;f=f?f@f
2934 assert result.exit_code == 1 2.f/f:f;f=f?f@f
2935 assert "App ID is required." in result.output 2.f/f:f;f=f?f@f
2938def test_deploy_on_ci_refuses_the_home_directory( 1abcdefg
2939 logged_in_cli: None, tmp_path: Path
2940) -> None:
2941 with changing_dir(tmp_path), patch("pathlib.Path.home", return_value=tmp_path): 2[f]f^f_f`f{f|f
2942 result = runner.invoke(app, ["deploy"], env={"CI": "true"}) 2[f]f^f_f`f{f|f
2944 assert result.exit_code == 1 2[f]f^f_f`f{f|f
2945 assert "Refusing to deploy your home directory." in result.output 2[f]f^f_f`f{f|f
2948def test_deploy_asks_before_deploying_the_home_directory( 1abcdefg
2949 logged_in_cli: None, tmp_path: Path
2950) -> None:
2951 with ( 2MfNf
2952 changing_dir(tmp_path),
2953 patch("pathlib.Path.home", return_value=tmp_path),
2954 patch("rich_toolkit.container.getchar") as mock_getchar,
2955 ):
2956 mock_getchar.side_effect = [Keys.ENTER] # Accept the default: "No" 2MfVfWfXfNfYfZf
2958 result = runner.invoke(app, ["deploy"]) 2MfVfWfXfNfYfZf
2960 assert result.exit_code == 0 2MfVfWfXfNfYfZf
2961 assert "You are deploying your home directory" in result.output 2MfVfWfXfNfYfZf
2962 assert "Deployment cancelled." in result.output 2MfVfWfXfNfYfZf
2965@pytest.mark.respx 1abcdefg
2966def test_deploy_continues_when_home_directory_is_confirmed( 1abcdefg
2967 logged_in_cli: None,
2968 configured_app: ConfiguredApp,
2969 respx_mock: respx.MockRouter,
2970) -> None:
2971 deployment_data = _get_random_deployment(app_id=configured_app.app_id) 2bcscvcycicDcGc
2972 _setup_deployment_mocks( 2bcscvcycicDcGc
2973 respx_mock,
2974 configured_app.app_id,
2975 configured_app.team_id,
2976 deployment_data,
2977 configured_app.path,
2978 )
2979 respx_mock.get(f"/deployments/{deployment_data['id']}").respond( 2bcscvcycicDcGc
2980 200, json={**deployment_data, "status": "success"}
2981 )
2983 with ( 2bcic
2984 changing_dir(configured_app.path),
2985 patch("pathlib.Path.home", return_value=configured_app.path),
2986 patch(
2987 "rich_toolkit.container.getchar",
2988 side_effect=[Keys.RIGHT_ARROW, Keys.ENTER],
2989 ),
2990 ):
2991 result = runner.invoke(app, ["deploy"]) 2bcscvcycicDcGc
2993 assert result.exit_code == 0 2bcscvcycicDcGc
2994 assert "You are deploying your home directory" in result.output 2bcscvcycicDcGc
2995 assert "Deployment uploaded successfully!" in result.output 2bcscvcycicDcGc
2996 assert "Deployment cancelled." not in result.output 2bcscvcycicDcGc
2999@pytest.mark.respx 1abcdefg
3000def test_degraded_deployment_fails_verification( 1abcdefg
3001 logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
3002) -> None:
3003 app_data = _get_random_app() 2pcrcucxcAcCcFc
3004 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2pcrcucxcAcCcFc
3006 _setup_deployment_mocks( 2pcrcucxcAcCcFc
3007 respx_mock, app_data["id"], "some-team-id", deployment_data, tmp_path
3008 )
3009 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2pcrcucxcAcCcFc
3010 return_value=Response(200, json={**deployment_data, "status": "degraded"})
3011 )
3013 with changing_dir(tmp_path): 2pcrcucxcAcCcFc
3014 result = runner.invoke(app, ["deploy"]) 2pcrcucxcAcCcFc
3016 assert result.exit_code == 1 2pcrcucxcAcCcFc
3017 assert "Degraded" in result.output 2pcrcucxcAcCcFc