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

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

9 

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

18 

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) 

38 

39runner = CliRunner() 1abcdefg

40 

41assets_path = Path(__file__).parent / "assets" 1abcdefg

42 

43 

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

50 

51 

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

56 

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

58 

59 

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

66 

67 

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

78 

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 } 

86 

87 

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

96 

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 } 

106 

107 

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

137 

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

144 

145 result = runner.invoke(app, ["deploy"]) 2MeVeWeXeNeYeZe

146 

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

153 

154 

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

167 

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

175 

176 

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

182 

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

193 

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

198 

199 

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

205 

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

211 

212 

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

222 

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

226 

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 ) 

243 

244 with changing_dir(tmp_path): 21c2c3c4c5c6c7c

245 result = runner.invoke(app, ["deploy", "--json"]) 21c2c3c4c5c6c7c

246 

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 } 

262 

263 

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

269 

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 } 

278 

279 

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

285 

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 } 

294 

295 

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 ) 

307 

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

312 

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 ) 

331 

332 with changing_dir(tmp_path): 2RbSbTbUbVbWbXb

333 result = runner.invoke(app, ["deploy", "--json"]) 2RbSbTbUbVbWbXb

334 

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 ] 

343 

344 

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

350 

351 respx_mock.get("/teams/").mock(return_value=Response(500)) 2kfvfwfxflfyfzf

352 

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

358 

359 result = runner.invoke(app, ["deploy"]) 2kfvfwfxflfyfzf

360 

361 assert result.exit_code == 1 2kfvfwfxflfyfzf

362 

363 assert "Error fetching teams. Please try again later" in result.output 2kfvfwfxflfyfzf

364 

365 

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

371 

372 respx_mock.get("/teams/").mock(return_value=Response(401)) 2mfAfBfCfnfDfEf

373 

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

379 

380 result = runner.invoke(app, ["deploy"]) 2mfAfBfCfnfDfEf

381 

382 assert result.exit_code == 1 2mfAfBfCfnfDfEf

383 

384 assert "The specified token is not valid" in result.output 2mfAfBfCfnfDfEf

385 

386 

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

392 

393 team_1 = _get_random_team() 2_d}d~dae`ddeee

394 team_2 = _get_random_team() 2_d}d~dae`ddeee

395 

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 ) 

402 

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

408 

409 result = runner.invoke(app, ["deploy"]) 2_d}d~dae`ddeee

410 

411 assert result.exit_code == 1 2_d}d~dae`ddeee

412 

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

415 

416 

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

422 

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

425 

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 ) 

432 

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

438 

439 result = runner.invoke(app, ["deploy"]) 29d?d@d[d,d]d^d

440 

441 assert result.exit_code == 1 29d?d@d[d,d]d^d

442 

443 assert "Filter: al" in result.output 29d?d@d[d,d]d^d

444 

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

448 

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

451 

452 

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

458 

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 ) 

465 

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

471 

472 result = runner.invoke(app, ["deploy"]) 2Oe8e9e!eSe$e%e

473 

474 assert result.exit_code == 1 2Oe8e9e!eSe$e%e

475 

476 assert "What's your app name?" in result.output 2Oe8e9e!eSe$e%e

477 

478 

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

484 

485 team = _get_random_team() 2!c;c=c?c#c@c[c

486 

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 ) 

493 

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

496 

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 ) 

500 

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

506 

507 result = runner.invoke(app, ["deploy"]) 2!c;c=c?c#c@c[c

508 

509 assert result.exit_code == 1 2!c;c=c?c#c@c[c

510 

511 assert "Filter: an" in result.output 2!c;c=c?c#c@c[c

512 

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

516 

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

519 

520 

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

526 

527 team = _get_random_team() 2Wd!d%d)dZd-d:d

528 

529 respx_mock.get("/teams/").mock( 2Wd!d%d)dZd-d:d

530 return_value=Response( 

531 200, 

532 json={"data": [team]}, 

533 ) 

534 ) 

535 

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"]))) 

539 

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

545 

546 result = runner.invoke(app, ["deploy"]) 2Wd!d%d)dZd-d:d

547 

548 assert result.exit_code == 1 2Wd!d%d)dZd-d:d

549 

550 assert "App created successfully" in result.output 2Wd!d%d)dZd-d:d

551 

552 

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

559 

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 ) 

569 

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

575 

576 result = runner.invoke(app, ["deploy"]) 2{dneperebeuewe

577 

578 assert result.exit_code == 1 2{dneperebeuewe

579 assert "App limit reached" in result.output 2{dneperebeuewe

580 

581 

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 ] 

595 

596 team = _get_random_team() 2BdMdOdQdDdSdUd

597 

598 respx_mock.get("/teams/").mock( 2BdMdOdQdDdSdUd

599 return_value=Response( 

600 200, 

601 json={"data": [team]}, 

602 ) 

603 ) 

604 

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"]))) 

608 

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

614 

615 result = runner.invoke(app, ["deploy"]) 2BdMdOdQdDdSdUd

616 

617 assert result.exit_code == 1 2BdMdOdQdDdSdUd

618 

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

622 

623 

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 ] 

650 

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 ) 

657 

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

663 

664 result = runner.invoke(app, ["deploy"]) 27e=e?e@e#e[e]e

665 

666 assert expected_error in result.output 27e=e?e@e#e[e]e

667 

668 

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 ] 

682 

683 team = _get_random_team() 2mePeQeReteTeUe

684 

685 respx_mock.get("/teams/").mock( 2mePeQeReteTeUe

686 return_value=Response( 

687 200, 

688 json={"data": [team]}, 

689 ) 

690 ) 

691 

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

697 

698 result = runner.invoke(app, ["deploy"]) 2mePeQeReteTeUe

699 

700 assert result.exit_code == 0 2mePeQeReteTeUe

701 assert "Deployment cancelled." in result.output 2mePeQeReteTeUe

702 

703 

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

709 

710 team = _get_random_team() 2CdNdPdRdEdTdVd

711 

712 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2CdNdPdRdEdTdVd

713 

714 app_data = _get_random_app(team_id=team["id"]) 2CdNdPdRdEdTdVd

715 

716 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2CdNdPdRdEdTdVd

717 return_value=Response(200, json={"data": [app_data]}) 

718 ) 

719 

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

725 

726 result = runner.invoke(app, ["deploy"]) 2CdNdPdRdEdTdVd

727 

728 assert result.exit_code == 1 2CdNdPdRdEdTdVd

729 

730 assert "Select the app you want to deploy to:" in result.output 2CdNdPdRdEdTdVd

731 assert app_data["slug"] in result.output 2CdNdPdRdEdTdVd

732 

733 

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 ] 

747 

748 team = _get_random_team() 2Yd$d(d+d1d/d=d

749 

750 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2Yd$d(d+d1d/d=d

751 

752 app_data = _get_random_app(team_id=team["id"], directory="backend") 2Yd$d(d+d1d/d=d

753 

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 ) 

757 

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

763 

764 result = runner.invoke(app, ["deploy"]) 2Yd$d(d+d1d/d=d

765 

766 assert "Directory: backend" in result.output 2Yd$d(d+d1d/d=d

767 

768 

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 ] 

784 

785 team = _get_random_team() 2Xd#d'd*d0d.d;d

786 

787 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2Xd#d'd*d0d.d;d

788 

789 app_data = _get_random_app(team_id=team["id"], directory="backend") 2Xd#d'd*d0d.d;d

790 

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 ) 

794 

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

800 

801 result = runner.invoke(app, ["deploy"]) 2Xd#d'd*d0d.d;d

802 

803 assert "Directory: src" in result.output 2Xd#d'd*d0d.d;d

804 

805 

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 ] 

820 

821 team = _get_random_team() 2+ ; @ ^ , } bb

822 

823 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2+ ; @ ^ , } bb

824 

825 app_data = _get_random_app(team_id=team["id"], directory="backend") 2+ ; @ ^ , } bb

826 

827 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2+ ; @ ^ , } bb

828 return_value=Response(200, json={"data": [app_data]}) 

829 ) 

830 

831 updated_app_data = {**app_data, "directory": "src"} 2+ ; @ ^ , } bb

832 (tmp_path / "src").mkdir() 2+ ; @ ^ , } bb

833 

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)) 

837 

838 respx_mock.get(f"/apps/{app_data['id']}").mock( 2+ ; @ ^ , } bb

839 return_value=Response(200, json=updated_app_data) 

840 ) 

841 

842 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2+ ; @ ^ , } bb

843 

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 ) 

870 

871 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2+ ; @ ^ , } bb

872 return_value=Response(200, json={**deployment_data, "status": "success"}) 

873 ) 

874 

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

880 

881 result = runner.invoke(app, ["deploy"]) 2+ ; @ ^ , } bb

882 

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

886 

887 

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

901 

902 team = _get_random_team() 2jbJbKbLbsbPbQb

903 

904 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2jbJbKbLbsbPbQb

905 

906 app_data = _get_random_app(team_id=team["id"], directory="backend") 2jbJbKbLbsbPbQb

907 

908 respx_mock.get("/apps/", params={"team_id": team["id"]}).mock( 2jbJbKbLbsbPbQb

909 return_value=Response(200, json={"data": [app_data]}) 

910 ) 

911 

912 respx_mock.get(f"/apps/{app_data['id']}").mock( 2jbJbKbLbsbPbQb

913 return_value=Response(200, json=app_data) 

914 ) 

915 

916 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2jbJbKbLbsbPbQb

917 

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 ) 

944 

945 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2jbJbKbLbsbPbQb

946 return_value=Response(200, json={**deployment_data, "status": "success"}) 

947 ) 

948 

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

954 

955 result = runner.invoke(app, ["deploy"]) 2jbJbKbLbsbPbQb

956 

957 assert result.exit_code == 0 2jbJbKbLbsbPbQb

958 assert "App directory updated" not in result.output 2jbJbKbLbsbPbQb

959 

960 

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 ] 

973 

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

976 

977 respx_mock.get("/teams/").mock( 2Zb]b_b{b!b}bac

978 return_value=Response(200, json={"data": [team_data]}) 

979 ) 

980 

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)) 

984 

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 ) 

988 

989 deployment_data = _get_random_deployment(app_id=app_data["id"]) 2Zb]b_b{b!b}bac

990 

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 ) 

1003 

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 ) 

1011 

1012 respx_mock.post( 2Zb]b_b{b!b}bac

1013 "http://test.com", 

1014 data={"key": "value"}, 

1015 ).mock(return_value=Response(200)) 

1016 

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 ) 

1026 

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 ) 

1030 

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

1036 

1037 result = runner.invoke(app, ["deploy"]) 2Zb]b_b{b!b}bac

1038 

1039 assert result.exit_code == 0 2Zb]b_b{b!b}bac

1040 

1041 

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

1051 

1052 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2cbdbebfbgbhbib

1053 

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

1056 

1057 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2cbdbebfbgbhbib

1058 

1059 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2cbdbebfbgbhbib

1060 return_value=Response(201, json=deployment_data) 

1061 ) 

1062 

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 ) 

1069 

1070 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2cbdbebfbgbhbib

1071 return_value=Response(200) 

1072 ) 

1073 

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 ) 

1084 

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 ) 

1092 

1093 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2cbdbebfbgbhbib

1094 return_value=Response(200, json={**deployment_data, "status": "success"}) 

1095 ) 

1096 

1097 with changing_dir(tmp_path): 2cbdbebfbgbhbib

1098 result = runner.invoke(app, ["deploy"]) 2cbdbebfbgbhbib

1099 

1100 assert result.exit_code == 0 2cbdbebfbgbhbib

1101 

1102 # check that logs are shown 

1103 assert "All good!" in result.output 2cbdbebfbgbhbib

1104 

1105 # check that the app URL is shown 

1106 assert deployment_data["url"] in result.output 2cbdbebfbgbhbib

1107 

1108 

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

1118 

1119 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2. : ? ] ` | ab

1120 

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

1123 

1124 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2. : ? ] ` | ab

1125 

1126 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2. : ? ] ` | ab

1127 return_value=Response(201, json=deployment_data) 

1128 ) 

1129 

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 ) 

1136 

1137 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2. : ? ] ` | ab

1138 return_value=Response(200) 

1139 ) 

1140 

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 ) 

1154 

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 ) 

1162 

1163 with changing_dir(tmp_path): 2. : ? ] ` | ab

1164 result = runner.invoke(app, ["deploy"]) 2. : ? ] ` | ab

1165 

