From 9d90a55842421431b11898c46a0a790e499d69d3 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 16:24:43 +0000 Subject: [PATCH 1/2] docs(forms): update list visibility to reflect org-wide access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API deprecated `available_on_list_view` — form list is now org-wide for all members. Updated CLI help text, api_client docstring, and the vendored forms skill doc to reflect that capabilities are governed by each form's permissions, not a per-form list-view toggle. Co-authored-by: Cursor --- .agents/skills/dailybot/forms/SKILL.md | 2 +- dailybot_cli/api_client.py | 7 ++++--- dailybot_cli/commands/form.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 2f2f4ed..50fccb9 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -109,7 +109,7 @@ The resolver step (4) is the customer-extension hook. See **Step 7 — Custom-sk dailybot form list --json ``` -Returns all forms visible to the logged-in user. **As of `dailybot-cli >= 3.2.0` this is org-scoped**: it lists every form the caller can see on the webapp list view — an org **admin sees all** the org's forms; a member sees forms flagged for the list view plus their own. (Before 3.2.0 the endpoint returned only the caller's *own* forms even for admins — a server-side bug; if a developer reports "I only see a handful of my forms," check `dailybot --version` and have them `dailybot upgrade`.) The shape is stable and machine-readable: +Returns all forms in the caller's organization. Every org member sees every org form; capabilities (editing, response visibility, state changes) are governed by each form's permissions. Pass `--mine` to narrow to only your own forms. (If a developer reports "I only see a handful of my forms," check `dailybot --version` and have them `dailybot upgrade` — older CLIs had a server-side visibility bug.) The shape is stable and machine-readable: ```json [ diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 49f501b..95b077f 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -879,9 +879,10 @@ def list_forms( ) -> list[dict[str, Any]]: """GET /v1/forms/ — optionally expand questions, search, and page. - The default response is org-scoped (every form the caller can see on the - webapp list view). Pass ``owner="me"`` to narrow to the caller's own forms, - or ``filter_scope`` to apply a server-side scope filter. + The default response is org-wide (every form in the caller's org). + Capabilities are governed by each form's permissions. Pass + ``owner="me"`` to narrow to the caller's own forms, or + ``filter_scope`` to apply a server-side scope filter. When ``meta`` is given it is populated with ``count`` / ``next`` for a pagination footer. diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index 63e682e..b88ccec 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -255,8 +255,8 @@ def form_list( """List forms visible to you. \b - Acts as you. You can only see and act on what you could in the webapp: - by default this is every form in your org you have list-view access to. + Lists all forms in your organization. Capabilities (editing, response + visibility, state changes) are governed by each form's permissions. Pass --mine to narrow to only the forms you own, or --filter to scope. Archived forms are hidden unless you pass --include-archived or --filter archived. From f06eb2be3961f1257c74fa7afcf72999047dec34 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 19:37:21 +0000 Subject: [PATCH 2/2] feat(form): add --owner filter, form owners picker, and migrate --mine to owner_user_ids - Added repeatable --owner flag to `form list` (UUID, email, or name) that sends `owner_user_ids` to the API - Migrated --mine from deprecated `owner=me` to `owner_user_ids` with the caller's resolved UUID - Added `form owners` subcommand (GET /v1/forms/form-owners/) for discovering which org members own forms - Added `list_form_owners` and `owner_user_ids` param to API client - Added error codes: invalid_owner_user_id, too_many_owner_user_ids - Marked `--filter me` as deprecated in help text - Updated vendored skill docs with new flags and picker - 6 new tests (913 total pass) Co-authored-by: Cursor --- .agents/skills/dailybot/forms/SKILL.md | 36 +++- .../dailybot/shared/list-query-and-errors.md | 2 + dailybot_cli/api_client.py | 34 +++- dailybot_cli/commands/form.py | 154 +++++++++++++++++- dailybot_cli/commands/public_api_helpers.py | 4 +- dailybot_cli/commands/user_scoped_actions.py | 2 + tests/api_client_test.py | 38 +++++ tests/public_api_commands_test.py | 79 ++++++++- 8 files changed, 334 insertions(+), 15 deletions(-) diff --git a/.agents/skills/dailybot/forms/SKILL.md b/.agents/skills/dailybot/forms/SKILL.md index 50fccb9..1025013 100644 --- a/.agents/skills/dailybot/forms/SKILL.md +++ b/.agents/skills/dailybot/forms/SKILL.md @@ -161,22 +161,48 @@ dailybot form list --all --json dailybot form list --page 2 --page-size 20 --json ``` -### Scope the list to your own forms — `--mine` (CLI >= 3.2.0) +### Filter by owner — `--mine` and `--owner` (CLI >= 3.5.2) -Since the default is now org-scoped, pass **`--mine`** to narrow the result to -only the forms **you own** (it sends `owner=me` to the API). Useful when an admin -wants their personal forms out of the full org list. +Pass **`--mine`** to narrow the result to only the forms **you own**, or +**`--owner`** (repeatable) to filter by specific owners. `--owner` accepts +a UUID, email, or name — non-UUID values are resolved via the org directory. +The two flags can be combined (AND semantics with every other filter). ```bash # Only the forms I own: dailybot form list --mine --json + +# Forms owned by a specific person (by name): +dailybot form list --owner "Jane Doe" --json + +# Forms owned by two people (by UUID): +dailybot form list --owner --owner --json ``` +> **Deprecation:** the `--filter me` scope is deprecated server-side. Use +> `--mine` or `--owner` instead — `--filter me` still works but maps to +> the legacy `filter=me` parameter. + +### Form owners picker — `form owners` (CLI >= 3.5.2) + +A lightweight endpoint to discover which org members own at least one form, +without pulling the full member directory. + +```bash +dailybot form owners # table of owners +dailybot form owners --search jane # search by name/email +dailybot form owners --json # machine-readable +``` + +Each result has `uuid`, `full_name`, `image`, `role`, and optionally `email` +(email is only visible to admins/managers — the CLI must not assume it exists). + ### Server-side filtering, sorting, and archived forms (CLI >= 3.5.0) | Flag | Values | Description | |------|--------|-------------| -| `--filter` | `all`, `me`, `public`, `approval`, `workflow`, `archived` | Scope filter (server-side). | +| `--filter` | `all`, `public`, `approval`, `workflow`, `archived` | Scope filter (server-side). `me` still works but is deprecated. | +| `--owner` | UUID, email, or name (repeatable) | Filter by form owner(s). Max 50. (`CLI >= 3.5.2`) | | `--order` | `alphabetical`, `recent`, `total` | Sort field (`total` = total response count). | | `--ascending` / `--asc` | flag | Sort ascending (default: descending). | | `--include-questions` | flag | Include question definitions in each form. | diff --git a/.agents/skills/dailybot/shared/list-query-and-errors.md b/.agents/skills/dailybot/shared/list-query-and-errors.md index 3469c13..0ebbe4f 100644 --- a/.agents/skills/dailybot/shared/list-query-and-errors.md +++ b/.agents/skills/dailybot/shared/list-query-and-errors.md @@ -160,6 +160,8 @@ In `--json` mode the error surfaces as `{ error, status, code, detail }`. | `send_as_user_not_found` | The `--send-as-user` UUID doesn't resolve to a user. | Confirm the user exists (`dailybot user list`). | | `invalid_kudos_filter` | `kudos list --filter` got an unrecognized value. | Use `received` or `given` (the CLI also accepts `KUDOS_RECEIVED` / `KUDOS_GIVEN`). | | `send_message_validation_error` | `chat send` payload is missing content or otherwise invalid. | Read the `detail` — it names the problem (e.g. no message/buttons/image). | +| `invalid_owner_user_id` | `--owner` value isn't a valid UUID (after resolution). | Fix the UUID or name. (`CLI >= 3.5.2`) | +| `too_many_owner_user_ids` | More than 50 `--owner` values. | Narrow the filter — max 50 owners per request. (`CLI >= 3.5.2`) | ### 429 — rate limit diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 95b077f..52eab7f 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -20,6 +20,7 @@ MAX_FALLBACK_DETAIL_CHARS: int = 160 # cap for a non-JSON error body echoed to the user MAX_SEARCH_QUERY_LENGTH: int = 256 # server rejects search queries longer than this +MAX_OWNER_USER_IDS: int = 50 # server rejects owner_user_ids lists longer than this def _fallback_detail(response: httpx.Response) -> str: @@ -865,6 +866,7 @@ def list_forms( include_questions: bool = False, include_archived: bool = False, owner: str | None = None, + owner_user_ids: list[str] | None = None, filter_scope: str | None = None, order: str | None = None, is_ascend: bool = False, @@ -881,7 +883,7 @@ def list_forms( The default response is org-wide (every form in the caller's org). Capabilities are governed by each form's permissions. Pass - ``owner="me"`` to narrow to the caller's own forms, or + ``owner_user_ids`` to filter by specific owners, or ``filter_scope`` to apply a server-side scope filter. When ``meta`` is given it is populated with ``count`` / ``next`` for a @@ -894,6 +896,8 @@ def list_forms( params["include_archived"] = "true" if owner: params["owner"] = owner + if owner_user_ids: + params["owner_user_ids"] = ",".join(owner_user_ids) if filter_scope: params["filter"] = filter_scope if order: @@ -912,6 +916,34 @@ def list_forms( _fill_meta(meta, result) return result.results + def list_form_owners( + self, + *, + search: str | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + """GET /v1/forms/form-owners/ — paginated picker of org members who own forms. + + Returns the raw paginated envelope ``{count, next, previous, results}``. + Each result has ``uuid``, ``full_name``, ``image``, ``role``, and + optionally ``email`` (only visible to admins/managers). + """ + params: dict[str, Any] = {} + if search: + params["search"] = search + if offset is not None: + params["offset"] = offset + if limit is not None: + params["limit"] = limit + response: httpx.Response = httpx.get( + f"{self.api_url}/v1/forms/form-owners/", + params=params, + headers=self._headers(), + timeout=self.timeout, + ) + return self._handle_response(response) + def get_form(self, form_uuid: str) -> dict[str, Any]: """GET /v1/forms// — form metadata and question definitions.""" response: httpx.Response = httpx.get( diff --git a/dailybot_cli/commands/form.py b/dailybot_cli/commands/form.py index b88ccec..7cfccf9 100644 --- a/dailybot_cli/commands/form.py +++ b/dailybot_cli/commands/form.py @@ -6,7 +6,11 @@ import click -from dailybot_cli.api_client import APIError, DailyBotClient +from dailybot_cli.api_client import ( + MAX_OWNER_USER_IDS, + APIError, + DailyBotClient, +) from dailybot_cli.commands.authoring_helpers import ( build_question, build_question_edit_fields, @@ -26,7 +30,9 @@ emit_json, enforce_plan_access, exit_for_api_error, + get_current_user_uuid, require_auth, + resolve_user_by_name_or_uuid, validate_user_filter, ) from dailybot_cli.commands.query_options import ( @@ -64,6 +70,8 @@ _EMAIL_RE: re.Pattern[str] = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") FORM_LIST_FILTERS: tuple[str, ...] = ("all", "me", "public", "approval", "workflow", "archived") +# "me" is deprecated server-side — kept here for back-compat but --mine now +# uses owner_user_ids with the caller's UUID instead. FORM_LIST_ORDERS: tuple[str, ...] = ("alphabetical", "recent", "total") RESPONSE_ORDERS: tuple[str, ...] = ("recent", "oldest") RESPONSE_SOURCES: tuple[str, ...] = ("member", "anonymous", "automation", "public") @@ -202,14 +210,26 @@ def form() -> None: @click.option( "--mine", is_flag=True, - help="Only forms you own (default lists every form you can see in the org).", + help="Only forms you own (default lists every form in the org).", +) +@click.option( + "--owner", + "owners", + multiple=True, + help=( + "Filter by form owner (UUID, email, or name). Repeatable. " + "Names/emails are resolved via the org directory." + ), ) @click.option( "--filter", "filter_scope", type=click.Choice(FORM_LIST_FILTERS, case_sensitive=False), default=None, - help="Scope filter: all, me, public, approval, workflow, archived.", + help=( + "Scope filter: all, public, approval, workflow, archived. " + '("me" still works but is deprecated — use --mine or --owner instead.)' + ), ) @click.option( "--order", @@ -236,6 +256,7 @@ def form() -> None: def form_list( include_archived: bool, mine: bool, + owners: tuple[str, ...], filter_scope: str | None, order: str | None, is_ascend: bool, @@ -257,13 +278,15 @@ def form_list( \b Lists all forms in your organization. Capabilities (editing, response visibility, state changes) are governed by each form's permissions. - Pass --mine to narrow to only the forms you own, or --filter to scope. + Pass --mine or --owner to filter by form owner, or --filter to scope. Archived forms are hidden unless you pass --include-archived or --filter archived. \b Examples: dailybot form list dailybot form list --mine + dailybot form list --owner "Jane Doe" + dailybot form list --owner --owner dailybot form list --filter workflow --order alphabetical --asc dailybot form list --filter approval dailybot form list --filter public --order total @@ -289,12 +312,34 @@ def form_list( print_error(str(exc)) raise SystemExit(1) client = require_auth() + + owner_user_ids: list[str] | None = None + if mine or owners: + owner_user_ids = [] + if mine: + my_uuid: str | None = get_current_user_uuid(client) + if my_uuid: + owner_user_ids.append(my_uuid) + else: + print_error( + "Could not resolve your user UUID for --mine. " + "Try passing your UUID with --owner instead." + ) + raise SystemExit(1) + if owners: + owner_user_ids.extend(_resolve_owner_uuids(client, owners)) + if len(owner_user_ids) > MAX_OWNER_USER_IDS: + print_error( + f"Too many owner UUIDs ({len(owner_user_ids)}). Maximum is {MAX_OWNER_USER_IDS}." + ) + raise SystemExit(1) + execute_form_list( client, json_mode=json_mode, include_archived=include_archived, include_questions=include_questions, - owner="me" if mine else None, + owner_user_ids=owner_user_ids, filter_scope=filter_scope, order=order, is_ascend=is_ascend, @@ -302,6 +347,105 @@ def form_list( ) +def _resolve_owner_uuids( + client: DailyBotClient, + identifiers: tuple[str, ...], +) -> list[str]: + """Resolve owner identifiers (UUID, email, or name) to UUIDs. + + Loads the org directory only when non-UUID identifiers are present. + """ + from dailybot_cli.commands.public_api_helpers import UUID_PATTERN + + uuids: list[str] = [] + to_resolve: list[str] = [] + for ident in identifiers: + if UUID_PATTERN.match(ident): + uuids.append(ident) + else: + to_resolve.append(ident) + if to_resolve: + with console.status("Resolving owner names..."): + users: list[dict[str, Any]] = client.list_users(include_email=True) + for ident in to_resolve: + try: + uid, _name = resolve_user_by_name_or_uuid(users, ident) + uuids.append(uid) + except ValueError as exc: + print_error(str(exc)) + raise SystemExit(1) + return uuids + + +@form.command("owners") +@click.option("--search", "-s", default=None, help="Search owners by name or email.") +@click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") +@click.option("--limit", "-l", type=int, default=None, help="Max results (default 20, max 50).") +def form_owners( + search: str | None, + json_mode: bool, + limit: int | None, +) -> None: + """List org members who own at least one form. + + \b + A lightweight picker for the --owner filter on `form list`. Returns only + members who own at least one non-archived form. Email is visible only to + admins/managers. + + \b + Examples: + dailybot form owners + dailybot form owners --search jane + dailybot form owners --json + """ + client = require_auth() + try: + with console.status("Fetching form owners..."): + data: dict[str, Any] = client.list_form_owners( + search=search, + limit=limit, + ) + except APIError as exc: + exit_for_api_error(exc, json_mode) + + results: list[dict[str, Any]] = data.get("results", []) + if json_mode: + emit_json(results) + return + + if not results: + from dailybot_cli.display import print_info + + print_info("No form owners found.") + return + + from rich.table import Table + + table = Table(title="Form owners", border_style="cyan") + table.add_column("Name", style="bold") + table.add_column("UUID", style="dim") + table.add_column("Role") + has_email: bool = any(r.get("email") for r in results) + if has_email: + table.add_column("Email") + for row in results: + cols: list[str] = [ + str(row.get("full_name", "")), + str(row.get("uuid", "")), + str(row.get("role", "")), + ] + if has_email: + cols.append(str(row.get("email", ""))) + table.add_row(*cols) + console.print(table) + total: int | None = data.get("count") + if total is not None and total > len(results): + from dailybot_cli.display import print_info + + print_info(f"Showing {len(results)} of {total} owners. Use --search to narrow.") + + @form.command("get") @click.argument("form_uuid") @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") diff --git a/dailybot_cli/commands/public_api_helpers.py b/dailybot_cli/commands/public_api_helpers.py index 3d165e3..18a80a1 100644 --- a/dailybot_cli/commands/public_api_helpers.py +++ b/dailybot_cli/commands/public_api_helpers.py @@ -238,7 +238,9 @@ "This form requires guest submitter info. " "Add --guest-name and --guest-email to your command." ), - "invalid_filter": "Unknown --filter value. Use: all, me, public, approval, workflow, archived.", + "invalid_filter": "Unknown --filter value. Use: all, public, approval, workflow, archived.", + "invalid_owner_user_id": "Invalid --owner UUID format.", + "too_many_owner_user_ids": "Too many --owner UUIDs (max 50). Narrow your filter.", "invalid_order": "Unknown --order value. Use: alphabetical, recent, total (forms) or recent, oldest (responses).", "invalid_submission_sources": ( "Unknown --source value. Use: member, anonymous, automation, public (comma-separated)." diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 03c76d4..e54838f 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -502,6 +502,7 @@ def execute_form_list( include_archived: bool = False, include_questions: bool = False, owner: str | None = None, + owner_user_ids: list[str] | None = None, filter_scope: str | None = None, order: str | None = None, is_ascend: bool = False, @@ -518,6 +519,7 @@ def execute_form_list( include_questions=always_include_questions, include_archived=include_archived, owner=owner, + owner_user_ids=owner_user_ids, filter_scope=filter_scope, order=order, is_ascend=is_ascend, diff --git a/tests/api_client_test.py b/tests/api_client_test.py index af9b847..3decc7d 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -510,6 +510,44 @@ def test_list_forms_with_filter_and_order(self, client: DailyBotClient) -> None: assert call_kwargs["params"]["order"] == "alphabetical" assert call_kwargs["params"]["is_ascend"] == "true" + def test_list_forms_with_owner_user_ids(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [{"uuid": "f-1", "name": "Form"}], + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + client.list_forms( + owner_user_ids=["uuid-1", "uuid-2"], + ) + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"]["owner_user_ids"] == "uuid-1,uuid-2" + + def test_list_form_owners(self, client: DailyBotClient) -> None: + mock_response: MagicMock = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + {"uuid": "u-1", "full_name": "Alice", "role": "admin"}, + ], + } + + with patch("httpx.get", return_value=mock_response) as mock_get: + data: dict[str, Any] = client.list_form_owners(search="ali", limit=10) + + call_kwargs: dict[str, Any] = mock_get.call_args[1] + assert call_kwargs["params"]["search"] == "ali" + assert call_kwargs["params"]["limit"] == 10 + assert data["results"][0]["full_name"] == "Alice" + def test_list_form_responses_with_filters(self, client: DailyBotClient) -> None: mock_response: MagicMock = MagicMock(spec=httpx.Response) mock_response.status_code = 200 diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index a89ae33..02391b1 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -1638,12 +1638,19 @@ def test_all_and_limit_conflict_rejected(self, _auth: MagicMock) -> None: assert "--all" in result.output and "--limit" in result.output @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") - def test_form_list_mine_passes_owner_me(self, _auth: MagicMock) -> None: + def test_form_list_mine_passes_owner_user_ids(self, _auth: MagicMock) -> None: page = self._envelope([{"id": "f1", "name": "Mine"}], None, 1) - with patch("dailybot_cli.api_client.httpx.get", return_value=page) as mock_get: + auth_resp = MagicMock(status_code=200) + auth_resp.json.return_value = {"user": {"uuid": "my-uuid-1234"}} + auth_resp.headers = {"content-type": "application/json"} + with ( + patch("dailybot_cli.api_client.httpx.get") as mock_get, + ): + mock_get.side_effect = [auth_resp, page] result = CliRunner().invoke(cli, ["form", "list", "--mine"]) assert result.exit_code == 0 - assert mock_get.call_args[1]["params"]["owner"] == "me" + list_call = mock_get.call_args_list[1] + assert list_call[1]["params"]["owner_user_ids"] == "my-uuid-1234" @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") def test_form_list_default_omits_owner(self, _auth: MagicMock) -> None: @@ -1652,6 +1659,72 @@ def test_form_list_default_omits_owner(self, _auth: MagicMock) -> None: result = CliRunner().invoke(cli, ["form", "list"]) assert result.exit_code == 0 assert "owner" not in mock_get.call_args[1]["params"] + assert "owner_user_ids" not in mock_get.call_args[1]["params"] + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + def test_form_list_owner_by_uuid(self, _auth: MagicMock) -> None: + """--owner with a UUID sends owner_user_ids directly.""" + page = self._envelope([{"id": "f1", "name": "A"}], None, 1) + target_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + with patch("dailybot_cli.api_client.httpx.get", return_value=page) as mock_get: + result = CliRunner().invoke(cli, ["form", "list", "--owner", target_uuid]) + assert result.exit_code == 0 + assert mock_get.call_args[1]["params"]["owner_user_ids"] == target_uuid + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + def test_form_list_owner_resolves_name(self, _auth: MagicMock) -> None: + """--owner with a name resolves via the user directory.""" + page = self._envelope([{"id": "f1", "name": "A"}], None, 1) + users_page = self._envelope( + [{"uuid": "user-uuid-123", "full_name": "Jane Doe", "is_active": True}], + None, + 1, + ) + with patch("dailybot_cli.api_client.httpx.get") as mock_get: + mock_get.side_effect = [users_page, page] + result = CliRunner().invoke(cli, ["form", "list", "--owner", "Jane Doe"]) + assert result.exit_code == 0 + list_call = mock_get.call_args_list[1] + assert list_call[1]["params"]["owner_user_ids"] == "user-uuid-123" + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + def test_form_owners_command(self, _auth: MagicMock) -> None: + """form owners returns a table of form owners.""" + resp = MagicMock(status_code=200) + resp.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + {"uuid": "owner-1", "full_name": "Alice", "role": "admin", "email": "a@e.com"}, + ], + } + resp.headers = {"content-type": "application/json"} + with patch("dailybot_cli.api_client.httpx.get", return_value=resp): + result = CliRunner().invoke(cli, ["form", "owners"]) + assert result.exit_code == 0 + assert "Alice" in result.output + + @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") + def test_form_owners_json(self, _auth: MagicMock) -> None: + """form owners --json emits JSON.""" + resp = MagicMock(status_code=200) + resp.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + {"uuid": "owner-1", "full_name": "Alice", "role": "admin"}, + ], + } + resp.headers = {"content-type": "application/json"} + with patch("dailybot_cli.api_client.httpx.get", return_value=resp): + result = CliRunner().invoke(cli, ["form", "owners", "--json"]) + assert result.exit_code == 0 + import json + + data = json.loads(result.output) + assert data[0]["uuid"] == "owner-1" @patch("dailybot_cli.commands.public_api_helpers.get_agent_auth", return_value="tok") def test_form_list_search_too_long(self, _auth: MagicMock) -> None: