spacerouter-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/.gitignore +4 -0
  2. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/PKG-INFO +2 -2
  3. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/pyproject.toml +2 -2
  4. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/__init__.py +1 -1
  5. spacerouter_cli-0.2.0/src/spacerouter_cli/commands/billing.py +59 -0
  6. spacerouter_cli-0.2.0/src/spacerouter_cli/commands/dashboard.py +55 -0
  7. spacerouter_cli-0.2.0/src/spacerouter_cli/commands/node.py +116 -0
  8. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/request.py +21 -23
  9. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/main.py +4 -2
  10. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/output.py +0 -1
  11. spacerouter_cli-0.2.0/tests/test_billing.py +66 -0
  12. spacerouter_cli-0.2.0/tests/test_dashboard.py +84 -0
  13. spacerouter_cli-0.2.0/tests/test_integration.py +91 -0
  14. spacerouter_cli-0.2.0/tests/test_node.py +113 -0
  15. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_request.py +38 -8
  16. spacerouter_cli-0.1.0/src/spacerouter_cli/commands/node.py +0 -30
  17. spacerouter_cli-0.1.0/tests/test_integration.py +0 -98
  18. spacerouter_cli-0.1.0/tests/test_node.py +0 -70
  19. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/__init__.py +0 -0
  20. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/api_key.py +0 -0
  21. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/config_cmd.py +0 -0
  22. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/commands/status.py +0 -0
  23. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/src/spacerouter_cli/config.py +0 -0
  24. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/__init__.py +0 -0
  25. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/conftest.py +0 -0
  26. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_api_key.py +0 -0
  27. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_config_cmd.py +0 -0
  28. {spacerouter_cli-0.1.0 → spacerouter_cli-0.2.0}/tests/test_status.py +0 -0
@@ -20,6 +20,10 @@ dist/
20
20
  *.swp
21
21
  *.swo
22
22
 
23
+ # Secrets
24
+ .env
25
+ .env.*
26
+
23
27
  # OS
24
28
  .DS_Store
25
29
  Thumbs.db
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spacerouter-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: CLI for the Space Router residential proxy network — designed for AI agents
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Requires-Dist: httpx<1.0,>=0.27
8
8
  Requires-Dist: rich<14.0,>=13.0
9
- Requires-Dist: spacerouter>=0.1.0
9
+ Requires-Dist: spacerouter>=0.2.0
10
10
  Requires-Dist: typer<1.0,>=0.12
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "spacerouter-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "CLI for the Space Router residential proxy network — designed for AI agents"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  dependencies = [
12
12
  "typer>=0.12,<1.0",
13
13
  "httpx>=0.27,<1.0",
14
- "spacerouter>=0.1.0",
14
+ "spacerouter>=0.2.0",
15
15
  "rich>=13.0,<14.0",
16
16
  ]
17
17
 
@@ -1,3 +1,3 @@
1
1
  """SpaceRouter CLI — AI-agent-friendly tool for residential proxy requests."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,59 @@
1
+ """``spacerouter billing`` — billing and checkout management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from spacerouter import SpaceRouterAdmin
10
+
11
+ from spacerouter_cli.config import resolve_config
12
+ from spacerouter_cli.output import cli_error_handler, print_json
13
+
14
+ app = typer.Typer(no_args_is_help=True)
15
+
16
+ CoordinationUrlOpt = Annotated[
17
+ Optional[str],
18
+ typer.Option("--coordination-url", help="Coordination API URL."),
19
+ ]
20
+
21
+
22
+ @app.command("checkout")
23
+ @cli_error_handler
24
+ def checkout(
25
+ email: Annotated[str, typer.Option("--email", help="Email for the checkout session.")],
26
+ coordination_url: CoordinationUrlOpt = None,
27
+ ) -> None:
28
+ """Create a Stripe checkout session."""
29
+ cfg = resolve_config(coordination_api_url=coordination_url)
30
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
31
+ session = admin.create_checkout(email)
32
+ print_json(session.model_dump())
33
+
34
+
35
+ @app.command("verify")
36
+ @cli_error_handler
37
+ def verify(
38
+ token: Annotated[str, typer.Option("--token", help="Email verification token.")],
39
+ coordination_url: CoordinationUrlOpt = None,
40
+ ) -> None:
41
+ """Verify an email address."""
42
+ cfg = resolve_config(coordination_api_url=coordination_url)
43
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
44
+ admin.verify_email(token)
45
+ print_json({"ok": True})
46
+
47
+
48
+ @app.command("reissue")
49
+ @cli_error_handler
50
+ def reissue(
51
+ email: Annotated[str, typer.Option("--email", help="Account email.")],
52
+ token: Annotated[str, typer.Option("--token", help="Verification token.")],
53
+ coordination_url: CoordinationUrlOpt = None,
54
+ ) -> None:
55
+ """Reissue an API key using email verification."""
56
+ cfg = resolve_config(coordination_api_url=coordination_url)
57
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
58
+ result = admin.reissue_api_key(email=email, token=token)
59
+ print_json(result.model_dump())
@@ -0,0 +1,55 @@
1
+ """``spacerouter dashboard`` — dashboard data access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from spacerouter import SpaceRouterAdmin
10
+
11
+ from spacerouter_cli.config import resolve_config
12
+ from spacerouter_cli.output import cli_error_handler, print_json
13
+
14
+ app = typer.Typer(no_args_is_help=True)
15
+
16
+ CoordinationUrlOpt = Annotated[
17
+ Optional[str],
18
+ typer.Option("--coordination-url", help="Coordination API URL."),
19
+ ]
20
+
21
+
22
+ @app.command("transfers")
23
+ @cli_error_handler
24
+ def transfers(
25
+ identity_address: Annotated[Optional[str], typer.Option("--identity-address", help="Identity address to query.")] = None,
26
+ wallet_address: Annotated[Optional[str], typer.Option("--wallet-address", help="[Deprecated] Use --identity-address.", hidden=True)] = None,
27
+ page: Annotated[Optional[int], typer.Option("--page", help="Page number.")] = None,
28
+ page_size: Annotated[Optional[int], typer.Option("--page-size", help="Results per page.")] = None,
29
+ coordination_url: CoordinationUrlOpt = None,
30
+ ) -> None:
31
+ """Get paginated data transfer history."""
32
+ addr = identity_address or wallet_address
33
+ if not addr:
34
+ raise typer.BadParameter("--identity-address is required")
35
+ cfg = resolve_config(coordination_api_url=coordination_url)
36
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
37
+ result = admin.get_transfers(
38
+ identity_address=addr,
39
+ page=page,
40
+ page_size=page_size,
41
+ )
42
+ print_json(result.model_dump())
43
+
44
+
45
+ @app.command("credit-line")
46
+ @cli_error_handler
47
+ def credit_line(
48
+ address: Annotated[str, typer.Option("--address", help="Wallet address to check credit line for.")],
49
+ coordination_url: CoordinationUrlOpt = None,
50
+ ) -> None:
51
+ """Query credit line status for an address."""
52
+ cfg = resolve_config(coordination_api_url=coordination_url)
53
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
54
+ result = admin.get_credit_line(address)
55
+ print_json(result.model_dump())
@@ -0,0 +1,116 @@
1
+ """``spacerouter node`` — manage proxy nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from spacerouter import SpaceRouterAdmin
10
+
11
+ from spacerouter_cli.config import resolve_config
12
+ from spacerouter_cli.output import cli_error_handler, print_json
13
+
14
+ app = typer.Typer(no_args_is_help=True)
15
+
16
+ CoordinationUrlOpt = Annotated[
17
+ Optional[str],
18
+ typer.Option("--coordination-url", help="Coordination API URL."),
19
+ ]
20
+
21
+ IdentityKeyOpt = Annotated[
22
+ Optional[str],
23
+ typer.Option("--identity-key", help="Path to node identity key file. Default: ~/.spacerouter/identity.key"),
24
+ ]
25
+
26
+
27
+ def _load_identity(key_path: str | None = None) -> str:
28
+ """Load the node identity private key (auto-create if missing)."""
29
+ from spacerouter.identity import load_or_create_identity, DEFAULT_IDENTITY_PATH
30
+ path = key_path or DEFAULT_IDENTITY_PATH
31
+ private_key, address = load_or_create_identity(path)
32
+ return private_key
33
+
34
+
35
+ @app.command("register")
36
+ @cli_error_handler
37
+ def register(
38
+ endpoint_url: Annotated[str, typer.Option("--endpoint-url", help="Node endpoint URL.")],
39
+ staking_address: Annotated[str, typer.Option("--staking-address", help="Staking wallet address.")],
40
+ collection_address: Annotated[Optional[str], typer.Option("--collection-address", help="Collection wallet. Defaults to identity address.")] = None,
41
+ label: Annotated[Optional[str], typer.Option("--label", help="Human-readable node label.")] = None,
42
+ connectivity_type: Annotated[Optional[str], typer.Option("--connectivity-type", help="direct, upnp, or external_provider.")] = None,
43
+ identity_key: IdentityKeyOpt = None,
44
+ coordination_url: CoordinationUrlOpt = None,
45
+ ) -> None:
46
+ """Register a new proxy node using identity key. Creates vouching signature automatically."""
47
+ private_key = _load_identity(identity_key)
48
+ cfg = resolve_config(coordination_api_url=coordination_url)
49
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
50
+ node = admin.register_node_with_identity(
51
+ private_key=private_key,
52
+ endpoint_url=endpoint_url,
53
+ staking_address=staking_address,
54
+ collection_address=collection_address,
55
+ label=label,
56
+ connectivity_type=connectivity_type,
57
+ )
58
+ print_json(node.model_dump())
59
+
60
+
61
+ @app.command("list")
62
+ @cli_error_handler
63
+ def list_nodes(
64
+ coordination_url: CoordinationUrlOpt = None,
65
+ ) -> None:
66
+ """List all registered nodes (deprecated — use 'get' instead)."""
67
+ cfg = resolve_config(coordination_api_url=coordination_url)
68
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
69
+ nodes = admin.list_nodes()
70
+ print_json([n.model_dump() for n in nodes])
71
+
72
+
73
+ @app.command("update-status")
74
+ @cli_error_handler
75
+ def update_status(
76
+ node_id: Annotated[str, typer.Argument(help="Node ID.")],
77
+ status: Annotated[str, typer.Option("--status", help="offline or draining. To go online, use request-probe.")],
78
+ identity_key: IdentityKeyOpt = None,
79
+ coordination_url: CoordinationUrlOpt = None,
80
+ ) -> None:
81
+ """Update a node's operational status. Requires node identity key."""
82
+ private_key = _load_identity(identity_key)
83
+ cfg = resolve_config(coordination_api_url=coordination_url)
84
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
85
+ admin.update_node_status(node_id, status=status, private_key=private_key) # type: ignore[arg-type]
86
+ print_json({"ok": True})
87
+
88
+
89
+ @app.command("request-probe")
90
+ @cli_error_handler
91
+ def request_probe(
92
+ node_id: Annotated[str, typer.Argument(help="Node ID.")],
93
+ identity_key: IdentityKeyOpt = None,
94
+ coordination_url: CoordinationUrlOpt = None,
95
+ ) -> None:
96
+ """Request a health probe for an offline node. Requires node identity key."""
97
+ private_key = _load_identity(identity_key)
98
+ cfg = resolve_config(coordination_api_url=coordination_url)
99
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
100
+ admin.request_probe(node_id, private_key=private_key)
101
+ print_json({"ok": True, "message": "Probe queued. Node will go online if probe passes."})
102
+
103
+
104
+ @app.command("delete")
105
+ @cli_error_handler
106
+ def delete(
107
+ node_id: Annotated[str, typer.Argument(help="Node ID.")],
108
+ identity_key: IdentityKeyOpt = None,
109
+ coordination_url: CoordinationUrlOpt = None,
110
+ ) -> None:
111
+ """Delete a registered node. Requires node identity key."""
112
+ private_key = _load_identity(identity_key)
113
+ cfg = resolve_config(coordination_api_url=coordination_url)
114
+ with SpaceRouterAdmin(cfg.coordination_api_url) as admin:
115
+ admin.delete_node(node_id, private_key=private_key)
116
+ print_json({"ok": True})
@@ -19,8 +19,8 @@ app = typer.Typer(no_args_is_help=True)
19
19
  ApiKeyOpt = Annotated[Optional[str], typer.Option("--api-key", help="API key for proxy auth.")]
20
20
  GatewayOpt = Annotated[Optional[str], typer.Option("--gateway-url", help="Proxy gateway URL.")]
21
21
  HeaderOpt = Annotated[Optional[list[str]], typer.Option("--header", "-H", help="Custom header (Name: Value). Repeatable.")]
22
+ RegionOpt = Annotated[Optional[str], typer.Option("--region", help="2-letter country code (e.g. US, KR).")]
22
23
  IpTypeOpt = Annotated[Optional[str], typer.Option("--ip-type", help="IP type filter: residential, mobile, datacenter, business.")]
23
- RegionOpt = Annotated[Optional[str], typer.Option("--region", help="Region filter substring.")]
24
24
  TimeoutOpt = Annotated[Optional[float], typer.Option("--timeout", help="Request timeout in seconds.")]
25
25
  OutputOpt = Annotated[str, typer.Option("--output", help="Output mode: json (structured) or raw (body only).")]
26
26
  FollowOpt = Annotated[bool, typer.Option("--follow-redirects", help="Follow HTTP redirects.")]
@@ -53,8 +53,8 @@ def _do_request(
53
53
  api_key: str | None,
54
54
  gateway_url: str | None,
55
55
  header: list[str] | None,
56
- ip_type: str | None,
57
56
  region: str | None,
57
+ ip_type: str | None = None,
58
58
  timeout: float | None,
59
59
  output: str,
60
60
  follow_redirects: bool,
@@ -78,10 +78,9 @@ def _do_request(
78
78
  with SpaceRouter(
79
79
  cfg.api_key,
80
80
  gateway_url=cfg.gateway_url,
81
- ip_type=ip_type,
82
81
  region=region,
82
+ ip_type=ip_type,
83
83
  timeout=cfg.timeout,
84
- coordination_url=cfg.coordination_api_url,
85
84
  follow_redirects=follow_redirects,
86
85
  ) as client:
87
86
  resp = client.request(method, url, **kwargs)
@@ -94,7 +93,6 @@ def _do_request(
94
93
  "headers": dict(resp.headers),
95
94
  "body": _try_parse_json(resp.text),
96
95
  "spacerouter": {
97
- "node_id": resp.node_id,
98
96
  "request_id": resp.request_id,
99
97
  },
100
98
  })
@@ -110,16 +108,16 @@ def get(
110
108
  api_key: ApiKeyOpt = None,
111
109
  gateway_url: GatewayOpt = None,
112
110
  header: HeaderOpt = None,
113
- ip_type: IpTypeOpt = None,
114
111
  region: RegionOpt = None,
112
+ ip_type: IpTypeOpt = None,
115
113
  timeout: TimeoutOpt = None,
116
114
  output: OutputOpt = "json",
117
115
  follow_redirects: FollowOpt = False,
118
116
  ) -> None:
119
117
  """Send a GET request through the residential proxy."""
120
118
  _do_request("GET", url, api_key=api_key, gateway_url=gateway_url, header=header,
121
- ip_type=ip_type, region=region, timeout=timeout, output=output,
122
- follow_redirects=follow_redirects)
119
+ region=region, ip_type=ip_type, timeout=timeout,
120
+ output=output, follow_redirects=follow_redirects)
123
121
 
124
122
 
125
123
  @app.command()
@@ -130,16 +128,16 @@ def post(
130
128
  gateway_url: GatewayOpt = None,
131
129
  header: HeaderOpt = None,
132
130
  data: DataOpt = None,
133
- ip_type: IpTypeOpt = None,
134
131
  region: RegionOpt = None,
132
+ ip_type: IpTypeOpt = None,
135
133
  timeout: TimeoutOpt = None,
136
134
  output: OutputOpt = "json",
137
135
  follow_redirects: FollowOpt = False,
138
136
  ) -> None:
139
137
  """Send a POST request through the residential proxy."""
140
138
  _do_request("POST", url, api_key=api_key, gateway_url=gateway_url, header=header,
141
- ip_type=ip_type, region=region, timeout=timeout, output=output,
142
- follow_redirects=follow_redirects, data=data)
139
+ region=region, ip_type=ip_type, timeout=timeout,
140
+ output=output, follow_redirects=follow_redirects, data=data)
143
141
 
144
142
 
145
143
  @app.command()
@@ -150,16 +148,16 @@ def put(
150
148
  gateway_url: GatewayOpt = None,
151
149
  header: HeaderOpt = None,
152
150
  data: DataOpt = None,
153
- ip_type: IpTypeOpt = None,
154
151
  region: RegionOpt = None,
152
+ ip_type: IpTypeOpt = None,
155
153
  timeout: TimeoutOpt = None,
156
154
  output: OutputOpt = "json",
157
155
  follow_redirects: FollowOpt = False,
158
156
  ) -> None:
159
157
  """Send a PUT request through the residential proxy."""
160
158
  _do_request("PUT", url, api_key=api_key, gateway_url=gateway_url, header=header,
161
- ip_type=ip_type, region=region, timeout=timeout, output=output,
162
- follow_redirects=follow_redirects, data=data)
159
+ region=region, ip_type=ip_type, timeout=timeout,
160
+ output=output, follow_redirects=follow_redirects, data=data)
163
161
 
164
162
 
165
163
  @app.command()
@@ -170,16 +168,16 @@ def patch(
170
168
  gateway_url: GatewayOpt = None,
171
169
  header: HeaderOpt = None,
172
170
  data: DataOpt = None,
173
- ip_type: IpTypeOpt = None,
174
171
  region: RegionOpt = None,
172
+ ip_type: IpTypeOpt = None,
175
173
  timeout: TimeoutOpt = None,
176
174
  output: OutputOpt = "json",
177
175
  follow_redirects: FollowOpt = False,
178
176
  ) -> None:
179
177
  """Send a PATCH request through the residential proxy."""
180
178
  _do_request("PATCH", url, api_key=api_key, gateway_url=gateway_url, header=header,
181
- ip_type=ip_type, region=region, timeout=timeout, output=output,
182
- follow_redirects=follow_redirects, data=data)
179
+ region=region, ip_type=ip_type, timeout=timeout,
180
+ output=output, follow_redirects=follow_redirects, data=data)
183
181
 
184
182
 
185
183
  @app.command()
@@ -189,16 +187,16 @@ def delete(
189
187
  api_key: ApiKeyOpt = None,
190
188
  gateway_url: GatewayOpt = None,
191
189
  header: HeaderOpt = None,
192
- ip_type: IpTypeOpt = None,
193
190
  region: RegionOpt = None,
191
+ ip_type: IpTypeOpt = None,
194
192
  timeout: TimeoutOpt = None,
195
193
  output: OutputOpt = "json",
196
194
  follow_redirects: FollowOpt = False,
197
195
  ) -> None:
198
196
  """Send a DELETE request through the residential proxy."""
199
197
  _do_request("DELETE", url, api_key=api_key, gateway_url=gateway_url, header=header,
200
- ip_type=ip_type, region=region, timeout=timeout, output=output,
201
- follow_redirects=follow_redirects)
198
+ region=region, ip_type=ip_type, timeout=timeout,
199
+ output=output, follow_redirects=follow_redirects)
202
200
 
203
201
 
204
202
  @app.command()
@@ -208,13 +206,13 @@ def head(
208
206
  api_key: ApiKeyOpt = None,
209
207
  gateway_url: GatewayOpt = None,
210
208
  header: HeaderOpt = None,
211
- ip_type: IpTypeOpt = None,
212
209
  region: RegionOpt = None,
210
+ ip_type: IpTypeOpt = None,
213
211
  timeout: TimeoutOpt = None,
214
212
  output: OutputOpt = "json",
215
213
  follow_redirects: FollowOpt = False,
216
214
  ) -> None:
217
215
  """Send a HEAD request through the residential proxy."""
218
216
  _do_request("HEAD", url, api_key=api_key, gateway_url=gateway_url, header=header,
219
- ip_type=ip_type, region=region, timeout=timeout, output=output,
220
- follow_redirects=follow_redirects)
217
+ region=region, ip_type=ip_type, timeout=timeout,
218
+ output=output, follow_redirects=follow_redirects)
@@ -7,7 +7,7 @@ import json
7
7
  import typer
8
8
 
9
9
  from spacerouter_cli import __version__
10
- from spacerouter_cli.commands import api_key, config_cmd, node, request, status
10
+ from spacerouter_cli.commands import api_key, billing, config_cmd, dashboard, node, request, status
11
11
 
12
12
  app = typer.Typer(
13
13
  name="spacerouter",
@@ -18,7 +18,9 @@ app = typer.Typer(
18
18
 
19
19
  app.add_typer(request.app, name="request", help="Make proxied HTTP requests")
20
20
  app.add_typer(api_key.app, name="api-key", help="Manage API keys")
21
- app.add_typer(node.app, name="node", help="View node information")
21
+ app.add_typer(node.app, name="node", help="Manage proxy nodes")
22
+ app.add_typer(billing.app, name="billing", help="Billing and checkout")
23
+ app.add_typer(dashboard.app, name="dashboard", help="Dashboard data")
22
24
  app.add_typer(config_cmd.app, name="config", help="Configuration management")
23
25
  app.command(name="status", help="Check service health")(status.status)
24
26
 
@@ -70,7 +70,6 @@ def cli_error_handler(func):
70
70
  print_error(
71
71
  "upstream_error",
72
72
  str(e),
73
- node_id=e.node_id,
74
73
  status_code=e.status_code,
75
74
  request_id=e.request_id,
76
75
  )
@@ -0,0 +1,66 @@
1
+ """Tests for ``spacerouter billing`` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from spacerouter.models import BillingReissueResult, CheckoutSession
8
+
9
+ from spacerouter_cli.main import app
10
+ from tests.conftest import parse_json_output
11
+
12
+
13
+ class TestCheckout:
14
+ @patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
15
+ def test_checkout_success(self, mock_admin_cls, runner, cli_env):
16
+ mock_admin = MagicMock()
17
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
18
+ mock_admin.__exit__ = MagicMock(return_value=False)
19
+ mock_admin.create_checkout.return_value = CheckoutSession(
20
+ checkout_url="https://checkout.stripe.com/session"
21
+ )
22
+ mock_admin_cls.return_value = mock_admin
23
+
24
+ result = runner.invoke(app, [
25
+ "billing", "checkout", "--email", "user@example.com",
26
+ ])
27
+ assert result.exit_code == 0
28
+ data = parse_json_output(result.output)
29
+ assert "stripe.com" in data["checkout_url"]
30
+
31
+
32
+ class TestVerifyEmail:
33
+ @patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
34
+ def test_verify_success(self, mock_admin_cls, runner, cli_env):
35
+ mock_admin = MagicMock()
36
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
37
+ mock_admin.__exit__ = MagicMock(return_value=False)
38
+ mock_admin_cls.return_value = mock_admin
39
+
40
+ result = runner.invoke(app, [
41
+ "billing", "verify", "--token", "tok-123",
42
+ ])
43
+ assert result.exit_code == 0
44
+ data = parse_json_output(result.output)
45
+ assert data["ok"] is True
46
+
47
+
48
+ class TestReissue:
49
+ @patch("spacerouter_cli.commands.billing.SpaceRouterAdmin")
50
+ def test_reissue_success(self, mock_admin_cls, runner, cli_env):
51
+ mock_admin = MagicMock()
52
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
53
+ mock_admin.__exit__ = MagicMock(return_value=False)
54
+ mock_admin.reissue_api_key.return_value = BillingReissueResult(
55
+ new_api_key="sr_live_new_key"
56
+ )
57
+ mock_admin_cls.return_value = mock_admin
58
+
59
+ result = runner.invoke(app, [
60
+ "billing", "reissue",
61
+ "--email", "user@example.com",
62
+ "--token", "tok-456",
63
+ ])
64
+ assert result.exit_code == 0
65
+ data = parse_json_output(result.output)
66
+ assert data["new_api_key"] == "sr_live_new_key"
@@ -0,0 +1,84 @@
1
+ """Tests for ``spacerouter dashboard`` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from spacerouter.models import CreditLineStatus, Transfer, TransferPage
8
+
9
+ from spacerouter_cli.main import app
10
+ from tests.conftest import parse_json_output
11
+
12
+
13
+ class TestTransfers:
14
+ @patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
15
+ def test_transfers_with_identity_address(self, mock_admin_cls, runner, cli_env):
16
+ mock_admin = MagicMock()
17
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
18
+ mock_admin.__exit__ = MagicMock(return_value=False)
19
+ mock_admin.get_transfers.return_value = TransferPage(
20
+ page=1,
21
+ total_pages=3,
22
+ total_bytes=2048,
23
+ transfers=[
24
+ Transfer(
25
+ request_id="req-1",
26
+ bytes=512,
27
+ method="GET",
28
+ target_host="example.com",
29
+ created_at="2025-01-01T00:00:00Z",
30
+ ),
31
+ ],
32
+ )
33
+ mock_admin_cls.return_value = mock_admin
34
+
35
+ result = runner.invoke(app, [
36
+ "dashboard", "transfers",
37
+ "--identity-address", "0xabc",
38
+ "--page", "1",
39
+ "--page-size", "10",
40
+ ])
41
+ assert result.exit_code == 0
42
+ data = parse_json_output(result.output)
43
+ assert data["total_pages"] == 3
44
+ assert len(data["transfers"]) == 1
45
+
46
+ @patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
47
+ def test_transfers_legacy_wallet_address(self, mock_admin_cls, runner, cli_env):
48
+ mock_admin = MagicMock()
49
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
50
+ mock_admin.__exit__ = MagicMock(return_value=False)
51
+ mock_admin.get_transfers.return_value = TransferPage(
52
+ page=1, total_pages=1, total_bytes=0, transfers=[],
53
+ )
54
+ mock_admin_cls.return_value = mock_admin
55
+
56
+ result = runner.invoke(app, [
57
+ "dashboard", "transfers", "--wallet-address", "0xabc",
58
+ ])
59
+ assert result.exit_code == 0
60
+
61
+
62
+ class TestCreditLine:
63
+ @patch("spacerouter_cli.commands.dashboard.SpaceRouterAdmin")
64
+ def test_credit_line(self, mock_admin_cls, runner, cli_env):
65
+ mock_admin = MagicMock()
66
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
67
+ mock_admin.__exit__ = MagicMock(return_value=False)
68
+ mock_admin.get_credit_line.return_value = CreditLineStatus(
69
+ address="0xabc",
70
+ credit_limit=1000.0,
71
+ used=250.0,
72
+ available=750.0,
73
+ status="active",
74
+ foundation_managed=True,
75
+ )
76
+ mock_admin_cls.return_value = mock_admin
77
+
78
+ result = runner.invoke(app, [
79
+ "dashboard", "credit-line", "--address", "0xabc",
80
+ ])
81
+ assert result.exit_code == 0
82
+ data = parse_json_output(result.output)
83
+ assert data["available"] == 750.0
84
+ assert data["foundation_managed"] is True
@@ -0,0 +1,91 @@
1
+ """Integration tests for the SpaceRouter CLI.
2
+
3
+ These tests hit the **live** Coordination API and proxy gateway at
4
+ ``gateway.spacerouter.org``. They require the ``SR_API_KEY`` environment
5
+ variable to be set to a billing-provisioned key:
6
+
7
+ SR_API_KEY=sr_live_xxx pytest tests/test_integration.py -v
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+
15
+ import pytest
16
+ from typer.testing import CliRunner
17
+
18
+ from spacerouter_cli.main import app
19
+
20
+
21
+ runner = CliRunner()
22
+
23
+ COORDINATION_URL = os.environ.get(
24
+ "SR_COORDINATION_API_URL", "https://coordination.spacerouter.org"
25
+ )
26
+ GATEWAY_URL = os.environ.get(
27
+ "SR_GATEWAY_URL", "https://gateway.spacerouter.org:8080"
28
+ )
29
+
30
+ # A billing-provisioned API key for proxy tests.
31
+ API_KEY = os.environ.get("SR_API_KEY")
32
+
33
+ pytestmark = pytest.mark.skipif(not API_KEY, reason="SR_API_KEY not set")
34
+
35
+
36
+ class TestCLIIntegration:
37
+ """End-to-end tests against the live Space Router infrastructure."""
38
+
39
+ def test_proxy_request(self):
40
+ """Proxy a GET request through the gateway with a billing-provisioned key."""
41
+ cmd = [
42
+ "request", "get", "https://httpbin.org/ip",
43
+ "--api-key", API_KEY,
44
+ "--gateway-url", GATEWAY_URL,
45
+ ]
46
+
47
+ result = runner.invoke(app, cmd)
48
+ assert result.exit_code == 0, f"request failed: {result.output}"
49
+ data = json.loads(result.output)
50
+ assert data["status_code"] == 200
51
+ assert "origin" in data["body"]
52
+
53
+ def test_api_key_crud(self):
54
+ """Create, list, and revoke an API key via CLI."""
55
+ # Create
56
+ result = runner.invoke(app, [
57
+ "api-key", "create",
58
+ "--name", "integration-crud-cli",
59
+ "--coordination-url", COORDINATION_URL,
60
+ ])
61
+ assert result.exit_code == 0
62
+ data = json.loads(result.output)
63
+ key_id = data["id"]
64
+
65
+ try:
66
+ # List
67
+ result = runner.invoke(app, [
68
+ "api-key", "list",
69
+ "--coordination-url", COORDINATION_URL,
70
+ ])
71
+ assert result.exit_code == 0
72
+ keys = json.loads(result.output)
73
+ ids = [k["id"] for k in keys]
74
+ assert key_id in ids
75
+ finally:
76
+ # Revoke
77
+ result = runner.invoke(app, [
78
+ "api-key", "revoke", key_id,
79
+ "--coordination-url", COORDINATION_URL,
80
+ ])
81
+ assert result.exit_code == 0
82
+
83
+ def test_node_list(self):
84
+ """List nodes via CLI."""
85
+ result = runner.invoke(app, [
86
+ "node", "list",
87
+ "--coordination-url", COORDINATION_URL,
88
+ ])
89
+ assert result.exit_code == 0
90
+ data = json.loads(result.output)
91
+ assert isinstance(data, list)
@@ -0,0 +1,113 @@
1
+ """Tests for ``spacerouter node`` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from spacerouter.models import Node
8
+
9
+ from spacerouter_cli.main import app
10
+ from tests.conftest import parse_json_output
11
+
12
+ _SAMPLE_NODE = Node(
13
+ id="node-1",
14
+ endpoint_url="http://192.168.1.100:9090",
15
+ public_ip="73.162.1.1",
16
+ connectivity_type="direct",
17
+ node_type="residential",
18
+ status="online",
19
+ health_score=0.95,
20
+ region="US",
21
+ label=None,
22
+ ip_type="residential",
23
+ ip_region="US",
24
+ as_type="isp",
25
+ identity_address="0xabc",
26
+ staking_address="0xdef",
27
+ collection_address="0xabc",
28
+ created_at="2025-01-01T00:00:00Z",
29
+ )
30
+
31
+
32
+ class TestRegisterNode:
33
+ @patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
34
+ @patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
35
+ def test_register(self, mock_admin_cls, mock_identity, runner, cli_env):
36
+ mock_admin = MagicMock()
37
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
38
+ mock_admin.__exit__ = MagicMock(return_value=False)
39
+ mock_admin.register_node_with_identity.return_value = _SAMPLE_NODE
40
+ mock_admin_cls.return_value = mock_admin
41
+
42
+ result = runner.invoke(app, [
43
+ "node", "register",
44
+ "--endpoint-url", "http://192.168.1.100:9090",
45
+ "--staking-address", "0xdef",
46
+ ])
47
+ assert result.exit_code == 0
48
+ data = parse_json_output(result.output)
49
+ assert data["id"] == "node-1"
50
+ assert data["identity_address"] == "0xabc"
51
+
52
+
53
+ class TestListNodes:
54
+ @patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
55
+ def test_list_success(self, mock_admin_cls, runner, cli_env):
56
+ mock_admin = MagicMock()
57
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
58
+ mock_admin.__exit__ = MagicMock(return_value=False)
59
+ mock_admin.list_nodes.return_value = [_SAMPLE_NODE]
60
+ mock_admin_cls.return_value = mock_admin
61
+
62
+ result = runner.invoke(app, ["node", "list"])
63
+ assert result.exit_code == 0
64
+ data = parse_json_output(result.output)
65
+ assert len(data) == 1
66
+ assert data[0]["id"] == "node-1"
67
+
68
+
69
+ class TestUpdateNodeStatus:
70
+ @patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
71
+ @patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
72
+ def test_update_status(self, mock_admin_cls, mock_identity, runner, cli_env):
73
+ mock_admin = MagicMock()
74
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
75
+ mock_admin.__exit__ = MagicMock(return_value=False)
76
+ mock_admin_cls.return_value = mock_admin
77
+
78
+ result = runner.invoke(app, [
79
+ "node", "update-status", "node-1", "--status", "draining",
80
+ ])
81
+ assert result.exit_code == 0
82
+ data = parse_json_output(result.output)
83
+ assert data["ok"] is True
84
+
85
+
86
+ class TestRequestProbe:
87
+ @patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
88
+ @patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
89
+ def test_request_probe(self, mock_admin_cls, mock_identity, runner, cli_env):
90
+ mock_admin = MagicMock()
91
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
92
+ mock_admin.__exit__ = MagicMock(return_value=False)
93
+ mock_admin_cls.return_value = mock_admin
94
+
95
+ result = runner.invoke(app, ["node", "request-probe", "node-1"])
96
+ assert result.exit_code == 0
97
+ data = parse_json_output(result.output)
98
+ assert data["ok"] is True
99
+
100
+
101
+ class TestDeleteNode:
102
+ @patch("spacerouter_cli.commands.node._load_identity", return_value="0x" + "ab" * 32)
103
+ @patch("spacerouter_cli.commands.node.SpaceRouterAdmin")
104
+ def test_delete(self, mock_admin_cls, mock_identity, runner, cli_env):
105
+ mock_admin = MagicMock()
106
+ mock_admin.__enter__ = MagicMock(return_value=mock_admin)
107
+ mock_admin.__exit__ = MagicMock(return_value=False)
108
+ mock_admin_cls.return_value = mock_admin
109
+
110
+ result = runner.invoke(app, ["node", "delete", "node-1"])
111
+ assert result.exit_code == 0
112
+ data = parse_json_output(result.output)
113
+ assert data["ok"] is True
@@ -13,19 +13,15 @@ def _mock_proxy_response(
13
13
  status_code: int = 200,
14
14
  text: str = '{"origin": "73.162.1.1"}',
15
15
  headers: dict | None = None,
16
- node_id: str | None = "node-1",
17
16
  request_id: str | None = "req-1",
18
17
  ):
19
18
  resp = MagicMock()
20
19
  resp.status_code = status_code
21
20
  resp.text = text
22
21
  all_headers = dict(headers or {})
23
- if node_id:
24
- all_headers["x-spacerouter-node"] = node_id
25
22
  if request_id:
26
23
  all_headers["x-spacerouter-request-id"] = request_id
27
24
  resp.headers = all_headers
28
- resp.node_id = node_id
29
25
  resp.request_id = request_id
30
26
  return resp
31
27
 
@@ -49,7 +45,6 @@ class TestGet:
49
45
  assert result.exit_code == 0
50
46
  data = parse_json_output(result.output)
51
47
  assert data["status_code"] == 200
52
- assert data["spacerouter"]["node_id"] == "node-1"
53
48
  assert data["spacerouter"]["request_id"] == "req-1"
54
49
  assert data["body"]["origin"] == "73.162.1.1"
55
50
 
@@ -168,9 +163,9 @@ class TestPost:
168
163
  assert "JSON" in data["message"]
169
164
 
170
165
 
171
- class TestIpTypeAndRegion:
166
+ class TestRegion:
172
167
  @patch("spacerouter_cli.commands.request.SpaceRouter")
173
- def test_passes_ip_type_and_region(self, mock_sr_cls, runner, cli_env):
168
+ def test_passes_region(self, mock_sr_cls, runner, cli_env):
174
169
  mock_client = MagicMock()
175
170
  mock_client.__enter__ = MagicMock(return_value=mock_client)
176
171
  mock_client.__exit__ = MagicMock(return_value=False)
@@ -179,11 +174,46 @@ class TestIpTypeAndRegion:
179
174
 
180
175
  result = runner.invoke(app, [
181
176
  "request", "get", "http://example.com",
182
- "--ip-type", "residential",
183
177
  "--region", "US",
184
178
  ])
185
179
  assert result.exit_code == 0
186
180
  mock_sr_cls.assert_called_once()
187
181
  call_kwargs = mock_sr_cls.call_args
182
+ assert call_kwargs[1]["region"] == "US"
183
+
184
+
185
+ class TestIpType:
186
+ @patch("spacerouter_cli.commands.request.SpaceRouter")
187
+ def test_passes_ip_type(self, mock_sr_cls, runner, cli_env):
188
+ mock_client = MagicMock()
189
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
190
+ mock_client.__exit__ = MagicMock(return_value=False)
191
+ mock_client.request.return_value = _mock_proxy_response()
192
+ mock_sr_cls.return_value = mock_client
193
+
194
+ result = runner.invoke(app, [
195
+ "request", "get", "http://example.com",
196
+ "--ip-type", "residential",
197
+ ])
198
+ assert result.exit_code == 0
199
+ mock_sr_cls.assert_called_once()
200
+ call_kwargs = mock_sr_cls.call_args
188
201
  assert call_kwargs[1]["ip_type"] == "residential"
202
+
203
+ @patch("spacerouter_cli.commands.request.SpaceRouter")
204
+ def test_passes_region_and_ip_type(self, mock_sr_cls, runner, cli_env):
205
+ mock_client = MagicMock()
206
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
207
+ mock_client.__exit__ = MagicMock(return_value=False)
208
+ mock_client.request.return_value = _mock_proxy_response()
209
+ mock_sr_cls.return_value = mock_client
210
+
211
+ result = runner.invoke(app, [
212
+ "request", "get", "http://example.com",
213
+ "--region", "US",
214
+ "--ip-type", "mobile",
215
+ ])
216
+ assert result.exit_code == 0
217
+ call_kwargs = mock_sr_cls.call_args
189
218
  assert call_kwargs[1]["region"] == "US"
219
+ assert call_kwargs[1]["ip_type"] == "mobile"
@@ -1,30 +0,0 @@
1
- """``spacerouter node`` — view node information."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Annotated, Optional
6
-
7
- import httpx
8
- import typer
9
-
10
- from spacerouter_cli.config import resolve_config
11
- from spacerouter_cli.output import cli_error_handler, print_json
12
-
13
- app = typer.Typer(no_args_is_help=True)
14
-
15
- CoordinationUrlOpt = Annotated[
16
- Optional[str],
17
- typer.Option("--coordination-url", help="Coordination API URL."),
18
- ]
19
-
20
-
21
- @app.command("list")
22
- @cli_error_handler
23
- def list_nodes(
24
- coordination_url: CoordinationUrlOpt = None,
25
- ) -> None:
26
- """List all registered nodes."""
27
- cfg = resolve_config(coordination_api_url=coordination_url)
28
- response = httpx.get(f"{cfg.coordination_api_url}/nodes", timeout=10.0)
29
- response.raise_for_status()
30
- print_json(response.json())
@@ -1,98 +0,0 @@
1
- """Integration tests for the SpaceRouter CLI.
2
-
3
- These tests hit the **live** Coordination API and proxy gateway at
4
- ``gateway.spacerouter.org``. They are gated behind the ``SR_INTEGRATION``
5
- environment variable so they never run in normal CI:
6
-
7
- SR_INTEGRATION=1 pytest tests/test_integration.py -v
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
- import os
14
-
15
- import pytest
16
- from typer.testing import CliRunner
17
-
18
- _RUN = os.environ.get("SR_INTEGRATION", "") == "1"
19
- pytestmark = pytest.mark.skipif(not _RUN, reason="SR_INTEGRATION not set")
20
-
21
- from spacerouter_cli.main import app # noqa: E402
22
-
23
-
24
- runner = CliRunner()
25
-
26
- COORDINATION_URL = os.environ.get(
27
- "SR_COORDINATION_API_URL", "https://coordination.spacerouter.org"
28
- )
29
- GATEWAY_URL = os.environ.get(
30
- "SR_GATEWAY_URL", "http://gateway.spacerouter.org:8080"
31
- )
32
-
33
-
34
- class TestCLIIntegration:
35
- """End-to-end: create key via CLI -> proxy request -> revoke."""
36
-
37
- def test_full_lifecycle(self):
38
- # 1. Create an ephemeral API key via the CLI.
39
- result = runner.invoke(app, [
40
- "api-key", "create",
41
- "--name", "integration-test-cli",
42
- "--coordination-url", COORDINATION_URL,
43
- ])
44
- assert result.exit_code == 0, f"create failed: {result.output}"
45
- data = json.loads(result.output)
46
- api_key = data["api_key"]
47
- key_id = data["id"]
48
- assert api_key.startswith("sr_live_")
49
-
50
- try:
51
- # 2. Proxy a GET request through the gateway.
52
- result = runner.invoke(app, [
53
- "request", "get", "https://httpbin.org/ip",
54
- "--api-key", api_key,
55
- "--gateway-url", GATEWAY_URL,
56
- ])
57
- assert result.exit_code == 0, f"request failed: {result.output}"
58
- data = json.loads(result.output)
59
- assert data["status_code"] == 200
60
- assert "origin" in data["body"]
61
-
62
- finally:
63
- # 3. Cleanup: revoke the key.
64
- result = runner.invoke(app, [
65
- "api-key", "revoke", key_id,
66
- "--coordination-url", COORDINATION_URL,
67
- ])
68
- assert result.exit_code == 0
69
-
70
- def test_api_key_crud(self):
71
- """Create, list, and revoke an API key via CLI."""
72
- # Create
73
- result = runner.invoke(app, [
74
- "api-key", "create",
75
- "--name", "integration-crud-cli",
76
- "--coordination-url", COORDINATION_URL,
77
- ])
78
- assert result.exit_code == 0
79
- data = json.loads(result.output)
80
- key_id = data["id"]
81
-
82
- try:
83
- # List
84
- result = runner.invoke(app, [
85
- "api-key", "list",
86
- "--coordination-url", COORDINATION_URL,
87
- ])
88
- assert result.exit_code == 0
89
- keys = json.loads(result.output)
90
- ids = [k["id"] for k in keys]
91
- assert key_id in ids
92
- finally:
93
- # Revoke
94
- result = runner.invoke(app, [
95
- "api-key", "revoke", key_id,
96
- "--coordination-url", COORDINATION_URL,
97
- ])
98
- assert result.exit_code == 0
@@ -1,70 +0,0 @@
1
- """Tests for ``spacerouter node`` commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from unittest.mock import patch, MagicMock
7
-
8
- import httpx
9
-
10
- from spacerouter_cli.main import app
11
- from tests.conftest import parse_json_output
12
-
13
- NODES_RESPONSE = [
14
- {
15
- "id": "node-1",
16
- "endpoint_url": "http://192.168.1.100:9090",
17
- "node_type": "residential",
18
- "status": "online",
19
- "health_score": 0.95,
20
- "region": "us-west",
21
- },
22
- {
23
- "id": "node-2",
24
- "endpoint_url": "http://10.0.0.50:9090",
25
- "node_type": "residential",
26
- "status": "offline",
27
- "health_score": 0.5,
28
- "region": "eu-west",
29
- },
30
- ]
31
-
32
-
33
- class TestListNodes:
34
- @patch("spacerouter_cli.commands.node.httpx.get")
35
- def test_list_success(self, mock_get, runner, cli_env):
36
- mock_resp = MagicMock()
37
- mock_resp.status_code = 200
38
- mock_resp.json.return_value = NODES_RESPONSE
39
- mock_resp.raise_for_status = MagicMock()
40
- mock_get.return_value = mock_resp
41
-
42
- result = runner.invoke(app, ["node", "list"])
43
- assert result.exit_code == 0
44
- data = parse_json_output(result.output)
45
- assert len(data) == 2
46
- assert data[0]["id"] == "node-1"
47
- assert data[1]["status"] == "offline"
48
-
49
- @patch("spacerouter_cli.commands.node.httpx.get")
50
- def test_list_connection_error(self, mock_get, runner, cli_env):
51
- mock_get.side_effect = httpx.ConnectError("Connection refused")
52
-
53
- result = runner.invoke(app, ["node", "list"])
54
- assert result.exit_code == 5
55
- data = parse_json_output(result.output)
56
- assert data["error"] == "connection_error"
57
-
58
- @patch("spacerouter_cli.commands.node.httpx.get")
59
- def test_custom_coordination_url(self, mock_get, runner, cli_env):
60
- mock_resp = MagicMock()
61
- mock_resp.status_code = 200
62
- mock_resp.json.return_value = []
63
- mock_resp.raise_for_status = MagicMock()
64
- mock_get.return_value = mock_resp
65
-
66
- result = runner.invoke(app, [
67
- "node", "list", "--coordination-url", "http://custom:9000"
68
- ])
69
- assert result.exit_code == 0
70
- mock_get.assert_called_once_with("http://custom:9000/nodes", timeout=10.0)