podojo-cli 0.2.2__tar.gz → 0.3.1__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 (32) hide show
  1. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/.github/workflows/publish.yml +2 -0
  2. podojo_cli-0.3.1/CHANGELOG.md +49 -0
  3. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/CLAUDE.md +10 -10
  4. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/PKG-INFO +1 -1
  5. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/pyproject.toml +1 -1
  6. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/client.py +10 -10
  7. podojo_cli-0.2.2/src/podojo_cli/commands/sessions.py → podojo_cli-0.3.1/src/podojo_cli/commands/usertests.py +62 -56
  8. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/main.py +2 -2
  9. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/tests/test_projects.py +15 -5
  10. podojo_cli-0.3.1/tests/test_usertests.py +282 -0
  11. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/uv.lock +1 -1
  12. podojo_cli-0.2.2/tests/test_sessions.py +0 -277
  13. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/.gitignore +0 -0
  14. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/README.md +0 -0
  15. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/__init__.py +0 -0
  16. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/__init__.py +0 -0
  17. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/auth.py +0 -0
  18. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/gdrive.py +0 -0
  19. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/projects.py +0 -0
  20. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/showreel.py +0 -0
  21. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/transcripts.py +0 -0
  22. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/commands/videos.py +0 -0
  23. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/config.py +0 -0
  24. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/gdrive/__init__.py +0 -0
  25. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/gdrive/list.py +0 -0
  26. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/gdrive/upload.py +0 -0
  27. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/video/__init__.py +0 -0
  28. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/src/podojo_cli/video/showreel.py +0 -0
  29. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/tests/conftest.py +0 -0
  30. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/tests/test_auth.py +0 -0
  31. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/tests/test_gdrive.py +0 -0
  32. {podojo_cli-0.2.2 → podojo_cli-0.3.1}/tests/test_showreel.py +0 -0
@@ -3,6 +3,8 @@ name: Publish to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ push:
7
+ branches: [main]
6
8
 
7
9
  jobs:
8
10
  publish:
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Podojo CLI will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com),
6
+ and this project adheres to [Semantic Versioning](https://semver.org).
7
+
8
+ ## [0.3.1] - 2026-04-04
9
+
10
+ ### Added
11
+ - Auto-publish to PyPI on push to main
12
+ - CHANGELOG.md for tracking version history
13
+
14
+ ## [0.3.0] - 2026-04-01
15
+
16
+ ### Changed
17
+ - Renamed `sessions` command to `usertests` (breaking change)
18
+ - Removed `client` field from usertest commands
19
+
20
+ ### Added
21
+ - Show preview and live URLs after `usertest create` and `usertest get`
22
+
23
+ ## [0.2.1] - 2026-03-28
24
+
25
+ ### Changed
26
+ - Default `project_name` to session ID on session creation
27
+
28
+ ### Fixed
29
+ - Test mocks to match actual API response shape
30
+
31
+ ## [0.2.0] - 2026-03-25
32
+
33
+ ### Added
34
+ - `sessions` command for unmoderated test management
35
+ - Live status column in sessions list
36
+
37
+ ### Fixed
38
+ - Publish workflow permissions for checkout
39
+ - Projects list iterating over paginated response dict
40
+
41
+ ## [0.1.0] - 2026-03-20
42
+
43
+ ### Added
44
+ - Initial CLI with Typer, httpx, and Rich
45
+ - `auth login/logout` commands with API key validation
46
+ - `showreel` command with video download
47
+ - `gdrive` upload and list commands
48
+ - `projects` list command
49
+ - PyPI publishing workflow
@@ -25,20 +25,20 @@ podojo gdrive setup ~/.podojo-gdrive.json
25
25
  podojo gdrive upload report.md --title "My Report"
26
26
  ```
27
27
 
28
- ## Sessions (Unmoderated Tests)
28
+ ## User Tests (Unmoderated Tests)
29
29
 
30
30
  ```bash
31
- podojo sessions list
32
- podojo sessions get <session_id>
33
- podojo sessions create --from-file session.yaml
34
- podojo sessions update <session_id> --from-file updates.yaml
35
- podojo sessions delete <session_id> [--yes]
36
- podojo sessions example # print template YAML to stdout
37
- podojo sessions validate session.yaml # validate without creating
31
+ podojo usertests list
32
+ podojo usertests get <usertest_id>
33
+ podojo usertests create --from-file usertest.yaml
34
+ podojo usertests update <usertest_id> --from-file updates.yaml
35
+ podojo usertests delete <usertest_id> [--yes]
36
+ podojo usertests example # print template YAML to stdout
37
+ podojo usertests validate usertest.yaml # validate without creating
38
38
  ```
39
39
 
40
- Session configs are YAML files. Run `podojo sessions example` for the full template.
41
- Required fields: `session_id`, `client`, `title`, `logo`, `prototype_url`, `steps`.
40
+ User test configs are YAML files. Run `podojo usertests example` for the full template.
41
+ Required fields: `usertest_id`, `client`, `title`, `logo`, `prototype_url`, `steps`.
42
42
  Each step needs `type` ("screen" or "prototype") and `title`. Screen steps should have `variant` ("question" or "task").
43
43
 
44
44
  ## Configuration
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: podojo-cli
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: CLI for the Podojo user research platform
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "podojo-cli"
3
- version = "0.2.2"
3
+ version = "0.3.1"
4
4
  description = "CLI for the Podojo user research platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -53,44 +53,44 @@ class PodojoClient:
53
53
  r.raise_for_status()
54
54
  return r.json()
55
55
 
56
- def list_sessions(self, skip: int = 0, limit: int = 50) -> dict:
56
+ def list_usertests(self, skip: int = 0, limit: int = 50) -> dict:
57
57
  r = httpx.get(
58
- f"{self.base_url}/sessions",
58
+ f"{self.base_url}/usertests",
59
59
  params={"skip": skip, "limit": limit},
60
60
  headers=self._headers(),
61
61
  )
62
62
  r.raise_for_status()
63
63
  return r.json()
64
64
 
65
- def get_session(self, session_id: str) -> dict:
65
+ def get_usertest(self, usertest_id: str) -> dict:
66
66
  r = httpx.get(
67
- f"{self.base_url}/sessions/{session_id}",
67
+ f"{self.base_url}/usertests/{usertest_id}",
68
68
  headers=self._headers(),
69
69
  )
70
70
  r.raise_for_status()
71
71
  return r.json()
72
72
 
73
- def create_session(self, data: dict) -> dict:
73
+ def create_usertest(self, data: dict) -> dict:
74
74
  r = httpx.post(
75
- f"{self.base_url}/sessions",
75
+ f"{self.base_url}/usertests",
76
76
  json=data,
77
77
  headers=self._headers(),
78
78
  )
79
79
  r.raise_for_status()
80
80
  return r.json()
81
81
 
82
- def update_session(self, session_id: str, data: dict) -> dict:
82
+ def update_usertest(self, usertest_id: str, data: dict) -> dict:
83
83
  r = httpx.put(
84
- f"{self.base_url}/sessions/{session_id}",
84
+ f"{self.base_url}/usertests/{usertest_id}",
85
85
  json=data,
86
86
  headers=self._headers(),
87
87
  )
88
88
  r.raise_for_status()
89
89
  return r.json()
90
90
 
91
- def delete_session(self, session_id: str) -> dict:
91
+ def delete_usertest(self, usertest_id: str) -> dict:
92
92
  r = httpx.delete(
93
- f"{self.base_url}/sessions/{session_id}",
93
+ f"{self.base_url}/usertests/{usertest_id}",
94
94
  headers=self._headers(),
95
95
  )
96
96
  r.raise_for_status()
@@ -8,22 +8,21 @@ from rich.table import Table
8
8
 
9
9
  from ..client import PodojoClient
10
10
 
11
- app = typer.Typer(help="Manage unmoderated test sessions")
11
+ app = typer.Typer(help="Manage unmoderated user tests")
12
12
  console = Console()
13
13
 
14
- REQUIRED_FIELDS = ["session_id", "client", "title", "logo", "prototype_url", "steps"]
14
+ REQUIRED_FIELDS = ["usertest_id", "title", "logo", "prototype_url", "steps"]
15
15
  VALID_STEP_TYPES = {"screen", "prototype"}
16
16
  VALID_STEP_VARIANTS = {"question", "task"}
17
17
  REQUIRED_STEP_FIELDS = ["type", "title"]
18
18
 
19
19
  EXAMPLE_YAML = """\
20
- # Podojo Unmoderated Test Session Configuration
20
+ # Podojo Unmoderated User Test Configuration
21
21
  #
22
- # Required fields: session_id, client, title, logo, prototype_url, steps
22
+ # Required fields: usertest_id, title, logo, prototype_url, steps
23
23
  # Optional fields: welcome_text, privacy_text, promo_code, promo_code_info, project_name
24
24
 
25
- session_id: checkout-usability-v1
26
- client: Acme Corp
25
+ usertest_id: checkout-usability-v1
27
26
  title: Checkout Flow Usability Test
28
27
  logo: https://example.com/logo.png
29
28
  prototype_url: https://figma.com/proto/abc123
@@ -46,10 +45,10 @@ privacy_text: |
46
45
  promo_code: THANKS10
47
46
  promo_code_info: "Use this code for 10% off your next purchase"
48
47
 
49
- # Optional: link session to a project
48
+ # Optional: link user test to a project
50
49
  project_name: checkout-redesign-q1
51
50
 
52
- # Optional: set session live (default: false)
51
+ # Optional: set user test live (default: false)
53
52
  # live: true
54
53
 
55
54
  # Steps define what participants see and do
@@ -87,8 +86,8 @@ steps:
87
86
  """
88
87
 
89
88
 
90
- def validate_session_data(data: dict) -> list[str]:
91
- """Validate session YAML data, return list of error strings."""
89
+ def validate_usertest_data(data: dict) -> list[str]:
90
+ """Validate user test YAML data, return list of error strings."""
92
91
  errors = []
93
92
  for field in REQUIRED_FIELDS:
94
93
  if field not in data or data[field] is None:
@@ -154,37 +153,35 @@ def _format_api_error(e: httpx.HTTPStatusError) -> str:
154
153
 
155
154
 
156
155
  @app.command("list")
157
- def list_sessions(
158
- skip: int = typer.Option(0, help="Number of sessions to skip"),
159
- limit: int = typer.Option(50, help="Max sessions to return"),
156
+ def list_usertests(
157
+ skip: int = typer.Option(0, help="Number of user tests to skip"),
158
+ limit: int = typer.Option(50, help="Max user tests to return"),
160
159
  ):
161
- """List all sessions."""
160
+ """List all user tests."""
162
161
  client = PodojoClient()
163
162
  try:
164
- result = client.list_sessions(skip=skip, limit=limit)
163
+ result = client.list_usertests(skip=skip, limit=limit)
165
164
  except httpx.HTTPStatusError as e:
166
165
  console.print(f"[red]Error:[/red] {_format_api_error(e)}")
167
166
  raise typer.Exit(1)
168
167
 
169
- sessions = result.get("sessions", [])
170
- if not sessions:
171
- console.print("No sessions found.")
168
+ usertests = result.get("usertests", [])
169
+ if not usertests:
170
+ console.print("No user tests found.")
172
171
  return
173
172
 
174
- table = Table(title="Sessions")
175
- table.add_column("Session ID")
173
+ table = Table(title="User Tests")
174
+ table.add_column("User Test ID")
176
175
  table.add_column("Title")
177
- table.add_column("Client")
178
176
  table.add_column("Steps", justify="right")
179
177
  table.add_column("Live")
180
178
  table.add_column("Last Updated")
181
179
 
182
- for s in sessions:
180
+ for s in usertests:
183
181
  live = "[green]Yes[/green]" if s.get("live") else "[dim]No[/dim]"
184
182
  table.add_row(
185
- s.get("session_id", ""),
183
+ s.get("usertest_id", ""),
186
184
  s.get("title", ""),
187
- s.get("client", ""),
188
185
  str(s.get("step_count", "")),
189
186
  live,
190
187
  s.get("last_updated", ""),
@@ -194,35 +191,39 @@ def list_sessions(
194
191
 
195
192
 
196
193
  @app.command("get")
197
- def get_session(
198
- session_id: str = typer.Argument(help="Session ID to retrieve"),
194
+ def get_usertest(
195
+ usertest_id: str = typer.Argument(help="User test ID to retrieve"),
199
196
  ):
200
- """Get a session and output as YAML."""
197
+ """Get a user test and output as YAML."""
201
198
  client = PodojoClient()
202
199
  try:
203
- session = client.get_session(session_id)
200
+ usertest = client.get_usertest(usertest_id)
204
201
  except httpx.HTTPStatusError as e:
205
202
  if e.response.status_code == 404:
206
- console.print(f"[red]Error:[/red] Session '{session_id}' not found")
203
+ console.print(f"[red]Error:[/red] User test '{usertest_id}' not found")
207
204
  else:
208
205
  console.print(f"[red]Error:[/red] {_format_api_error(e)}")
209
206
  raise typer.Exit(1)
210
207
 
211
208
  # Remove server-managed fields for a clean editable output
209
+ group = usertest.pop("group", "")
212
210
  for key in ("id", "created_at", "created_by", "last_updated"):
213
- session.pop(key, None)
211
+ usertest.pop(key, None)
214
212
 
215
- console.print(yaml.dump(session, default_flow_style=False, sort_keys=False, allow_unicode=True))
213
+ console.print(yaml.dump(usertest, default_flow_style=False, sort_keys=False, allow_unicode=True))
214
+ if group:
215
+ console.print(f"Preview: https://usertests.podojo.com/preview/{group}/{usertest_id}")
216
+ console.print(f"Live: https://usertests.podojo.com/{group}?test={usertest_id}")
216
217
 
217
218
 
218
219
  @app.command("create")
219
- def create_session(
220
- from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with session config"),
220
+ def create_usertest(
221
+ from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with user test config"),
221
222
  ):
222
- """Create a new session from a YAML file."""
223
+ """Create a new user test from a YAML file."""
223
224
  data = _load_yaml(from_file)
224
225
 
225
- errors = validate_session_data(data)
226
+ errors = validate_usertest_data(data)
226
227
  if errors:
227
228
  console.print("[red]Validation errors:[/red]")
228
229
  for err in errors:
@@ -230,82 +231,87 @@ def create_session(
230
231
  raise typer.Exit(1)
231
232
 
232
233
  if "project_name" not in data:
233
- data["project_name"] = data["session_id"]
234
+ data["project_name"] = data["usertest_id"]
234
235
 
235
236
  client = PodojoClient()
236
237
  try:
237
- result = client.create_session(data)
238
+ result = client.create_usertest(data)
238
239
  except httpx.HTTPStatusError as e:
239
240
  if e.response.status_code == 409:
240
- console.print(f"[red]Error:[/red] Session '{data.get('session_id')}' already exists")
241
+ console.print(f"[red]Error:[/red] User test '{data.get('usertest_id')}' already exists")
241
242
  else:
242
243
  console.print(f"[red]Error:[/red] {_format_api_error(e)}")
243
244
  raise typer.Exit(1)
244
245
 
245
- console.print(f"[green]Created session:[/green] {result.get('session_id', '')}")
246
+ usertest_id = result.get("usertest_id", "")
247
+ group = result.get("group", "")
248
+ console.print(f"[green]Created user test:[/green] {usertest_id}")
249
+ if group:
250
+ console.print(f" Preview: https://usertests.podojo.com/preview/{group}/{usertest_id}")
251
+ console.print(f" Live: https://usertests.podojo.com/{group}?test={usertest_id}")
246
252
 
247
253
 
248
254
  @app.command("update")
249
- def update_session(
250
- session_id: str = typer.Argument(help="Session ID to update"),
255
+ def update_usertest(
256
+ usertest_id: str = typer.Argument(help="User test ID to update"),
251
257
  from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with fields to update"),
252
258
  ):
253
- """Update a session from a YAML file (partial updates OK)."""
259
+ """Update a user test from a YAML file (partial updates OK)."""
254
260
  data = _load_yaml(from_file)
255
261
 
256
262
  client = PodojoClient()
257
263
  try:
258
- result = client.update_session(session_id, data)
264
+ result = client.update_usertest(usertest_id, data)
259
265
  except httpx.HTTPStatusError as e:
260
266
  if e.response.status_code == 404:
261
- console.print(f"[red]Error:[/red] Session '{session_id}' not found")
267
+ console.print(f"[red]Error:[/red] User test '{usertest_id}' not found")
262
268
  else:
263
269
  console.print(f"[red]Error:[/red] {_format_api_error(e)}")
264
270
  raise typer.Exit(1)
265
271
 
266
- console.print(f"[green]Updated session:[/green] {result.get('session_id', session_id)}")
272
+ console.print(f"[green]Updated user test:[/green] {result.get('usertest_id', usertest_id)}")
267
273
 
268
274
 
269
275
  @app.command("delete")
270
- def delete_session(
271
- session_id: str = typer.Argument(help="Session ID to delete"),
276
+ def delete_usertest(
277
+ usertest_id: str = typer.Argument(help="User test ID to delete"),
272
278
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
273
279
  ):
274
- """Delete a session."""
280
+ """Delete a user test."""
275
281
  if not yes:
276
- typer.confirm(f"Delete session '{session_id}'?", abort=True)
282
+ typer.confirm(f"Delete user test '{usertest_id}'?", abort=True)
277
283
 
278
284
  client = PodojoClient()
279
285
  try:
280
- client.delete_session(session_id)
286
+ client.delete_usertest(usertest_id)
281
287
  except httpx.HTTPStatusError as e:
282
288
  if e.response.status_code == 404:
283
- console.print(f"[red]Error:[/red] Session '{session_id}' not found")
289
+ console.print(f"[red]Error:[/red] User test '{usertest_id}' not found")
284
290
  else:
285
291
  console.print(f"[red]Error:[/red] {_format_api_error(e)}")
286
292
  raise typer.Exit(1)
287
293
 
288
- console.print(f"[green]Deleted session:[/green] {session_id}")
294
+ console.print(f"[green]Deleted user test:[/green] {usertest_id}")
289
295
 
290
296
 
291
297
  @app.command("validate")
292
298
  def validate(
293
299
  file: Path = typer.Argument(help="YAML file to validate"),
294
300
  ):
295
- """Validate a session YAML file without creating it."""
301
+ """Validate a user test YAML file without creating it."""
296
302
  data = _load_yaml(file)
297
303
 
298
- errors = validate_session_data(data)
304
+ errors = validate_usertest_data(data)
299
305
  if errors:
300
306
  console.print("[red]Validation errors:[/red]")
301
307
  for err in errors:
302
308
  console.print(f" {err}")
303
309
  raise typer.Exit(1)
304
310
 
305
- console.print("[green]Valid session config.[/green]")
311
+ console.print("[green]Valid user test config.[/green]")
306
312
 
307
313
 
308
314
  @app.command("example")
309
315
  def example():
310
- """Print an example session YAML template."""
316
+ """Print an example user test YAML template."""
311
317
  print(EXAMPLE_YAML)
@@ -1,6 +1,6 @@
1
1
  import typer
2
2
 
3
- from .commands import auth, gdrive, projects, sessions, showreel, transcripts, videos
3
+ from .commands import auth, gdrive, projects, usertests, showreel, transcripts, videos
4
4
 
5
5
  app = typer.Typer(
6
6
  name="podojo",
@@ -10,7 +10,7 @@ app = typer.Typer(
10
10
 
11
11
  app.add_typer(auth.app, name="auth")
12
12
  app.add_typer(projects.app, name="projects")
13
- app.add_typer(sessions.app, name="sessions")
13
+ app.add_typer(usertests.app, name="usertests")
14
14
  app.add_typer(transcripts.app, name="transcripts")
15
15
  app.add_typer(videos.app, name="videos")
16
16
  app.add_typer(showreel.app, name="showreel")
@@ -4,10 +4,15 @@ from podojo_cli.main import app
4
4
  def test_list_projects(runner, httpx_mock):
5
5
  httpx_mock.add_response(
6
6
  url="http://test.local/api/v1/projects?skip=0&limit=50",
7
- json=[
8
- {"project_name": "Alpha", "description": "First project"},
9
- {"project_name": "Beta", "description": "Second project"},
10
- ],
7
+ json={
8
+ "projects": [
9
+ {"name": "Alpha", "brief": "First project"},
10
+ {"name": "Beta", "brief": "Second project"},
11
+ ],
12
+ "total": 2,
13
+ "skip": 0,
14
+ "limit": 50,
15
+ },
11
16
  )
12
17
 
13
18
  result = runner.invoke(app, ["projects", "list"])
@@ -20,7 +25,12 @@ def test_list_projects(runner, httpx_mock):
20
25
  def test_list_projects_empty(runner, httpx_mock):
21
26
  httpx_mock.add_response(
22
27
  url="http://test.local/api/v1/projects?skip=0&limit=50",
23
- json=[],
28
+ json={
29
+ "projects": [],
30
+ "total": 0,
31
+ "skip": 0,
32
+ "limit": 50,
33
+ },
24
34
  )
25
35
 
26
36
  result = runner.invoke(app, ["projects", "list"])
@@ -0,0 +1,282 @@
1
+ from podojo_cli.main import app
2
+
3
+
4
+ VALID_USERTEST_YAML = """\
5
+ usertest_id: test-usertest-1
6
+ client: Test Corp
7
+ title: Test User Test
8
+ logo: https://example.com/logo.png
9
+ prototype_url: https://example.com/proto
10
+ steps:
11
+ - type: screen
12
+ variant: question
13
+ title: First Question
14
+ text: What do you see?
15
+ - type: prototype
16
+ title: Try the Flow
17
+ """
18
+
19
+ LIST_RESPONSE = {
20
+ "usertests": [
21
+ {
22
+ "usertest_id": "ut-1",
23
+ "title": "User Test One",
24
+ "client": "Acme",
25
+ "step_count": 3,
26
+ "live": True,
27
+ "last_updated": "2026-03-01T12:00:00",
28
+ },
29
+ {
30
+ "usertest_id": "ut-2",
31
+ "title": "User Test Two",
32
+ "client": "Beta",
33
+ "step_count": 5,
34
+ "live": False,
35
+ "last_updated": "2026-03-02T12:00:00",
36
+ },
37
+ ],
38
+ "total": 2,
39
+ }
40
+
41
+ GET_RESPONSE = {
42
+ "id": "abc123",
43
+ "usertest_id": "ut-1",
44
+ "title": "User Test One",
45
+ "client": "Acme",
46
+ "logo": "https://example.com/logo.png",
47
+ "prototype_url": "https://example.com/proto",
48
+ "steps": [{"type": "screen", "variant": "question", "title": "Q1"}],
49
+ "group": "test-group",
50
+ "created_at": "2026-03-01T12:00:00",
51
+ "created_by": "user@example.com",
52
+ "last_updated": "2026-03-01T12:00:00",
53
+ }
54
+
55
+
56
+ def test_list_usertests(runner, httpx_mock):
57
+ httpx_mock.add_response(
58
+ url="http://test.local/api/v1/usertests?skip=0&limit=50",
59
+ json=LIST_RESPONSE,
60
+ )
61
+
62
+ result = runner.invoke(app, ["usertests", "list"])
63
+
64
+ assert result.exit_code == 0
65
+ assert "User Test One" in result.output
66
+ assert "User Test Two" in result.output
67
+
68
+
69
+ def test_list_usertests_empty(runner, httpx_mock):
70
+ httpx_mock.add_response(
71
+ url="http://test.local/api/v1/usertests?skip=0&limit=50",
72
+ json={"usertests": [], "total": 0},
73
+ )
74
+
75
+ result = runner.invoke(app, ["usertests", "list"])
76
+
77
+ assert result.exit_code == 0
78
+ assert "No user tests found" in result.output
79
+
80
+
81
+ def test_get_usertest(runner, httpx_mock):
82
+ httpx_mock.add_response(
83
+ url="http://test.local/api/v1/usertests/ut-1",
84
+ json=GET_RESPONSE,
85
+ )
86
+
87
+ result = runner.invoke(app, ["usertests", "get", "ut-1"])
88
+
89
+ assert result.exit_code == 0
90
+ assert "usertest_id: ut-1" in result.output
91
+ assert "title: User Test One" in result.output
92
+ # Server-managed fields should be stripped
93
+ assert "created_at" not in result.output
94
+ assert "created_by" not in result.output
95
+ # URLs should be displayed
96
+ assert "https://usertests.podojo.com/preview/test-group/ut-1" in result.output
97
+ assert "https://usertests.podojo.com/test-group?test=ut-1" in result.output
98
+
99
+
100
+ def test_get_usertest_not_found(runner, httpx_mock):
101
+ httpx_mock.add_response(
102
+ url="http://test.local/api/v1/usertests/nonexistent",
103
+ status_code=404,
104
+ json={"detail": "User test not found"},
105
+ )
106
+
107
+ result = runner.invoke(app, ["usertests", "get", "nonexistent"])
108
+
109
+ assert result.exit_code == 1
110
+ assert "not found" in result.output
111
+
112
+
113
+ def test_create_usertest(runner, httpx_mock, tmp_path):
114
+ yaml_file = tmp_path / "usertest.yaml"
115
+ yaml_file.write_text(VALID_USERTEST_YAML)
116
+
117
+ httpx_mock.add_response(
118
+ url="http://test.local/api/v1/usertests",
119
+ method="POST",
120
+ json={"usertest_id": "test-usertest-1", "id": "abc123", "group": "test-group"},
121
+ )
122
+
123
+ result = runner.invoke(app, ["usertests", "create", "-f", str(yaml_file)])
124
+
125
+ assert result.exit_code == 0
126
+ assert "test-usertest-1" in result.output
127
+ assert "https://usertests.podojo.com/preview/test-group/test-usertest-1" in result.output
128
+ assert "https://usertests.podojo.com/test-group?test=test-usertest-1" in result.output
129
+
130
+
131
+ def test_create_usertest_file_not_found(runner):
132
+ result = runner.invoke(app, ["usertests", "create", "-f", "/nonexistent/usertest.yaml"])
133
+
134
+ assert result.exit_code == 1
135
+ assert "File not found" in result.output
136
+
137
+
138
+ def test_create_usertest_invalid_yaml(runner, tmp_path):
139
+ yaml_file = tmp_path / "bad.yaml"
140
+ yaml_file.write_text(":\n invalid: [yaml\n broken")
141
+
142
+ result = runner.invoke(app, ["usertests", "create", "-f", str(yaml_file)])
143
+
144
+ assert result.exit_code == 1
145
+ assert "Invalid YAML" in result.output
146
+
147
+
148
+ def test_create_usertest_missing_fields(runner, tmp_path):
149
+ yaml_file = tmp_path / "incomplete.yaml"
150
+ yaml_file.write_text("title: Just a title\n")
151
+
152
+ result = runner.invoke(app, ["usertests", "create", "-f", str(yaml_file)])
153
+
154
+ assert result.exit_code == 1
155
+ assert "Missing required field" in result.output
156
+ assert "'usertest_id'" in result.output
157
+
158
+
159
+ def test_create_usertest_invalid_step_type(runner, tmp_path):
160
+ yaml_file = tmp_path / "badstep.yaml"
161
+ yaml_file.write_text(
162
+ "usertest_id: test\n"
163
+ "client: Test\n"
164
+ "title: Test\n"
165
+ "logo: https://example.com/logo.png\n"
166
+ "prototype_url: https://example.com/proto\n"
167
+ "steps:\n"
168
+ " - type: invalid\n"
169
+ " title: Bad Step\n"
170
+ )
171
+
172
+ result = runner.invoke(app, ["usertests", "create", "-f", str(yaml_file)])
173
+
174
+ assert result.exit_code == 1
175
+ assert "'type' must be 'screen' or 'prototype'" in result.output
176
+
177
+
178
+ def test_create_usertest_duplicate(runner, httpx_mock, tmp_path):
179
+ yaml_file = tmp_path / "usertest.yaml"
180
+ yaml_file.write_text(VALID_USERTEST_YAML)
181
+
182
+ httpx_mock.add_response(
183
+ url="http://test.local/api/v1/usertests",
184
+ method="POST",
185
+ status_code=409,
186
+ json={"detail": "User test with this ID already exists"},
187
+ )
188
+
189
+ result = runner.invoke(app, ["usertests", "create", "-f", str(yaml_file)])
190
+
191
+ assert result.exit_code == 1
192
+ assert "already exists" in result.output
193
+
194
+
195
+ def test_update_usertest(runner, httpx_mock, tmp_path):
196
+ yaml_file = tmp_path / "update.yaml"
197
+ yaml_file.write_text("title: Updated Title\n")
198
+
199
+ httpx_mock.add_response(
200
+ url="http://test.local/api/v1/usertests/ut-1",
201
+ method="PUT",
202
+ json={"usertest_id": "ut-1", "title": "Updated Title"},
203
+ )
204
+
205
+ result = runner.invoke(app, ["usertests", "update", "ut-1", "-f", str(yaml_file)])
206
+
207
+ assert result.exit_code == 0
208
+ assert "Updated user test" in result.output
209
+
210
+
211
+ def test_update_usertest_not_found(runner, httpx_mock, tmp_path):
212
+ yaml_file = tmp_path / "update.yaml"
213
+ yaml_file.write_text("title: Updated Title\n")
214
+
215
+ httpx_mock.add_response(
216
+ url="http://test.local/api/v1/usertests/nonexistent",
217
+ method="PUT",
218
+ status_code=404,
219
+ json={"detail": "User test not found"},
220
+ )
221
+
222
+ result = runner.invoke(app, ["usertests", "update", "nonexistent", "-f", str(yaml_file)])
223
+
224
+ assert result.exit_code == 1
225
+ assert "not found" in result.output
226
+
227
+
228
+ def test_delete_usertest(runner, httpx_mock):
229
+ httpx_mock.add_response(
230
+ url="http://test.local/api/v1/usertests/ut-1",
231
+ method="DELETE",
232
+ json={"status": "deleted", "usertest_id": "ut-1"},
233
+ )
234
+
235
+ result = runner.invoke(app, ["usertests", "delete", "ut-1", "--yes"])
236
+
237
+ assert result.exit_code == 0
238
+ assert "Deleted user test" in result.output
239
+
240
+
241
+ def test_delete_usertest_not_found(runner, httpx_mock):
242
+ httpx_mock.add_response(
243
+ url="http://test.local/api/v1/usertests/nonexistent",
244
+ method="DELETE",
245
+ status_code=404,
246
+ json={"detail": "User test not found"},
247
+ )
248
+
249
+ result = runner.invoke(app, ["usertests", "delete", "nonexistent", "--yes"])
250
+
251
+ assert result.exit_code == 1
252
+ assert "not found" in result.output
253
+
254
+
255
+ def test_validate_valid(runner, tmp_path):
256
+ yaml_file = tmp_path / "usertest.yaml"
257
+ yaml_file.write_text(VALID_USERTEST_YAML)
258
+
259
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
260
+
261
+ assert result.exit_code == 0
262
+ assert "Valid user test config" in result.output
263
+
264
+
265
+ def test_validate_invalid(runner, tmp_path):
266
+ yaml_file = tmp_path / "bad.yaml"
267
+ yaml_file.write_text("title: Just a title\n")
268
+
269
+ result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
270
+
271
+ assert result.exit_code == 1
272
+ assert "Missing required field" in result.output
273
+
274
+
275
+ def test_example(runner):
276
+ result = runner.invoke(app, ["usertests", "example"])
277
+
278
+ assert result.exit_code == 0
279
+ assert "usertest_id:" in result.output
280
+ assert "steps:" in result.output
281
+ assert "prototype" in result.output
282
+ assert "screen" in result.output
@@ -404,7 +404,7 @@ wheels = [
404
404
 
405
405
  [[package]]
406
406
  name = "podojo-cli"
407
- version = "0.2.1"
407
+ version = "0.3.0"
408
408
  source = { editable = "." }
409
409
  dependencies = [
410
410
  { name = "google-api-python-client" },
@@ -1,277 +0,0 @@
1
- from podojo_cli.main import app
2
-
3
-
4
- VALID_SESSION_YAML = """\
5
- session_id: test-session-1
6
- client: Test Corp
7
- title: Test Session
8
- logo: https://example.com/logo.png
9
- prototype_url: https://example.com/proto
10
- steps:
11
- - type: screen
12
- variant: question
13
- title: First Question
14
- text: What do you see?
15
- - type: prototype
16
- title: Try the Flow
17
- """
18
-
19
- LIST_RESPONSE = {
20
- "sessions": [
21
- {
22
- "session_id": "sess-1",
23
- "title": "Session One",
24
- "client": "Acme",
25
- "step_count": 3,
26
- "live": True,
27
- "last_updated": "2026-03-01T12:00:00",
28
- },
29
- {
30
- "session_id": "sess-2",
31
- "title": "Session Two",
32
- "client": "Beta",
33
- "step_count": 5,
34
- "live": False,
35
- "last_updated": "2026-03-02T12:00:00",
36
- },
37
- ],
38
- "total": 2,
39
- }
40
-
41
- GET_RESPONSE = {
42
- "id": "abc123",
43
- "session_id": "sess-1",
44
- "title": "Session One",
45
- "client": "Acme",
46
- "logo": "https://example.com/logo.png",
47
- "prototype_url": "https://example.com/proto",
48
- "steps": [{"type": "screen", "variant": "question", "title": "Q1"}],
49
- "created_at": "2026-03-01T12:00:00",
50
- "created_by": "user@example.com",
51
- "last_updated": "2026-03-01T12:00:00",
52
- }
53
-
54
-
55
- def test_list_sessions(runner, httpx_mock):
56
- httpx_mock.add_response(
57
- url="http://test.local/api/v1/sessions?skip=0&limit=50",
58
- json=LIST_RESPONSE,
59
- )
60
-
61
- result = runner.invoke(app, ["sessions", "list"])
62
-
63
- assert result.exit_code == 0
64
- assert "Session One" in result.output
65
- assert "Session Two" in result.output
66
- assert "Acme" in result.output
67
-
68
-
69
- def test_list_sessions_empty(runner, httpx_mock):
70
- httpx_mock.add_response(
71
- url="http://test.local/api/v1/sessions?skip=0&limit=50",
72
- json={"sessions": [], "total": 0},
73
- )
74
-
75
- result = runner.invoke(app, ["sessions", "list"])
76
-
77
- assert result.exit_code == 0
78
- assert "No sessions found" in result.output
79
-
80
-
81
- def test_get_session(runner, httpx_mock):
82
- httpx_mock.add_response(
83
- url="http://test.local/api/v1/sessions/sess-1",
84
- json=GET_RESPONSE,
85
- )
86
-
87
- result = runner.invoke(app, ["sessions", "get", "sess-1"])
88
-
89
- assert result.exit_code == 0
90
- assert "session_id: sess-1" in result.output
91
- assert "title: Session One" in result.output
92
- # Server-managed fields should be stripped
93
- assert "created_at" not in result.output
94
- assert "created_by" not in result.output
95
-
96
-
97
- def test_get_session_not_found(runner, httpx_mock):
98
- httpx_mock.add_response(
99
- url="http://test.local/api/v1/sessions/nonexistent",
100
- status_code=404,
101
- json={"detail": "Session not found"},
102
- )
103
-
104
- result = runner.invoke(app, ["sessions", "get", "nonexistent"])
105
-
106
- assert result.exit_code == 1
107
- assert "not found" in result.output
108
-
109
-
110
- def test_create_session(runner, httpx_mock, tmp_path):
111
- yaml_file = tmp_path / "session.yaml"
112
- yaml_file.write_text(VALID_SESSION_YAML)
113
-
114
- httpx_mock.add_response(
115
- url="http://test.local/api/v1/sessions",
116
- method="POST",
117
- json={"session_id": "test-session-1", "id": "abc123"},
118
- )
119
-
120
- result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
121
-
122
- assert result.exit_code == 0
123
- assert "test-session-1" in result.output
124
-
125
-
126
- def test_create_session_file_not_found(runner):
127
- result = runner.invoke(app, ["sessions", "create", "-f", "/nonexistent/session.yaml"])
128
-
129
- assert result.exit_code == 1
130
- assert "File not found" in result.output
131
-
132
-
133
- def test_create_session_invalid_yaml(runner, tmp_path):
134
- yaml_file = tmp_path / "bad.yaml"
135
- yaml_file.write_text(":\n invalid: [yaml\n broken")
136
-
137
- result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
138
-
139
- assert result.exit_code == 1
140
- assert "Invalid YAML" in result.output
141
-
142
-
143
- def test_create_session_missing_fields(runner, tmp_path):
144
- yaml_file = tmp_path / "incomplete.yaml"
145
- yaml_file.write_text("title: Just a title\n")
146
-
147
- result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
148
-
149
- assert result.exit_code == 1
150
- assert "Missing required field" in result.output
151
- assert "'session_id'" in result.output
152
-
153
-
154
- def test_create_session_invalid_step_type(runner, tmp_path):
155
- yaml_file = tmp_path / "badstep.yaml"
156
- yaml_file.write_text(
157
- "session_id: test\n"
158
- "client: Test\n"
159
- "title: Test\n"
160
- "logo: https://example.com/logo.png\n"
161
- "prototype_url: https://example.com/proto\n"
162
- "steps:\n"
163
- " - type: invalid\n"
164
- " title: Bad Step\n"
165
- )
166
-
167
- result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
168
-
169
- assert result.exit_code == 1
170
- assert "'type' must be 'screen' or 'prototype'" in result.output
171
-
172
-
173
- def test_create_session_duplicate(runner, httpx_mock, tmp_path):
174
- yaml_file = tmp_path / "session.yaml"
175
- yaml_file.write_text(VALID_SESSION_YAML)
176
-
177
- httpx_mock.add_response(
178
- url="http://test.local/api/v1/sessions",
179
- method="POST",
180
- status_code=409,
181
- json={"detail": "Session with this ID already exists"},
182
- )
183
-
184
- result = runner.invoke(app, ["sessions", "create", "-f", str(yaml_file)])
185
-
186
- assert result.exit_code == 1
187
- assert "already exists" in result.output
188
-
189
-
190
- def test_update_session(runner, httpx_mock, tmp_path):
191
- yaml_file = tmp_path / "update.yaml"
192
- yaml_file.write_text("title: Updated Title\n")
193
-
194
- httpx_mock.add_response(
195
- url="http://test.local/api/v1/sessions/sess-1",
196
- method="PUT",
197
- json={"session_id": "sess-1", "title": "Updated Title"},
198
- )
199
-
200
- result = runner.invoke(app, ["sessions", "update", "sess-1", "-f", str(yaml_file)])
201
-
202
- assert result.exit_code == 0
203
- assert "Updated session" in result.output
204
-
205
-
206
- def test_update_session_not_found(runner, httpx_mock, tmp_path):
207
- yaml_file = tmp_path / "update.yaml"
208
- yaml_file.write_text("title: Updated Title\n")
209
-
210
- httpx_mock.add_response(
211
- url="http://test.local/api/v1/sessions/nonexistent",
212
- method="PUT",
213
- status_code=404,
214
- json={"detail": "Session not found"},
215
- )
216
-
217
- result = runner.invoke(app, ["sessions", "update", "nonexistent", "-f", str(yaml_file)])
218
-
219
- assert result.exit_code == 1
220
- assert "not found" in result.output
221
-
222
-
223
- def test_delete_session(runner, httpx_mock):
224
- httpx_mock.add_response(
225
- url="http://test.local/api/v1/sessions/sess-1",
226
- method="DELETE",
227
- json={"status": "deleted", "session_id": "sess-1"},
228
- )
229
-
230
- result = runner.invoke(app, ["sessions", "delete", "sess-1", "--yes"])
231
-
232
- assert result.exit_code == 0
233
- assert "Deleted session" in result.output
234
-
235
-
236
- def test_delete_session_not_found(runner, httpx_mock):
237
- httpx_mock.add_response(
238
- url="http://test.local/api/v1/sessions/nonexistent",
239
- method="DELETE",
240
- status_code=404,
241
- json={"detail": "Session not found"},
242
- )
243
-
244
- result = runner.invoke(app, ["sessions", "delete", "nonexistent", "--yes"])
245
-
246
- assert result.exit_code == 1
247
- assert "not found" in result.output
248
-
249
-
250
- def test_validate_valid(runner, tmp_path):
251
- yaml_file = tmp_path / "session.yaml"
252
- yaml_file.write_text(VALID_SESSION_YAML)
253
-
254
- result = runner.invoke(app, ["sessions", "validate", str(yaml_file)])
255
-
256
- assert result.exit_code == 0
257
- assert "Valid session config" in result.output
258
-
259
-
260
- def test_validate_invalid(runner, tmp_path):
261
- yaml_file = tmp_path / "bad.yaml"
262
- yaml_file.write_text("title: Just a title\n")
263
-
264
- result = runner.invoke(app, ["sessions", "validate", str(yaml_file)])
265
-
266
- assert result.exit_code == 1
267
- assert "Missing required field" in result.output
268
-
269
-
270
- def test_example(runner):
271
- result = runner.invoke(app, ["sessions", "example"])
272
-
273
- assert result.exit_code == 0
274
- assert "session_id:" in result.output
275
- assert "steps:" in result.output
276
- assert "prototype" in result.output
277
- assert "screen" in result.output
File without changes
File without changes
File without changes