1166 assert result.exit_code == 1 2. : ? ] ` | ab

1167 

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

1171 

1172 

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

1182 

1183 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2lbnbpbrbubwbyb

1184 

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

1187 

1188 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2lbnbpbrbubwbyb

1189 

1190 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2lbnbpbrbubwbyb

1191 return_value=Response(201, json=deployment_data) 

1192 ) 

1193 

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 ) 

1200 

1201 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2lbnbpbrbubwbyb

1202 return_value=Response(200) 

1203 ) 

1204 

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 ) 

1212 

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 ) 

1220 

1221 with changing_dir(tmp_path): 2lbnbpbrbubwbyb

1222 result = runner.invoke(app, ["deploy"]) 2lbnbpbrbubwbyb

1223 

1224 assert "Something went wrong" in result.stdout 2lbnbpbrbubwbyb

1225 

1226 assert result.exit_code == 1 2lbnbpbrbubwbyb

1227 

1228 

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

1234 

1235 with changing_dir(configured_app.path): 2OfPfQfRfSfTfUf

1236 result = runner.invoke(app, ["deploy"]) 2OfPfQfRfSfTfUf

1237 

1238 assert result.exit_code == 1 2OfPfQfRfSfTfUf

1239 

1240 assert "App not found" in result.output 2OfPfQfRfSfTfUf

1241 

1242 

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 ] 

1252 

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

1256 

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 ) 

1263 

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)) 

1267 

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 ) 

1271 

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 ) 

1286 

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 ) 

1294 

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 ) 

1298 

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

1304 

1305 return runner.invoke(app, ["deploy", "--no-wait"]) 2YbGbHb[b0b1b^b3b4b`b6b7b9bMbNb|b#b$b~b'b(b

1306 

1307 

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

1313 

1314 assert result.exit_code == 0 2Yb[b^b`b9b|b~b

1315 

1316 assert "Check the status of your deployment at" in result.output 2Yb[b^b`b9b|b~b

1317 

1318 

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

1324 

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

1328 

1329 

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

1336 

1337 _deploy_without_waiting(respx_mock, tmp_path) 2Hb1b4b7bNb$b(b

1338 

1339 assert git_ignore_path.read_text() == ".fastapicloud\n" 2Hb1b4b7bNb$b(b

1340 

1341 

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 ] 

1351 

1352 team = _get_random_team() 2|doeqesecevexe

1353 

1354 respx_mock.get("/teams/").mock(return_value=Response(200, json={"data": [team]})) 2|doeqesecevexe

1355 

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 ) 

1360 

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

1366 

1367 result = runner.invoke(app, ["deploy"]) 2|doeqesecevexe

1368 

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 ) 

1374 

1375 

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

1389 

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

1393 

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 ) 

1411 

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

1420 

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

1424 

1425 

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

1435 

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

1439 

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 ) 

1457 

1458 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 2kbmbobqbtbvbxb

1459 return_value=Response(500, text="Internal Server Error") 

1460 ) 

1461 

1462 with changing_dir(tmp_path), patch("time.sleep"): 2kbmbobqbtbvbxb

1463 result = runner.invoke(app, ["deploy"]) 2kbmbobqbtbvbxb

1464 

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

1468 

1469 

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

1481 

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

1485 

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 ) 

1503 

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

1507 

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 ) 

1529 

1530 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 1wACEyGI

1531 side_effect=build_logs_handler 

1532 ) 

1533 

1534 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1wACEyGI

1535 return_value=Response(200, json={**deployment_data, "status": "success"}) 

1536 ) 

1537 

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

1545 

1546 assert result.exit_code == 0 1wACEyGI

1547 assert "Ready the chicken!" in result.output 1wACEyGI

1548 

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

1552 

1553 

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

1565 

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

1569 

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 ) 

1587 

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

1591 

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 ) 

1613 

1614 respx_mock.get(f"/deployments/{deployment_data['id']}/build-logs").mock( 1vzBDxFH

1615 side_effect=build_logs_handler 

1616 ) 

1617 

1618 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1vzBDxFH

1619 return_value=Response(200, json={**deployment_data, "status": "success"}) 

1620 ) 

1621 

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

1629 

1630 assert result.exit_code == 0 1vzBDxFH

1631 assert "Ready the chicken!" in result.output 1vzBDxFH

1632 

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

1636 

1637 

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

1647 

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

1651 

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 ) 

1656 

1657 upload_cancelled_route = respx_mock.post( 2IcWcXcYcMcZc0c

1658 f"/deployments/{deployment_data['id']}/upload-cancelled" 

1659 ).mock(return_value=Response(200)) 

1660 

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

1669 

1670 assert upload_cancelled_route.called 2IcWcXcYcMcZc0c

1671 

1672 

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

1682 

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

1686 

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 ) 

1691 

1692 upload_cancelled_route = respx_mock.post( 2ncJcKcLcocNcOc

1693 f"/deployments/{deployment_data['id']}/upload-cancelled" 

1694 ).mock(return_value=Response(500)) 

1695 

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

1704 

1705 assert upload_cancelled_route.called 2ncJcKcLcocNcOc

1706 assert "HTTPStatusError" not in result.output 2ncJcKcLcocNcOc

1707 

1708 

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

1717 

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

1721 

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 ) 

1735 

1736 

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

1744 

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

1748 

1749 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2FdgdGdhdHdidIdjdJdkdKdldLdmd

1750 

1751 return respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2FdgdGdhdHdidIdjdJdkdKdldLdmd

1752 return_value=creation_response 

1753 ) 

1754 

1755 

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

1761 

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 ) 

1774 

1775 with changing_dir(tmp_path): 2gdhdidjdkdldmd

1776 result = runner.invoke(app, ["deploy"]) 2gdhdidjdkdldmd

1777 

1778 output = " ".join(result.output.split()) 2gdhdidjdkdldmd

1779 

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

1784 

1785 request_body = json.loads(create_deployment_route.calls.last.request.content) 2gdhdidjdkdldmd

1786 assert request_body["archive_size_bytes"] > 0 2gdhdidjdkdldmd

1787 

1788 

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

1794 

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 ) 

1806 

1807 with changing_dir(tmp_path): 2FdGdHdIdJdKdLd

1808 result = runner.invoke(app, ["deploy", "--json"]) 2FdGdHdIdJdKdLd

1809 

1810 assert result.exit_code == 1 2FdGdHdIdJdKdLd

1811 

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 ) 

1821 

1822 

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

1829 

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 ) 

1837 

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

1840 

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

1843 

