Coverage for tests/domains/test_cli.py: 100%
315 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-09 00:25 +0000
1import json 1abcdefg
2from datetime import datetime, timezone 1abcdefg
3from typing import Any 1abcdefg
4from unittest.mock import patch 1abcdefg
6import pytest 1abcdefg
7import respx 1abcdefg
8import time_machine 1abcdefg
9from httpx import ConnectError, Response 1abcdefg
10from inline_snapshot import snapshot 1abcdefg
12from fastapi_cloud_cli.api import CustomDomainStatus 1abcdefg
13from fastapi_cloud_cli.cli import cloud_app as app 1abcdefg
14from fastapi_cloud_cli.commands.domains.rendering import DOMAIN_STATUS 1abcdefg
15from tests.conftest import ConfiguredApp 1abcdefg
16from tests.utils import Keys, SnapshotCliRunner, changing_dir 1abcdefg
18runner = SnapshotCliRunner() 1abcdefg
20APP_ID = "00000000-0000-4000-8000-000000000002" 1abcdefg
21DOMAIN_ID = "00000000-0000-4000-8000-000000000003" 1abcdefg
24def custom_domain(**overrides: Any) -> dict[str, Any]: 1abcdefg
25 return { 2jbv kbw h lb= mbnb-bobpbqbx ? y .b@ z /bA rbo B C sbD tbE i ub[ vbwb:bxbybzbF ] G ;b^ H =bI Abp J K BbL CbM j Db_ EbFb?bGbHbIbN ` O @b{ P [bQ Jbq R S KbT LbU k Mb| NbOb]bPbQbRbV } W ^b~ X _bY Sbr Z 0 Tb1 Ub2 l VbabWbXb`bYbZb0b3 bb4 {bcb5 |b6 1bs 7 8 2b9 3b! m 4bdb5b6b}b7b8b9b# eb$ ~bfb% ac' !bt ( ) #b* $b+ n %bgb'b(bbc)b*b+b, hb- ccib. dc/ ,bu : ;
26 "id": DOMAIN_ID,
27 "name": "api.example.com",
28 "status": "internal_dcv_pending",
29 "setup_in_progress": True,
30 "setup_failed": False,
31 "setup_successful": False,
32 "is_using_pre_validation": False,
33 "dns_records": [
34 {
35 "type": "CNAME",
36 "name": "api",
37 "value": f"{DOMAIN_ID}.endpoints.fastapicloud.dev.",
38 }
39 ],
40 "created_at": "2026-08-28T10:00:00Z",
41 "updated_at": "2026-08-28T10:01:00Z",
42 "setup_started_at": "2026-08-28T10:00:00Z",
43 "setup_checked_at": "2026-08-28T10:01:00Z",
44 "app_id": APP_ID,
45 **overrides,
46 }
49@pytest.mark.parametrize( 1abcdefg
50 "command",
51 [
52 ["list"],
53 ["get", "api.example.com"],
54 ["add", "api.example.com", "--standard"],
55 ["remove", "api.example.com", "--yes"],
56 ["restart", "api.example.com"],
57 ],
58 ids=["list", "get", "add", "remove", "restart"],
59)
60def test_domains_commands_require_user_session( 1abcdefg
61 command: list[str],
62 logged_out_cli: None,
63) -> None:
64 result = runner.invoke( 28c9c!c#c$c%c'c
65 app,
66 ["domains", *command, "--app-id", APP_ID, "--json"],
67 )
69 assert result.exit_code == 1 28c9c!c#c$c%c'c
70 assert json.loads(result.stdout) == { 28c9c!c#c$c%c'c
71 "error": {
72 "code": "not_logged_in",
73 "message": "No credentials found.",
74 "hint": "Run `fastapi cloud login`.",
75 }
76 }
79def test_domains_list_requires_app_context(logged_in_cli: None) -> None: 1abcdefg
80 result = runner.invoke(app, ["domains", "list", "--json"]) 2(c)c*c+c,c-c.c
82 assert result.exit_code == 1 2(c)c*c+c,c-c.c
83 assert json.loads(result.stdout) == { 2(c)c*c+c,c-c.c
84 "error": {
85 "code": "missing_required_input",
86 "message": "App ID is required.",
87 "hint": "Pass --app-id or run `fastapi cloud apps create --link` first.",
88 }
89 }
92@pytest.mark.respx 1abcdefg
93@time_machine.travel(datetime(2026, 9, 1, 10, 1, tzinfo=timezone.utc), tick=False) 1abcdefg
94def test_domains_list_uses_linked_app_and_renders_rows( 1abcdefg
95 logged_in_cli: None,
96 respx_mock: respx.MockRouter,
97 configured_app: ConfiguredApp,
98) -> None:
99 newest = custom_domain( 1xFNV3#,
100 id="00000000-0000-4000-8000-000000000004",
101 name="new.example.com",
102 status="origin_setup_success",
103 setup_in_progress=False,
104 setup_successful=True,
105 is_using_pre_validation=True,
106 app_id=configured_app.app_id,
107 )
108 oldest = custom_domain(name="old.example.com", app_id=configured_app.app_id) 1xFNV3#,
109 respx_mock.get(f"/apps/{configured_app.app_id}/custom-domains").mock( 1xFNV3#,
110 return_value=Response(200, json={"data": [newest, oldest], "count": 2})
111 )
113 with changing_dir(configured_app.path): 1xFNV3#,
114 result = runner.invoke(app, ["domains", "list"]) 1xFNV3#,
116 assert result.exit_code == 0 1xFNV3#,
117 assert result.output == snapshot("""\ 1xFNV3#,
118custom domains
120Domain Status Setup mode Last check
122new.example.com Live Zero-downtime 4 days ago
123old.example.com Pending Standard 4 days ago\
124""")
127@pytest.mark.respx 1abcdefg
128def test_domains_list_returns_json( 1abcdefg
129 logged_in_cli: None,
130 respx_mock: respx.MockRouter,
131) -> None:
132 domain = custom_domain() 2qbzbIbRb0b9b+b
133 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2qbzbIbRb0b9b+b
134 return_value=Response(200, json={"data": [domain], "count": 1})
135 )
137 result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) 2qbzbIbRb0b9b+b
139 assert result.exit_code == 0 2qbzbIbRb0b9b+b
140 assert json.loads(result.stdout) == { 2qbzbIbRb0b9b+b
141 "data": {
142 "app_id": APP_ID,
143 "domains": [domain],
144 "total_count": 1,
145 }
146 }
149@pytest.mark.respx 1abcdefg
150def test_domains_list_renders_empty_state( 1abcdefg
151 logged_in_cli: None,
152 respx_mock: respx.MockRouter,
153) -> None:
154 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2sctcucvcwcxcyc
155 return_value=Response(200, json={"data": [], "count": 0})
156 )
158 result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID]) 2sctcucvcwcxcyc
160 assert result.exit_code == 0 2sctcucvcwcxcyc
161 assert result.output == snapshot("""\ 2sctcucvcwcxcyc
162custom domains
164No custom domains found.\
165""")
168@pytest.mark.respx 1abcdefg
169def test_domains_list_surfaces_app_not_found_as_json( 1abcdefg
170 logged_in_cli: None,
171 respx_mock: respx.MockRouter,
172) -> None:
173 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2zcAcBcCcDcEcFc
174 return_value=Response(404, json={"detail": "App not found"})
175 )
177 result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) 2zcAcBcCcDcEcFc
179 assert result.exit_code == 1 2zcAcBcCcDcEcFc
180 assert json.loads(result.stdout) == { 2zcAcBcCcDcEcFc
181 "error": {
182 "code": "not_found",
183 "message": "App not found",
184 "hint": None,
185 }
186 }
189def test_domain_status_metadata_is_exhaustive() -> None: 1abcdefg
190 assert set(DOMAIN_STATUS) == set(CustomDomainStatus) 2IdJdKdLdMdNdOd
193@pytest.mark.respx 1abcdefg
194def test_domains_get_renders_restart_action_when_setup_failed( 1abcdefg
195 logged_in_cli: None,
196 respx_mock: respx.MockRouter,
197) -> None:
198 domain = custom_domain( 2nbwbFbObXb6b(b
199 status="internal_dcv_timeout",
200 setup_in_progress=False,
201 setup_failed=True,
202 )
203 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2nbwbFbObXb6b(b
204 return_value=Response(200, json={"data": [domain], "count": 1})
205 )
207 result = runner.invoke( 2nbwbFbObXb6b(b
208 app,
209 ["domains", "get", domain["name"], "--app-id", APP_ID],
210 )
212 assert result.exit_code == 0 2nbwbFbObXb6b(b
213 assert result.output == snapshot("""\ 2nbwbFbObXb6b(b
214custom domains
216🌐 api.example.com
218⚠️ Restart domain verification
219 We couldn't verify the domain in time, it's possible the DNS changes may
220 still be propagating. Please restart the domain verification process.
222 To verify ownership and route traffic, add this CNAME record at your DNS
223 provider:
225 type CNAME
226 name api
227 value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev.
229💡 Copy as shown; remove the trailing dot only if your provider rejects it.
230 Cloudflare: use DNS only (gray cloud).
232 Correct the DNS records, then run `fastapi cloud domains restart
233 api.example.com`.\
234""")
237@pytest.mark.respx 1abcdefg
238def test_domains_get_renders_correction_action_when_dns_is_invalid( 1abcdefg
239 logged_in_cli: None,
240 respx_mock: respx.MockRouter,
241) -> None:
242 domain = custom_domain(status="internal_dcv_invalid") 2mbvbEbNbWb5b'b
243 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2mbvbEbNbWb5b'b
244 return_value=Response(200, json={"data": [domain], "count": 1})
245 )
247 result = runner.invoke( 2mbvbEbNbWb5b'b
248 app,
249 ["domains", "get", domain["name"], "--app-id", APP_ID],
250 )
252 assert result.exit_code == 0 2mbvbEbNbWb5b'b
253 assert result.output == snapshot("""\ 2mbvbEbNbWb5b'b
254custom domains
256🌐 api.example.com
258⚠️ Verification needed
259 The DNS records were found but don't match the expected values. Double-check
260 your provider settings.
262 To verify ownership and route traffic, add this CNAME record at your DNS
263 provider:
265 type CNAME
266 name api
267 value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev.
269💡 Copy as shown; remove the trailing dot only if your provider rejects it.
270 Cloudflare: use DNS only (gray cloud).
272 Correct the DNS records shown. We'll check again automatically.\
273""")
276@pytest.mark.respx 1abcdefg
277def test_domains_get_omits_next_action_while_setup_is_in_progress( 1abcdefg
278 logged_in_cli: None,
279 respx_mock: respx.MockRouter,
280) -> None:
281 domain = custom_domain(dns_records=[]) 2lbubDbMbVb4b%b
282 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2lbubDbMbVb4b%b
283 return_value=Response(200, json={"data": [domain], "count": 1})
284 )
286 result = runner.invoke( 2lbubDbMbVb4b%b
287 app,
288 ["domains", "get", domain["name"], "--app-id", APP_ID],
289 )
291 assert result.exit_code == 0 2lbubDbMbVb4b%b
292 assert result.output == snapshot("""\ 2lbubDbMbVb4b%b
293custom domains
295🌐 api.example.com
297⏳ Waiting domain verification
298 We are checking your DNS configuration to confirm domain ownership. This
299 usually takes a few minutes.
301 Add the record below. We'll verify ownership, issue your TLS certificate,
302 and route traffic automatically.\
303""")
306def test_domains_get_json_requires_domain(logged_in_cli: None) -> None: 1abcdefg
307 result = runner.invoke( 2/c:c;c=c?c@c[c
308 app,
309 ["domains", "get", "--app-id", APP_ID, "--json"],
310 )
312 assert result.exit_code == 1 2/c:c;c=c?c@c[c
313 assert json.loads(result.stdout) == { 2/c:c;c=c?c@c[c
314 "error": {
315 "code": "missing_required_input",
316 "message": "Custom domain is required.",
317 "hint": "Pass DOMAIN to choose a custom domain.",
318 }
319 }
322@pytest.mark.respx 1abcdefg
323@pytest.mark.parametrize("identifier", [" API.Example.COM. ", DOMAIN_ID]) 1abcdefg
324def test_domains_get_resolves_normalized_hostname_or_id_as_json( 1abcdefg
325 identifier: str,
326 logged_in_cli: None,
327 respx_mock: respx.MockRouter,
328) -> None:
329 domain = custom_domain() 2obxbGbPbYb7b)b
330 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2obxbGbPbYb7b)b
331 return_value=Response(200, json={"data": [domain], "count": 1})
332 )
334 result = runner.invoke( 2obxbGbPbYb7b)b
335 app,
336 ["domains", "get", identifier, "--app-id", APP_ID, "--json"],
337 )
339 assert result.exit_code == 0 2obxbGbPbYb7b)b
340 assert json.loads(result.stdout) == {"data": {"app_id": APP_ID, "domain": domain}} 2obxbGbPbYb7b)b
343@pytest.mark.respx 1abcdefg
344@time_machine.travel(datetime(2026, 9, 1, 10, 1, tzinfo=timezone.utc), tick=False) 1abcdefg
345def test_domains_get_prompts_with_selector_and_renders_details( 1abcdefg
346 logged_in_cli: None,
347 respx_mock: respx.MockRouter,
348) -> None:
349 domain = custom_domain() 2= [ _ | abdbgb
350 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2= [ _ | abdbgb
351 return_value=Response(200, json={"data": [domain], "count": 1})
352 )
354 with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): 2= [ _ | abdbgb
355 result = runner.invoke(app, ["domains", "get", "--app-id", APP_ID]) 2= [ _ | abdbgb
357 assert result.exit_code == 0 2= [ _ | abdbgb
358 assert result.output == snapshot("""\ 2= [ _ | abdbgb
359custom domains
361 Select the custom domain to get:
362 ● api.example.com
364 Select the custom domain to get: api.example.com
366🌐 api.example.com
368⏳ Waiting domain verification
369 We are checking your DNS configuration to confirm domain ownership. This
370 usually takes a few minutes.
372 To verify ownership and route traffic, add this CNAME record at your DNS
373 provider:
375 type CNAME
376 name api
377 value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev.
379💡 Copy as shown; remove the trailing dot only if your provider rejects it.
380 Cloudflare: use DNS only (gray cloud).\
381""")
384@pytest.mark.respx 1abcdefg
385def test_domains_get_reports_not_found( 1abcdefg
386 logged_in_cli: None,
387 respx_mock: respx.MockRouter,
388) -> None:
389 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2-b:b?b]b`b}bbc
390 return_value=Response(200, json={"data": [custom_domain()], "count": 1})
391 )
393 result = runner.invoke( 2-b:b?b]b`b}bbc
394 app,
395 ["domains", "get", "missing.example.com", "--app-id", APP_ID, "--json"],
396 )
398 assert result.exit_code == 1 2-b:b?b]b`b}bbc
399 assert json.loads(result.stdout) == { 2-b:b?b]b`b}bbc
400 "error": {
401 "code": "not_found",
402 "message": "Custom domain missing.example.com not found.",
403 "hint": (
404 "Run `fastapi cloud domains list` to see available custom domains."
405 ),
406 }
407 }
410@pytest.mark.respx 1abcdefg
411def test_domains_get_selector_handles_empty_collection( 1abcdefg
412 logged_in_cli: None,
413 respx_mock: respx.MockRouter,
414) -> None:
415 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2GcHcIcJcKcLcMc
416 return_value=Response(200, json={"data": [], "count": 0})
417 )
419 result = runner.invoke(app, ["domains", "get", "--app-id", APP_ID]) 2GcHcIcJcKcLcMc
421 assert result.exit_code == 0 2GcHcIcJcKcLcMc
422 assert result.output == snapshot("""\ 2GcHcIcJcKcLcMc
423custom domains
425No custom domains found.\
426""")
429PREVALIDATION_RECORDS = [ 1abcdefg
430 {"type": "TXT", "name": "_fc-dcv.api", "value": "ownership-value"},
431 {"type": "TXT", "name": None, "value": None},
432 {
433 "type": "CNAME",
434 "name": "_acme-challenge.api",
435 "value": "certificate-value.",
436 },
437 {"type": "CNAME", "name": "api", "value": "traffic-value."},
438]
441@pytest.mark.respx 1abcdefg
442@pytest.mark.parametrize( 1abcdefg
443 ("status", "shows_ownership", "shows_certificate", "shows_traffic", "done_steps"),
444 [
445 ("internal_dcv_pending", True, False, False, 0),
446 ("external_dcv_pending", False, True, False, 1),
447 ("origin_setup_pending", False, False, True, 2),
448 ],
449)
450def test_domains_get_gates_zero_downtime_records_by_phase( 1abcdefg
451 status: str,
452 shows_ownership: bool,
453 shows_certificate: bool,
454 shows_traffic: bool,
455 done_steps: int,
456 logged_in_cli: None,
457 respx_mock: respx.MockRouter,
458) -> None:
459 domain = custom_domain( 1hijklmn
460 status=status,
461 is_using_pre_validation=True,
462 dns_records=PREVALIDATION_RECORDS,
463 )
464 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1hijklmn
465 return_value=Response(200, json={"data": [domain], "count": 1})
466 )
468 result = runner.invoke( 1hijklmn
469 app,
470 ["domains", "get", "api.example.com", "--app-id", APP_ID],
471 )
473 assert result.exit_code == 0 1hijklmn
474 assert ("ownership-value" in result.output) is shows_ownership 1hijklmn
475 assert ("certificate-value" in result.output) is shows_certificate 1hijklmn
476 assert ("Generating; check again shortly" in result.output) is shows_certificate 1hijklmn
477 assert ("Type" in result.output) is shows_certificate 1hijklmn
478 assert ("traffic-value" in result.output) is shows_traffic 1hijklmn
479 assert result.output.count("✅") == done_steps 1hijklmn
482@pytest.mark.respx 1abcdefg
483@time_machine.travel(datetime(2026, 9, 1, 10, 1, tzinfo=timezone.utc), tick=False) 1abcdefg
484def test_domains_get_shows_url_and_no_action_when_live( 1abcdefg
485 logged_in_cli: None,
486 respx_mock: respx.MockRouter,
487) -> None:
488 domain = custom_domain( 2pbybHbQbZb8b*b
489 status="origin_setup_success",
490 setup_in_progress=False,
491 setup_successful=True,
492 )
493 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2pbybHbQbZb8b*b
494 return_value=Response(200, json={"data": [domain], "count": 1})
495 )
497 result = runner.invoke( 2pbybHbQbZb8b*b
498 app,
499 ["domains", "get", domain["name"], "--app-id", APP_ID],
500 )
502 assert result.exit_code == 0 2pbybHbQbZb8b*b
503 assert result.output == snapshot("""\ 2pbybHbQbZb8b*b
504custom domains
506🌐 https://api.example.com
508✅ Your domain is live.\
509""")
512def test_domains_add_json_requires_domain(logged_in_cli: None) -> None: 1abcdefg
513 result = runner.invoke( 2]c^c_c`c{c|c}c
514 app,
515 ["domains", "add", "--standard", "--app-id", APP_ID, "--json"],
516 )
518 assert result.exit_code == 1 2]c^c_c`c{c|c}c
519 assert json.loads(result.stdout) == { 2]c^c_c`c{c|c}c
520 "error": {
521 "code": "missing_required_input",
522 "message": "Custom domain is required.",
523 "hint": "Pass DOMAIN to choose the hostname to add.",
524 }
525 }
528def test_domains_add_json_requires_setup_mode(logged_in_cli: None) -> None: 1abcdefg
529 result = runner.invoke( 2~cadbdcdddedfd
530 app,
531 ["domains", "add", "api.example.com", "--app-id", APP_ID, "--json"],
532 )
534 assert result.exit_code == 1 2~cadbdcdddedfd
535 assert json.loads(result.stdout) == { 2~cadbdcdddedfd
536 "error": {
537 "code": "missing_required_input",
538 "message": "Custom domain setup mode is required.",
539 "hint": "Pass either --standard or --zero-downtime.",
540 }
541 }
544def test_domains_add_rejects_both_setup_modes(logged_in_cli: None) -> None: 1abcdefg
545 result = runner.invoke( 2gdhdidjdkdldmd
546 app,
547 [
548 "domains",
549 "add",
550 "api.example.com",
551 "--standard",
552 "--zero-downtime",
553 "--app-id",
554 APP_ID,
555 "--json",
556 ],
557 )
559 assert result.exit_code == 1 2gdhdidjdkdldmd
560 assert json.loads(result.stdout) == { 2gdhdidjdkdldmd
561 "error": {
562 "code": "invalid_input",
563 "message": "Setup modes are mutually exclusive.",
564 "hint": "Pass either --standard or --zero-downtime.",
565 }
566 }
569@pytest.mark.respx 1abcdefg
570def test_domains_add_surfaces_backend_hostname_validation( 1abcdefg
571 logged_in_cli: None,
572 respx_mock: respx.MockRouter,
573) -> None:
574 message = "Domain name must have at least two labels (e.g., example.com)" 2ecfcgchcicjckc
575 respx_mock.post( 2ecfcgchcicjckc
576 f"/apps/{APP_ID}/custom-domains",
577 json={"name": "localhost", "is_using_pre_validation": False},
578 ).mock(
579 return_value=Response(
580 422,
581 json={
582 "detail": [
583 {
584 "type": "value_error",
585 "loc": ["body", "name"],
586 "msg": f"Value error, {message}",
587 "input": "localhost",
588 }
589 ]
590 },
591 )
592 )
594 result = runner.invoke( 2ecfcgchcicjckc
595 app,
596 [
597 "domains",
598 "add",
599 " LOCALHOST. ",
600 "--standard",
601 "--app-id",
602 APP_ID,
603 "--json",
604 ],
605 )
607 assert result.exit_code == 1 2ecfcgchcicjckc
608 assert json.loads(result.stdout) == { 2ecfcgchcicjckc
609 "error": {
610 "code": "invalid_input",
611 "message": message,
612 "hint": None,
613 }
614 }
617@pytest.mark.respx 1abcdefg
618@pytest.mark.parametrize( 1abcdefg
619 ("flag", "is_using_pre_validation"),
620 [("--standard", False), ("--zero-downtime", True)],
621)
622def test_domains_add_posts_setup_mode_and_returns_json( 1abcdefg
623 flag: str,
624 is_using_pre_validation: bool,
625 logged_in_cli: None,
626 respx_mock: respx.MockRouter,
627) -> None:
628 domain = custom_domain( 2jbsbBbKbTb2b#b
629 name="api.example.com",
630 is_using_pre_validation=is_using_pre_validation,
631 )
632 respx_mock.post( 2jbsbBbKbTb2b#b
633 f"/apps/{APP_ID}/custom-domains",
634 json={
635 "name": "api.example.com",
636 "is_using_pre_validation": is_using_pre_validation,
637 },
638 ).mock(return_value=Response(201, json=domain))
640 result = runner.invoke( 2jbsbBbKbTb2b#b
641 app,
642 [
643 "domains",
644 "add",
645 " API.Example.COM. ",
646 flag,
647 "--app-id",
648 APP_ID,
649 "--json",
650 ],
651 )
653 assert result.exit_code == 0 2jbsbBbKbTb2b#b
654 assert json.loads(result.stdout) == {"data": {"app_id": APP_ID, "domain": domain}} 2jbsbBbKbTb2b#b
657@pytest.mark.respx 1abcdefg
658def test_domains_add_with_arguments_renders_result( 1abcdefg
659 logged_in_cli: None,
660 respx_mock: respx.MockRouter,
661) -> None:
662 domain = custom_domain() 2kbtbCbLbUb3b$b
663 respx_mock.post( 2kbtbCbLbUb3b$b
664 f"/apps/{APP_ID}/custom-domains",
665 json={"name": "api.example.com", "is_using_pre_validation": False},
666 ).mock(return_value=Response(201, json=domain))
668 result = runner.invoke( 2kbtbCbLbUb3b$b
669 app,
670 [
671 "domains",
672 "add",
673 "api.example.com",
674 "--standard",
675 "--app-id",
676 APP_ID,
677 ],
678 )
680 assert result.exit_code == 0 2kbtbCbLbUb3b$b
681 assert result.output == snapshot("""\ 2kbtbCbLbUb3b$b
682custom domains
684🐔 Added api.example.com
686 To verify ownership and route traffic, add this CNAME record at your DNS
687 provider:
689 type CNAME
690 name api
691 value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev.
693💡 Copy as shown; remove the trailing dot only if your provider rejects it.
694 Cloudflare: use DNS only (gray cloud).\
695""")
698@pytest.mark.respx 1abcdefg
699def test_domains_add_standard_wizard( 1abcdefg
700 logged_in_cli: None,
701 respx_mock: respx.MockRouter,
702) -> None:
703 domain = custom_domain() 1vDLT19*
704 respx_mock.post( 1vDLT19*
705 f"/apps/{APP_ID}/custom-domains",
706 json={"name": "api.example.com", "is_using_pre_validation": False},
707 ).mock(return_value=Response(201, json=domain))
708 keys = ["api.example.com", Keys.ENTER, Keys.ENTER] 1vDLT19*
710 with patch("rich_toolkit.container.getchar", side_effect=keys): 1vDLT19*
711 result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) 1vDLT19*
713 assert result.exit_code == 0 1vDLT19*
714 assert result.output == snapshot("""\ 1vDLT19*
715custom domains
717🌐 What domain do you want to add?
720🌐 What domain do you want to add?
721 api.example.com
723🌐 What domain do you want to add? api.example.com
725 Is api.example.com already serving traffic?
727 ● No — set up a new or unused domain
728 ○ Yes — migrate it without downtime
730 No — set up a new or unused domain
732🐔 Added api.example.com
734 To verify ownership and route traffic, add this CNAME record at your DNS
735 provider:
737 type CNAME
738 name api
739 value 00000000-0000-4000-8000-000000000003.endpoints.fastapicloud.dev.
741💡 Copy as shown; remove the trailing dot only if your provider rejects it.
742 Cloudflare: use DNS only (gray cloud).\
743""")
746@pytest.mark.respx 1abcdefg
747def test_domains_add_zero_downtime_wizard_hides_traffic_records( 1abcdefg
748 logged_in_cli: None,
749 respx_mock: respx.MockRouter,
750) -> None:
751 domain = custom_domain( 1wEMU2!+
752 is_using_pre_validation=True,
753 dns_records=PREVALIDATION_RECORDS,
754 )
755 respx_mock.post( 1wEMU2!+
756 f"/apps/{APP_ID}/custom-domains",
757 json={"name": "api.example.com", "is_using_pre_validation": True},
758 ).mock(return_value=Response(201, json=domain))
759 keys = ["api.example.com", Keys.ENTER, Keys.DOWN_ARROW, Keys.ENTER] 1wEMU2!+
761 with patch("rich_toolkit.container.getchar", side_effect=keys): 1wEMU2!+
762 result = runner.invoke(app, ["domains", "add", "--app-id", APP_ID]) 1wEMU2!+
764 assert result.exit_code == 0 1wEMU2!+
765 assert result.output == snapshot("""\ 1wEMU2!+
766custom domains
768🌐 What domain do you want to add?
771🌐 What domain do you want to add?
772 api.example.com
774🌐 What domain do you want to add? api.example.com
776 Is api.example.com already serving traffic?
778 ● No — set up a new or unused domain
779 ○ Yes — migrate it without downtime
781 ○ No — set up a new or unused domain
782 ● Yes — migrate it without downtime
784 Yes — migrate it without downtime
786🐔 Added api.example.com
7881️⃣ Prove ownership
789 Add this TXT record at your DNS provider:
791 type TXT
792 name _fc-dcv.api
793 value ownership-value
7952️⃣ Secure your domain
7973️⃣ Switch traffic\
798""")
801@pytest.mark.respx 1abcdefg
802def test_domains_add_surfaces_duplicate_as_invalid_input( 1abcdefg
803 logged_in_cli: None,
804 respx_mock: respx.MockRouter,
805) -> None:
806 respx_mock.post( 2NcOcPcQcRcScTc
807 f"/apps/{APP_ID}/custom-domains",
808 json={"name": "api.example.com", "is_using_pre_validation": False},
809 ).mock(
810 return_value=Response(
811 400,
812 json={
813 "detail": "A custom domain with this name already exists for the app"
814 },
815 )
816 )
818 result = runner.invoke( 2NcOcPcQcRcScTc
819 app,
820 [
821 "domains",
822 "add",
823 "api.example.com",
824 "--standard",
825 "--app-id",
826 APP_ID,
827 "--json",
828 ],
829 )
831 assert result.exit_code == 1 2NcOcPcQcRcScTc
832 assert json.loads(result.stdout) == { 2NcOcPcQcRcScTc
833 "error": {
834 "code": "invalid_input",
835 "message": "A custom domain with this name already exists for the app",
836 "hint": None,
837 }
838 }
841@pytest.mark.respx 1abcdefg
842def test_domains_add_surfaces_entitlement_limit( 1abcdefg
843 logged_in_cli: None,
844 respx_mock: respx.MockRouter,
845) -> None:
846 message = "Custom domain limit reached (1). Upgrade your plan to add more domains." 2lcmcncocpcqcrc
847 respx_mock.post( 2lcmcncocpcqcrc
848 f"/apps/{APP_ID}/custom-domains",
849 json={"name": "api.example.com", "is_using_pre_validation": False},
850 ).mock(return_value=Response(403, json={"detail": message}))
852 result = runner.invoke( 2lcmcncocpcqcrc
853 app,
854 [
855 "domains",
856 "add",
857 "api.example.com",
858 "--standard",
859 "--app-id",
860 APP_ID,
861 "--json",
862 ],
863 )
865 assert result.exit_code == 1 2lcmcncocpcqcrc
866 assert json.loads(result.stdout) == { 2lcmcncocpcqcrc
867 "error": {
868 "code": "permission_denied",
869 "message": message,
870 "hint": None,
871 }
872 }
875def test_domains_remove_json_requires_domain(logged_in_cli: None) -> None: 1abcdefg
876 result = runner.invoke( 2ndodpdqdrdsdtd
877 app,
878 ["domains", "remove", "--yes", "--app-id", APP_ID, "--json"],
879 )
881 assert result.exit_code == 1 2ndodpdqdrdsdtd
882 assert json.loads(result.stdout) == { 2ndodpdqdrdsdtd
883 "error": {
884 "code": "missing_required_input",
885 "message": "Custom domain is required.",
886 "hint": "Pass DOMAIN to choose a custom domain.",
887 }
888 }
891def test_domains_remove_json_requires_confirmation(logged_in_cli: None) -> None: 1abcdefg
892 result = runner.invoke( 2udvdwdxdydzdAd
893 app,
894 ["domains", "remove", "api.example.com", "--app-id", APP_ID, "--json"],
895 )
897 assert result.exit_code == 1 2udvdwdxdydzdAd
898 assert json.loads(result.stdout) == { 2udvdwdxdydzdAd
899 "error": {
900 "code": "missing_required_input",
901 "message": "Removal confirmation is required.",
902 "hint": "Pass --yes to confirm removal.",
903 }
904 }
907@pytest.mark.respx 1abcdefg
908def test_domains_remove_resolves_name_and_returns_json( 1abcdefg
909 logged_in_cli: None,
910 respx_mock: respx.MockRouter,
911) -> None:
912 domain = custom_domain() 2@ ^ { ~ cbfbib
913 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2@ ^ { ~ cbfbib
914 return_value=Response(200, json={"data": [domain], "count": 1})
915 )
916 respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( 2@ ^ { ~ cbfbib
917 return_value=Response(200, json={"message": "Custom domain deleted"})
918 )
920 result = runner.invoke( 2@ ^ { ~ cbfbib
921 app,
922 [
923 "domains",
924 "remove",
925 " API.Example.COM. ",
926 "--yes",
927 "--app-id",
928 APP_ID,
929 "--json",
930 ],
931 )
933 assert result.exit_code == 0 2@ ^ { ~ cbfbib
934 assert json.loads(result.stdout) == { 2@ ^ { ~ cbfbib
935 "data": {
936 "app_id": APP_ID,
937 "domain_id": DOMAIN_ID,
938 "name": "api.example.com",
939 "removed": True,
940 }
941 }
944@pytest.mark.respx 1abcdefg
945def test_domains_remove_warns_and_confirms( 1abcdefg
946 logged_in_cli: None,
947 respx_mock: respx.MockRouter,
948) -> None:
949 domain = custom_domain() 1zHPX5%.
950 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1zHPX5%.
951 return_value=Response(200, json={"data": [domain], "count": 1})
952 )
953 respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( 1zHPX5%.
954 return_value=Response(200, json={"message": "Custom domain deleted"})
955 )
957 with patch( 1zHPX5%.
958 "rich_toolkit.container.getchar", side_effect=[Keys.RIGHT_ARROW, Keys.ENTER]
959 ):
960 result = runner.invoke( 1zHPX5%.
961 app,
962 ["domains", "remove", domain["name"], "--app-id", APP_ID],
963 )
965 assert result.exit_code == 0 1zHPX5%.
966 assert result.output == snapshot("""\ 1zHPX5%.
967custom domains
969⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at
970 your provider will not be changed.
972 Remove api.example.com?
973 ○ Yes ● No
975 Remove api.example.com?
976 ● Yes ○ No
978 Remove api.example.com? Yes
980🐔 Removed api.example.com\
981""")
984@pytest.mark.respx 1abcdefg
985def test_domains_remove_can_select_domain( 1abcdefg
986 logged_in_cli: None,
987 respx_mock: respx.MockRouter,
988) -> None:
989 domain = custom_domain() 1yGOW4$-
990 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1yGOW4$-
991 return_value=Response(200, json={"data": [domain], "count": 1})
992 )
993 respx_mock.delete(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}").mock( 1yGOW4$-
994 return_value=Response(200, json={"message": "Custom domain deleted"})
995 )
997 with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): 1yGOW4$-
998 result = runner.invoke( 1yGOW4$-
999 app,
1000 ["domains", "remove", "--yes", "--app-id", APP_ID],
1001 )
1003 assert result.exit_code == 0 1yGOW4$-
1004 assert result.output == snapshot("""\ 1yGOW4$-
1005custom domains
1007 Select the custom domain to remove:
1008 ● api.example.com
1010 Select the custom domain to remove: api.example.com
1012⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at
1013 your provider will not be changed.
1015🐔 Removed api.example.com\
1016""")
1019@pytest.mark.respx 1abcdefg
1020def test_domains_remove_can_be_cancelled( 1abcdefg
1021 logged_in_cli: None,
1022 respx_mock: respx.MockRouter,
1023) -> None:
1024 domain = custom_domain() 2? ] ` } bbebhb
1025 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2? ] ` } bbebhb
1026 return_value=Response(200, json={"data": [domain], "count": 1})
1027 )
1029 with patch( 2? ] ` } bbebhb
1030 "rich_toolkit.container.getchar",
1031 side_effect=[Keys.ENTER],
1032 ):
1033 result = runner.invoke( 2? ] ` } bbebhb
1034 app,
1035 ["domains", "remove", domain["name"], "--app-id", APP_ID],
1036 )
1038 assert result.exit_code == 0 2? ] ` } bbebhb
1039 assert result.output == snapshot("""\ 2? ] ` } bbebhb
1040custom domains
1042⚠️ FastAPI Cloud resources for api.example.com will be removed. DNS records at
1043 your provider will not be changed.
1045 Remove api.example.com?
1046 ○ Yes ● No
1048 Remove api.example.com? No
1050 Removal cancelled.\
1051""")
1054@pytest.mark.respx 1abcdefg
1055def test_domains_remove_reports_not_found_before_delete( 1abcdefg
1056 logged_in_cli: None,
1057 respx_mock: respx.MockRouter,
1058) -> None:
1059 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2.b;b@b^b{b~bcc
1060 return_value=Response(200, json={"data": [custom_domain()], "count": 1})
1061 )
1063 result = runner.invoke( 2.b;b@b^b{b~bcc
1064 app,
1065 [
1066 "domains",
1067 "remove",
1068 "missing.example.com",
1069 "--yes",
1070 "--app-id",
1071 APP_ID,
1072 "--json",
1073 ],
1074 )
1076 assert result.exit_code == 1 2.b;b@b^b{b~bcc
1077 assert json.loads(result.stdout)["error"] == { 2.b;b@b^b{b~bcc
1078 "code": "not_found",
1079 "message": "Custom domain missing.example.com not found.",
1080 "hint": "Run `fastapi cloud domains list` to see available custom domains.",
1081 }
1084@pytest.mark.respx 1abcdefg
1085def test_domains_remove_selector_handles_empty_collection( 1abcdefg
1086 logged_in_cli: None,
1087 respx_mock: respx.MockRouter,
1088) -> None:
1089 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2UcVcWcXcYcZc0c
1090 return_value=Response(200, json={"data": [], "count": 0})
1091 )
1093 result = runner.invoke( 2UcVcWcXcYcZc0c
1094 app,
1095 ["domains", "remove", "--yes", "--app-id", APP_ID],
1096 )
1098 assert result.exit_code == 0 2UcVcWcXcYcZc0c
1099 assert result.output == snapshot("""\ 2UcVcWcXcYcZc0c
1100custom domains
1102No custom domains found.\
1103""")
1106def test_domains_restart_json_requires_domain(logged_in_cli: None) -> None: 1abcdefg
1107 result = runner.invoke( 2BdCdDdEdFdGdHd
1108 app,
1109 ["domains", "restart", "--app-id", APP_ID, "--json"],
1110 )
1112 assert result.exit_code == 1 2BdCdDdEdFdGdHd
1113 assert json.loads(result.stdout) == { 2BdCdDdEdFdGdHd
1114 "error": {
1115 "code": "missing_required_input",
1116 "message": "Custom domain is required.",
1117 "hint": "Pass DOMAIN to choose a custom domain.",
1118 }
1119 }
1122@pytest.mark.respx 1abcdefg
1123def test_domains_restart_returns_reset_domain_as_json( 1abcdefg
1124 logged_in_cli: None,
1125 respx_mock: respx.MockRouter,
1126) -> None:
1127 failed_domain = custom_domain( 1AIQY6'/
1128 status="internal_dcv_timeout",
1129 setup_in_progress=False,
1130 setup_failed=True,
1131 )
1132 restarted_domain = custom_domain() 1AIQY6'/
1133 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1AIQY6'/
1134 return_value=Response(200, json={"data": [failed_domain], "count": 1})
1135 )
1136 respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( 1AIQY6'/
1137 return_value=Response(200, json=restarted_domain)
1138 )
1140 result = runner.invoke( 1AIQY6'/
1141 app,
1142 [
1143 "domains",
1144 "restart",
1145 " API.Example.COM. ",
1146 "--app-id",
1147 APP_ID,
1148 "--json",
1149 ],
1150 )
1152 assert result.exit_code == 0 1AIQY6'/
1153 assert json.loads(result.stdout) == { 1AIQY6'/
1154 "data": {"app_id": APP_ID, "domain": restarted_domain}
1155 }
1158@pytest.mark.respx 1abcdefg
1159def test_domains_restart_with_argument_renders_result( 1abcdefg
1160 logged_in_cli: None,
1161 respx_mock: respx.MockRouter,
1162) -> None:
1163 failed_domain = custom_domain( 1CKS08);
1164 status="internal_dcv_timeout",
1165 setup_in_progress=False,
1166 setup_failed=True,
1167 )
1168 restarted_domain = custom_domain(dns_records=[]) 1CKS08);
1169 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1CKS08);
1170 return_value=Response(200, json={"data": [failed_domain], "count": 1})
1171 )
1172 respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( 1CKS08);
1173 return_value=Response(200, json=restarted_domain)
1174 )
1176 result = runner.invoke( 1CKS08);
1177 app,
1178 ["domains", "restart", failed_domain["name"], "--app-id", APP_ID],
1179 )
1181 assert result.exit_code == 0 1CKS08);
1182 assert result.output == snapshot("""\ 1CKS08);
1183custom domains
1185🐔 Restarted verification for api.example.com
1187🌐 api.example.com
1189⏳ Waiting domain verification
1190 We are checking your DNS configuration to confirm domain ownership. This
1191 usually takes a few minutes.
1193 Add the record below. We'll verify ownership, issue your TLS certificate,
1194 and route traffic automatically.
1196 hint: Run `fastapi cloud domains get api.example.com` to check progress.\
1197""")
1200@pytest.mark.respx 1abcdefg
1201def test_domains_restart_selector_only_shows_failed_domains( 1abcdefg
1202 logged_in_cli: None,
1203 respx_mock: respx.MockRouter,
1204) -> None:
1205 live_domain = custom_domain( 1opqrstu
1206 id="00000000-0000-4000-8000-000000000004",
1207 name="live.example.com",
1208 status="origin_setup_success",
1209 setup_in_progress=False,
1210 setup_successful=True,
1211 )
1212 failed_domain = custom_domain( 1opqrstu
1213 name="failed.example.com",
1214 status="external_dcv_timeout",
1215 setup_in_progress=False,
1216 setup_failed=True,
1217 )
1218 restarted_domain = custom_domain( 1opqrstu
1219 name="failed.example.com",
1220 is_using_pre_validation=True,
1221 dns_records=PREVALIDATION_RECORDS,
1222 )
1223 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1opqrstu
1224 return_value=Response(
1225 200,
1226 json={"data": [live_domain, failed_domain], "count": 2},
1227 )
1228 )
1229 respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( 1opqrstu
1230 return_value=Response(200, json=restarted_domain)
1231 )
1233 with patch("rich_toolkit.container.getchar", side_effect=[Keys.ENTER]): 1opqrstu
1234 result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) 1opqrstu
1236 assert result.exit_code == 0 1opqrstu
1237 assert result.output == snapshot("""\ 1opqrstu
1238custom domains
1240 Select the custom domain to restart:
1241 ● failed.example.com
1243 Select the custom domain to restart: failed.example.com
1245🐔 Restarted verification for failed.example.com
1247🌐 failed.example.com
1249⏳ Waiting domain verification
1250 We are checking your DNS configuration to confirm domain ownership. This
1251 usually takes a few minutes.
12531️⃣ Prove ownership
1254 Add this TXT record at your DNS provider:
1256 type TXT
1257 name _fc-dcv.api
1258 value ownership-value
12602️⃣ Secure your domain
12623️⃣ Switch traffic
1264 hint: Run `fastapi cloud domains get failed.example.com` to check progress.\
1265""")
1268@pytest.mark.respx 1abcdefg
1269def test_domains_restart_selector_handles_no_failed_domains( 1abcdefg
1270 logged_in_cli: None,
1271 respx_mock: respx.MockRouter,
1272) -> None:
1273 live_domain = custom_domain( 2rbAbJbSb1b!b,b
1274 status="origin_setup_success",
1275 setup_in_progress=False,
1276 setup_successful=True,
1277 )
1278 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2rbAbJbSb1b!b,b
1279 return_value=Response(200, json={"data": [live_domain], "count": 1})
1280 )
1282 result = runner.invoke(app, ["domains", "restart", "--app-id", APP_ID]) 2rbAbJbSb1b!b,b
1284 assert result.exit_code == 0 2rbAbJbSb1b!b,b
1285 assert result.output == snapshot("""\ 2rbAbJbSb1b!b,b
1286custom domains
1288No failed custom domains found.\
1289""")
1292@pytest.mark.respx 1abcdefg
1293def test_domains_restart_reports_not_found( 1abcdefg
1294 logged_in_cli: None,
1295 respx_mock: respx.MockRouter,
1296) -> None:
1297 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 2/b=b[b_b|bacdc
1298 return_value=Response(200, json={"data": [custom_domain()], "count": 1})
1299 )
1301 result = runner.invoke( 2/b=b[b_b|bacdc
1302 app,
1303 [
1304 "domains",
1305 "restart",
1306 "missing.example.com",
1307 "--app-id",
1308 APP_ID,
1309 "--json",
1310 ],
1311 )
1313 assert result.exit_code == 1 2/b=b[b_b|bacdc
1314 assert json.loads(result.stdout) == { 2/b=b[b_b|bacdc
1315 "error": {
1316 "code": "not_found",
1317 "message": "Custom domain missing.example.com not found.",
1318 "hint": (
1319 "Run `fastapi cloud domains list` to see available custom domains."
1320 ),
1321 }
1322 }
1325@pytest.mark.respx 1abcdefg
1326def test_domains_restart_surfaces_backend_rejection_for_live_domain( 1abcdefg
1327 logged_in_cli: None,
1328 respx_mock: respx.MockRouter,
1329) -> None:
1330 live_domain = custom_domain( 1BJRZ7(:
1331 status="origin_setup_success",
1332 setup_in_progress=False,
1333 setup_successful=True,
1334 )
1335 message = "The custom domain setup has already been completed successfully" 1BJRZ7(:
1336 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 1BJRZ7(:
1337 return_value=Response(200, json={"data": [live_domain], "count": 1})
1338 )
1339 respx_mock.post(f"/apps/{APP_ID}/custom-domains/{DOMAIN_ID}/restart-setup").mock( 1BJRZ7(:
1340 return_value=Response(400, json={"detail": message})
1341 )
1343 result = runner.invoke( 1BJRZ7(:
1344 app,
1345 [
1346 "domains",
1347 "restart",
1348 live_domain["name"],
1349 "--app-id",
1350 APP_ID,
1351 "--json",
1352 ],
1353 )
1355 assert result.exit_code == 1 1BJRZ7(:
1356 assert json.loads(result.stdout) == { 1BJRZ7(:
1357 "error": {
1358 "code": "invalid_input",
1359 "message": message,
1360 "hint": None,
1361 }
1362 }
1365@pytest.mark.respx 1abcdefg
1366def test_domains_list_surfaces_network_errors( 1abcdefg
1367 logged_in_cli: None,
1368 respx_mock: respx.MockRouter,
1369) -> None:
1370 respx_mock.get(f"/apps/{APP_ID}/custom-domains").mock( 21c2c3c4c5c6c7c
1371 side_effect=ConnectError("Connection failed")
1372 )
1374 result = runner.invoke(app, ["domains", "list", "--app-id", APP_ID, "--json"]) 21c2c3c4c5c6c7c
1376 assert result.exit_code == 1 21c2c3c4c5c6c7c
1377 assert json.loads(result.stdout) == { 21c2c3c4c5c6c7c
1378 "error": {
1379 "code": "network_error",
1380 "message": "Error fetching custom domains. Please try again later.",
1381 "hint": None,
1382 }
1383 }