1844 

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-/=[_{~

1854 

1855 config_path = tmp_path / ".fastapicloud" / "cloud.json" 1-/=[_{~

1856 

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-/=[_{~

1859 

1860 respx_mock.get(f"/apps/{app_id}", headers={"Authorization": "Bearer hello"}).mock( 1-/=[_{~

1861 return_value=Response(200, json=app_data) 

1862 ) 

1863 

1864 respx_mock.post( 1-/=[_{~

1865 f"/apps/{app_id}/deployments/", headers={"Authorization": "Bearer hello"} 

1866 ).mock(return_value=Response(201, json=deployment_data)) 

1867 

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 ) 

1877 

1878 respx_mock.post("http://test.com", data={"key": "value"}).mock( 1-/=[_{~

1879 return_value=Response(200) 

1880 ) 

1881 

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 ) 

1895 

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 ) 

1904 

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"})) 

1909 

1910 with changing_dir(tmp_path): 1-/=[_{~

1911 result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) 1-/=[_{~

1912 

1913 assert result.exit_code == 0 1-/=[_{~

1914 

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 ) 

1920 

1921 # check that the app URL is shown 

1922 assert deployment_data["url"] in result.output 1-/=[_{~

1923 

1924 

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

1933 

1934 config_path = tmp_path / ".fastapicloud" / "cloud.json" 2ndodpdqdrdsdtd

1935 

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

1938 

1939 respx_mock.get(f"/apps/{app_id}", headers={"Authorization": "Bearer hello"}).mock( 2ndodpdqdrdsdtd

1940 return_value=Response(401, json=app_data) 

1941 ) 

1942 

1943 with changing_dir(tmp_path): 2ndodpdqdrdsdtd

1944 result = runner.invoke(app, ["deploy"], env={"FASTAPI_CLOUD_TOKEN": "hello"}) 2ndodpdqdrdsdtd

1945 

1946 assert result.exit_code == 1 2ndodpdqdrdsdtd

1947 

1948 assert ( 2ndodpdqdrdsdtd

1949 "The specified token is not valid. Make sure to use a valid token." 

1950 in result.output 

1951 ) 

1952 

1953 

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!

1995 

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!

1999 

2000 (tmp_path / "file.bin").write_bytes(random.randbytes(size)) 1Q678R9!

2001 

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 ) 

2032 

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!

2039 

2040 call_args = [ 1Q678R9!

2041 c.args[0] for c in mock_progress.call_args_list if isinstance(c.args[0], str) 

2042 ] 

2043 

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 ) 

2049 

2050 

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

2058 

2059 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2]c_c{c}cadcded

2060 

2061 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2]c_c{c}cadcded

2062 return_value=Response(201, json=deployment_data) 

2063 ) 

2064 

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 ) 

2071 

2072 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2]c_c{c}cadcded

2073 return_value=Response(200) 

2074 ) 

2075 

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 ) 

2081 

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 ) 

2091 

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 ) 

2095 

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

2098 

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

2101 

2102 

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

2110 

2111 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2^c`c|c~cbdddfd

2112 

2113 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2^c`c|c~cbdddfd

2114 return_value=Response(201, json=deployment_data) 

2115 ) 

2116 

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 ) 

2123 

2124 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2^c`c|c~cbdddfd

2125 return_value=Response(200) 

2126 ) 

2127 

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 ) 

2133 

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 ) 

2143 

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 ) 

2147 

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

2150 

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

2153 

2154 

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

2163 

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

2167 

2168 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) 2qctcwczcBcEcHc

2169 

2170 respx_mock.post(f"/apps/{app_id}/deployments/").mock( 2qctcwczcBcEcHc

2171 return_value=Response(201, json=deployment_data) 

2172 ) 

2173 

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 ) 

2180 

2181 respx_mock.post("http://test.com", data={"key": "value"}).mock( 2qctcwczcBcEcHc

2182 return_value=Response(200) 

2183 ) 

2184 

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 ) 

2190 

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 ) 

2200 

2201 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 2qctcwczcBcEcHc

2202 return_value=Response(200, json={**deployment_data, "status": "success"}) 

2203 ) 

2204 

2205 with changing_dir(tmp_path): 2qctcwczcBcEcHc

2206 result = runner.invoke(app, ["deploy", "--app-id", app_id]) 2qctcwczcBcEcHc

2207 

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

2212 

2213 

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

2221 

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

2225 

2226 cli_app_id = "different-app-id" 22d3d4d5d6d7d8d

2227 

2228 with changing_dir(tmp_path): 22d3d4d5d6d7d8d

2229 result = runner.invoke(app, ["deploy", "--app-id", cli_app_id]) 22d3d4d5d6d7d8d

2230 

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

2235 

2236 

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

2243 

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

2247 

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

2250 

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 } 

2265 

2266 

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

2272 

2273 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) 2ofpfqfrfsftfuf

2274 

2275 with changing_dir(tmp_path): 2ofpfqfrfsftfuf

2276 result = runner.invoke(app, ["deploy", "--app-id", app_id]) 2ofpfqfrfsftfuf

2277 

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

2282 

2283 

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

2289 

2290 respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(404)) 2FfGfHfIfJfKfLf

2291 

2292 with changing_dir(tmp_path): 2FfGfHfIfJfKfLf

2293 result = runner.invoke(app, ["deploy", "--app-id", app_id, "--json"]) 2FfGfHfIfJfKfLf

2294 

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 } 

2303 

2304 

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

2316 

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

2319 

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 ) 

2346 

2347 

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

2356 

2357 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2zbAbBbCbDbEbFb

2358 

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 ) 

2364 

2365 with changing_dir(tmp_path): 2zbAbBbCbDbEbFb

2366 result = runner.invoke(app, ["deploy"]) 2zbAbBbCbDbEbFb

2367 

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

2372 

2373 

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#$%'()*

2382 

2383 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 1#$%'()*

2384 

2385 call_count = 0 1#$%'()*

2386 

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#$%'()*

2393 

2394 respx_mock.get(f"/deployments/{deployment_data['id']}").mock( 1#$%'()*

2395 side_effect=poll_handler 

2396 ) 

2397 

2398 with changing_dir(tmp_path), patch("time.sleep"): 1#$%'()*

2399 result = runner.invoke(app, ["deploy"]) 1#$%'()*

2400 

2401 assert result.exit_code == 0 1#$%'()*

2402 assert deployment_data["url"] in result.output 1#$%'()*

2403 

2404 

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

2413 

2414 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2+bdcfchc;bkcmc

2415 

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

2424 

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

2428 

2429 

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

2438 

2439 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2,b-b.b/b=b?b@b

2440 

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 ) 

2446 

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

2449 

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

2452 

2453 

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

2462 

2463 _setup_deployment_mocks(respx_mock, app_id, team_id, deployment_data, tmp_path) 2*bccecgc:bjclc

2464 

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

2473 

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

2477 

2478 

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

2487 

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

2491 

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 ) 

2509 

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

2518 

2519 assert "🟡" in result.output 28c%c(c*c9c-c/c

2520 assert "Cancelled." in result.output 28c%c(c*c9c-c/c

2521 

2522 

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

2530 

2531 

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

2540 

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 ) 

2545 

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

2548 

2549 with changing_dir(tmp_path): 1opqrstu

2550 result = runner.invoke(app, ["deploy"]) 1opqrstu

2551 

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

2558 

2559 

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

2568 

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 ) 

2573 

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

2579 

2580 with changing_dir(tmp_path): 1hijklmn

2581 result = runner.invoke(app, ["deploy"]) 1hijklmn

2582 

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

2590 

2591 

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

2600 

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 ) 

2605 

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

2609 

2610 with changing_dir(tmp_path): 1JKLMNOP

2611 result = runner.invoke(app, ["deploy"]) 1JKLMNOP

2612 

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

2616 

2617 

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

2626 

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 ) 

2631 

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

2634 

2635 with changing_dir(tmp_path): 1SUWY024

2636 result = runner.invoke(app, ["deploy", "--large-file-threshold", "1"]) 1SUWY024

2637 

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

2641 

2642 

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

2651 

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 ) 

2656 

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

2659 

2660 with changing_dir(tmp_path): 1TVXZ135

2661 result = runner.invoke( 1TVXZ135

2662 app, ["deploy"], env={"FASTAPI_CLOUD_LARGE_FILE_THRESHOLD": "1"} 

2663 ) 

2664 

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

2668 

2669 

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

2676 

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

2679 

2680 

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 ) 

2732 

2733 with changing_dir(tmp_path): 2PcQcRcScTcUcVc

2734 result = SnapshotCliRunner().invoke(app, ["deploy"], env={"CI": str(ci)}) 2PcQcRcScTcUcVc

2735 

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

2747 

2748 

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

2789 

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

2794 

2795 result = SnapshotCliRunner().invoke(command) 20e1e2e3e4e5e6e

2796 

2797 assert result.exit_code == 1 20e1e2e3e4e5e6e

2798 assert result.output == snapshot("""\ 20e1e2e3e4e5e6e

2799🚨 Your lockfile is out of date 

2800 

2801 The lockfile does not match your project dependencies. 

2802 

2803 hint: Run `uv lock` and commit the updated lockfile. 

2804 

2805👀 Check out the logs at https://dashboard.fastapicloud.com/demo-build\ 

2806""") 

2807 

2808 

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 ) 

2818 

2819 

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

2827 

2828 with changing_dir(configured_app.path): 2yeAeCeEeGeIeKe

2829 result = runner.invoke(app, ["deploy", "--json"]) 2yeAeCeEeGeIeKe

2830 

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 } 

2844 

2845 

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 ) 

2858 

2859 with changing_dir(configured_app.path): 2zeBeDeFeHeJeLe

2860 result = runner.invoke(app, ["deploy", "--json"]) 2zeBeDeFeHeJeLe

2861 

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 ) 

2868 

2869 

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

2878 

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 ) 

2895 

2896 with changing_dir(configured_app.path): 2udvdwdxdydzdAd

2897 result = runner.invoke(app, ["deploy", "--json", "--force"]) 2udvdwdxdydzdAd

2898 

2899 assert result.exit_code == 0 2udvdwdxdydzdAd

2900 assert json.loads(result.stdout)["data"]["deployment_id"] == deployment_data["id"] 2udvdwdxdydzdAd

2901 

2902 

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

2913 

2914 with changing_dir(configured_app.path): 2fegeheiejekele

2915 result = runner.invoke(app, ["deploy", "--json"]) 2fegeheiejekele

2916 

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 } 

2926 

2927 

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

2933 

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

2936 

2937 

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

2943 

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

2946 

2947 

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

2957 

2958 result = runner.invoke(app, ["deploy"]) 2MfVfWfXfNfYfZf

2959 

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

2963 

2964 

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 ) 

2982 

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

2992 

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

2997 

2998 

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

3005 

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 ) 

3012 

3013 with changing_dir(tmp_path): 2pcrcucxcAcCcFc

3014 result = runner.invoke(app, ["deploy"]) 2pcrcucxcAcCcFc

3015 

3016 assert result.exit_code == 1 2pcrcucxcAcCcFc

3017 assert "Degraded" in result.output 2pcrcucxcAcCcFc