From 4d851e121ec61e8169bce3b1f2a7b09d41ff7c83 Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Tue, 27 Oct 2026 22:31:03 +0530 Subject: [PATCH] Add new API commands (2) Add CLI commands for the new public API endpoints for runs, test plans, milestones, results and the Jira, GitHub and Linear integrations. New commands: - integrations list, get - integrations jira issues search, get, create, and jira issue-types list, fields - integrations github issues get, create - integrations linear issues search, get, create - milestones get, update, archive, unarchive, delete - results get, update - runs list-assigned, open-status-counts, get, selections, folders, status-counts, preview, update, reopen, bulk-close, bulk-reopen, move, delete, test-cases update-version, logs list - test-plans list, get, update, update-run, close, reopen, move, delete Existing commands: - runs create and clone gain --integration-issue-id, clone gains --configuration-id, and runs list gains --plan-ids and --configuration-ids. - runs test-cases list gains --tags-filter-op, --status, --automation-results and --automation-results-filter-op. - test-cases list and count take every filter the API has, count the same as list: --tcase-ids, --template-tcase-ids, --requirement-ids, --precondition-ids, --step-ids, --author-ids, --created-after, --created-before, --recursive and --tags-filter-op, and custom fields as --cf-, repeated for several values. Strict mode would reject --cf-* options, so these two commands check unknown arguments themselves. - --project-code also takes the project's ID. - runs create and clone no longer cap --description at 512 characters; the API doesn't. Validation follows the API: - Run query plans: tcaseIds can't be combined with filters, even empty ones, and only a single query plan may have them. Live runs take filters only. A run of a test plan takes exactly one query plan, listing its tcaseIds. - A result takes at most 5 links, each with its integrationId, and a batch at most 1,000 results with 100 links across them. - Jira issue type IDs are numeric. GitHub and Linear issue titles and descriptions are counted in characters, Jira's in UTF-16 units. Help text says where a test case can be given by ID, sequence number or legacy ID, and examples use sequence numbers. Each manifest orders its commands list, get and other reads, create, update, other updates, delete, then nested groups, which is the order the help shows. The README and agent skill command trees follow it, and CLAUDE.md describes it. --- CLAUDE.md | 2 +- README.md | 228 ++++-- skills/qas-cli/SKILL.md | 210 +++-- src/api/index.ts | 2 + src/api/integrations.ts | 223 ++++++ src/api/milestones.ts | 52 +- src/api/results.ts | 100 ++- src/api/runs.ts | 353 +++++++-- src/api/schemas.ts | 19 + src/api/tcases.ts | 62 +- src/api/test-plans.ts | 166 +++- src/commands/api/builder.ts | 38 +- src/commands/api/executor.ts | 4 + src/commands/api/manifests/index.ts | 2 + src/commands/api/manifests/integrations.ts | 493 ++++++++++++ src/commands/api/manifests/milestones.ts | 149 +++- src/commands/api/manifests/results.ts | 126 ++- src/commands/api/manifests/runs.ts | 722 ++++++++++++++++-- src/commands/api/manifests/test-cases.ts | 412 +++++----- src/commands/api/manifests/test-plans.ts | 369 ++++++++- src/commands/api/manifests/utils.ts | 36 +- src/commands/api/types.ts | 2 + src/commands/api/utils.ts | 23 + src/tests/api/integrations/get.spec.ts | 59 ++ .../integrations/github-issues-create.spec.ts | 96 +++ .../integrations/github-issues-get.spec.ts | 80 ++ .../jira-issue-types-fields.spec.ts | 75 ++ .../jira-issue-types-list.spec.ts | 54 ++ .../integrations/jira-issues-create.spec.ts | 121 +++ .../api/integrations/jira-issues-get.spec.ts | 71 ++ .../integrations/jira-issues-search.spec.ts | 113 +++ .../integrations/linear-issues-create.spec.ts | 100 +++ .../integrations/linear-issues-get.spec.ts | 69 ++ .../integrations/linear-issues-search.spec.ts | 100 +++ src/tests/api/integrations/list.spec.ts | 55 ++ src/tests/api/milestones/archive.spec.ts | 73 ++ src/tests/api/milestones/delete.spec.ts | 64 ++ src/tests/api/milestones/get.spec.ts | 71 ++ src/tests/api/milestones/unarchive.spec.ts | 80 ++ src/tests/api/milestones/update.spec.ts | 116 +++ src/tests/api/results/batch-create.spec.ts | 23 +- src/tests/api/results/create.spec.ts | 39 +- src/tests/api/results/get.spec.ts | 96 +++ src/tests/api/results/update.spec.ts | 161 ++++ src/tests/api/runs/bulk-close.spec.ts | 115 +++ src/tests/api/runs/bulk-reopen.spec.ts | 116 +++ src/tests/api/runs/clone.spec.ts | 13 +- src/tests/api/runs/create.spec.ts | 62 +- src/tests/api/runs/delete.spec.ts | 110 +++ src/tests/api/runs/folders.spec.ts | 111 +++ src/tests/api/runs/get.spec.ts | 65 ++ src/tests/api/runs/list-assigned.spec.ts | 60 ++ src/tests/api/runs/logs-list.spec.ts | 117 +++ src/tests/api/runs/move.spec.ts | 125 +++ src/tests/api/runs/open-status-counts.spec.ts | 58 ++ src/tests/api/runs/preview.spec.ts | 133 ++++ src/tests/api/runs/reopen.spec.ts | 68 ++ src/tests/api/runs/selections.spec.ts | 71 ++ src/tests/api/runs/status-counts.spec.ts | 112 +++ src/tests/api/runs/tcases-list.spec.ts | 24 + .../api/runs/tcases-update-version.spec.ts | 137 ++++ src/tests/api/runs/update.spec.ts | 157 ++++ src/tests/api/test-cases/count.spec.ts | 19 +- src/tests/api/test-cases/list.spec.ts | 54 +- src/tests/api/test-helper.ts | 33 +- src/tests/api/test-plans/close.spec.ts | 68 ++ src/tests/api/test-plans/create.spec.ts | 24 +- src/tests/api/test-plans/delete.spec.ts | 68 ++ src/tests/api/test-plans/get.spec.ts | 69 ++ src/tests/api/test-plans/list.spec.ts | 100 +++ src/tests/api/test-plans/move.spec.ts | 127 +++ src/tests/api/test-plans/reopen.spec.ts | 77 ++ src/tests/api/test-plans/update-run.spec.ts | 137 ++++ src/tests/api/test-plans/update.spec.ts | 147 ++++ 74 files changed, 7495 insertions(+), 591 deletions(-) create mode 100644 src/api/integrations.ts create mode 100644 src/commands/api/manifests/integrations.ts create mode 100644 src/tests/api/integrations/get.spec.ts create mode 100644 src/tests/api/integrations/github-issues-create.spec.ts create mode 100644 src/tests/api/integrations/github-issues-get.spec.ts create mode 100644 src/tests/api/integrations/jira-issue-types-fields.spec.ts create mode 100644 src/tests/api/integrations/jira-issue-types-list.spec.ts create mode 100644 src/tests/api/integrations/jira-issues-create.spec.ts create mode 100644 src/tests/api/integrations/jira-issues-get.spec.ts create mode 100644 src/tests/api/integrations/jira-issues-search.spec.ts create mode 100644 src/tests/api/integrations/linear-issues-create.spec.ts create mode 100644 src/tests/api/integrations/linear-issues-get.spec.ts create mode 100644 src/tests/api/integrations/linear-issues-search.spec.ts create mode 100644 src/tests/api/integrations/list.spec.ts create mode 100644 src/tests/api/milestones/archive.spec.ts create mode 100644 src/tests/api/milestones/delete.spec.ts create mode 100644 src/tests/api/milestones/get.spec.ts create mode 100644 src/tests/api/milestones/unarchive.spec.ts create mode 100644 src/tests/api/milestones/update.spec.ts create mode 100644 src/tests/api/results/get.spec.ts create mode 100644 src/tests/api/results/update.spec.ts create mode 100644 src/tests/api/runs/bulk-close.spec.ts create mode 100644 src/tests/api/runs/bulk-reopen.spec.ts create mode 100644 src/tests/api/runs/delete.spec.ts create mode 100644 src/tests/api/runs/folders.spec.ts create mode 100644 src/tests/api/runs/get.spec.ts create mode 100644 src/tests/api/runs/list-assigned.spec.ts create mode 100644 src/tests/api/runs/logs-list.spec.ts create mode 100644 src/tests/api/runs/move.spec.ts create mode 100644 src/tests/api/runs/open-status-counts.spec.ts create mode 100644 src/tests/api/runs/preview.spec.ts create mode 100644 src/tests/api/runs/reopen.spec.ts create mode 100644 src/tests/api/runs/selections.spec.ts create mode 100644 src/tests/api/runs/status-counts.spec.ts create mode 100644 src/tests/api/runs/tcases-update-version.spec.ts create mode 100644 src/tests/api/runs/update.spec.ts create mode 100644 src/tests/api/test-plans/close.spec.ts create mode 100644 src/tests/api/test-plans/delete.spec.ts create mode 100644 src/tests/api/test-plans/get.spec.ts create mode 100644 src/tests/api/test-plans/list.spec.ts create mode 100644 src/tests/api/test-plans/move.spec.ts create mode 100644 src/tests/api/test-plans/reopen.spec.ts create mode 100644 src/tests/api/test-plans/update-run.spec.ts create mode 100644 src/tests/api/test-plans/update.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index ff71219..a211f7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ The `api` command provides direct programmatic access to the QA Sphere public AP **Key files**: - `types.ts` — Defines `ApiEndpointSpec` as a discriminated union on `bodyMode` (`'none'` | `'json'` | `'file'`), plus supporting types (`ApiPathParamSpec`, `ApiFieldSpec`, `ApiQueryOptionSpec`, `ExecuteFn`) -- `manifests/` — One file per resource (e.g., `runs.ts`, `test-cases.ts`), each exporting an array of `ApiEndpointSpec` objects. `manifests/index.ts` aggregates all specs into a single `allSpecs` array. `manifests/utils.ts` has shared param definitions (e.g., `projectCodeParam`) and `paginationOptions()` for list options +- `manifests/` — One file per resource (e.g., `runs.ts`, `test-cases.ts`), each exporting an array of `ApiEndpointSpec` objects. `manifests/index.ts` aggregates all specs into a single `allSpecs` array. `manifests/utils.ts` has shared param definitions (e.g., `projectCodeParam`) and `paginationOptions()` for list options. The array order is the order `-h` lists the commands in: list, get (and other reads), create, update, other updates, delete, then nested groups. Define the specs in the same order, and keep the README and SKILL.md command trees in it too - `builder.ts` — `buildCommandsFromSpecs()` builds a yargs command tree from the flat specs array, handling nested command paths automatically. Creates yargs options from path params, query options, field options, and body mode - `executor.ts` — `executeCommand()` orchestrates execution: collects and validates path params → processes body (based on `bodyMode` discriminant) → collects and validates query options → connects to API (lazy env loading) → executes with error mapping - `main.ts` — Registers the `api` command, delegates to `buildCommandsFromSpecs()` diff --git a/README.md b/README.md index 4e73159..5a1e38e 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ The examples below show what end-to-end automation with `qas-cli` looks like. Th ```bash RUN_ID=$(qasphere api runs create --project-code PRJ \ --title "Smoke $(date +%Y-%m-%d)" --type static \ - --query-plans '[{"tcaseIds": ["abc123", "def456"]}]' | jq -r '.id') + --query-plans '[{"tcaseIds": ["4", "5"]}]' | jq -r '.id') qasphere api results batch-create --project-code PRJ --run-id "$RUN_ID" \ - --items '[{"tcaseId": "abc123", "status": "passed"}, {"tcaseId": "def456", "status": "failed", "comment": "timeout on /cart"}]' + --items '[{"tcaseId": "4", "status": "passed"}, {"tcaseId": "5", "status": "failed", "comment": "timeout on /cart"}]' qasphere api runs close --project-code PRJ --run-id "$RUN_ID" ``` @@ -69,13 +69,27 @@ qasphere api runs list --project-code PRJ --closed false --include statusCounts ```bash RUN_ID=$(qasphere api runs create --project-code PRJ \ --title "CI build $BUILD_NUMBER" --type static \ - --query-plans '[{"tcaseIds": ["abc123"]}]' \ + --query-plans '[{"tcaseIds": ["4"]}]' \ --milestone-id 7 | jq -r '.id') qasphere junit-upload -r "$QAS_URL/project/PRJ/run/$RUN_ID" ./test-results.xml ``` -**4. AI agent workflows — reports and visualizations** — once the [skill](#ai-agent-skill) is registered with a coding agent (Claude Code, Cursor, etc.), the agent can drive ad-hoc reporting, dashboards, and charts in natural language. Under the hood it calls `qasphere api ...` to fetch the data, then renders it however the conversation needs (Markdown tables, matplotlib/Plotly charts, HTML/SVG, CSV exports, etc.). Example prompts: +**4. File a Jira issue for a failure and link it to the result** — the result links back to the issue, and the issue to the result: + +```bash +JIRA_ID=$(qasphere api integrations list --project-code PRJ \ + | jq -r '.integrations[] | select(.type == "jira") | .id') + +ISSUE=$(qasphere api integrations jira issues create --project-code PRJ \ + --integration-id "$JIRA_ID" --issue-type-id 10001 --summary "Checkout times out") + +qasphere api results create --project-code PRJ --run-id "$RUN_ID" --tcase-id 5 \ + --status failed --comment "timeout on /cart" \ + --links "$(jq -c --arg i "$JIRA_ID" '[{integrationId: $i, url, text: .key, meta: {id}}]' <<< "$ISSUE")" +``` + +**5. AI agent workflows — reports and visualizations** — once the [skill](#ai-agent-skill) is registered with a coding agent (Claude Code, Cursor, etc.), the agent can drive ad-hoc reporting, dashboards, and charts in natural language. Under the hood it calls `qasphere api ...` to fetch the data, then renders it however the conversation needs (Markdown tables, matplotlib/Plotly charts, HTML/SVG, CSV exports, etc.). Example prompts: - _"Plot the pass rate of the last 20 closed runs in project PRJ as a line chart."_ — agent calls `qasphere api runs list --project-code PRJ --closed true --include statusCounts`, computes `passed / all` per run from `statusCounts`, renders the chart. - _"Which folders in PRJ have the most failing tests this week?"_ — agent combines `runs list`, `runs test-cases list`, and `folders list` to aggregate failures by folder. @@ -186,112 +200,164 @@ Each line lists the options a command requires. Commands that send a request bod ``` qasphere api ├── ai-rules -│ └── list --project-code # List AI rules that apply in a project +│ └── list --project-code # List AI rules that apply in a project ├── audit-logs -│ └── list # List audit log entries +│ └── list # List audit log entries ├── configurations -│ ├── list --project-code # List configurations -│ ├── get --project-code --configuration-id # Get configuration -│ ├── create --project-code --title # Create configuration -│ ├── update --project-code --configuration-id --title # Rename configuration -│ └── delete --project-code --configuration-id # Delete configuration +│ ├── list --project-code # List configurations +│ ├── get --project-code --configuration-id # Get configuration +│ ├── create --project-code --title # Create configuration +│ ├── update --project-code --configuration-id --title # Rename configuration +│ └── delete --project-code --configuration-id # Delete configuration ├── custom-fields -│ └── list --project-code # List custom fields +│ └── list --project-code # List custom fields ├── files -│ └── upload --file # Upload a file attachment +│ └── upload --file # Upload a file attachment ├── folders -│ ├── list --project-code # List folders -│ ├── get --project-code --folder-id # Get folder -│ ├── tree --project-code # Get folder tree -│ ├── path --project-code --folder-id # Get folder path -│ ├── create --project-code --title # Create folder -│ ├── update --project-code --folder-id # Rename folder or edit its comment -│ ├── move --project-code --new-parent-id --items # Move folders -│ ├── copy --project-code --folder-ids # Copy folders -│ ├── delete --project-code --folder-ids # Delete folders (to trash) -│ └── bulk-create --project-code --folders # Create/update folders +│ ├── list --project-code # List folders +│ ├── get --project-code --folder-id # Get folder +│ ├── tree --project-code # Get folder tree +│ ├── path --project-code --folder-id # Get folder path +│ ├── create --project-code --title # Create folder +│ ├── update --project-code --folder-id # Rename folder or edit its comment +│ ├── move --project-code --new-parent-id --items # Move folders +│ ├── copy --project-code --folder-ids # Copy folders +│ ├── delete --project-code --folder-ids # Delete folders (to trash) +│ └── bulk-create --project-code --folders # Create/update folders +├── integrations +│ ├── list --project-code # List a project's integrations +│ ├── get --project-code --integration-id # Get integration +│ ├── jira +│ │ ├── issues +│ │ │ ├── search --project-code --integration-id # Search Jira issues +│ │ │ ├── get --project-code --integration-id --issue-id # Get Jira issue +│ │ │ └── create --project-code --integration-id --summary --issue-type-id # Create Jira issue +│ │ └── issue-types +│ │ ├── list --project-code --integration-id # List Jira issue types +│ │ └── fields --project-code --integration-id --issue-type-id # List an issue type's fields +│ ├── github +│ │ └── issues +│ │ ├── get --project-code --integration-id --issue-number # Get GitHub issue +│ │ └── create --project-code --integration-id --summary # Create GitHub issue +│ └── linear +│ └── issues +│ ├── search --project-code --integration-id # Search Linear issues +│ ├── get --project-code --integration-id --issue-id # Get Linear issue +│ └── create --project-code --integration-id --summary # Create Linear issue ├── milestones -│ ├── list --project-code # List milestones -│ └── create --project-code --title # Create milestone +│ ├── list --project-code # List milestones +│ ├── get --project-code --milestone-id # Get milestone +│ ├── create --project-code --title # Create milestone +│ ├── update --project-code --milestone-id # Rename milestone +│ ├── archive --project-code --milestone-id # Archive milestone, closing its plans and runs +│ ├── unarchive --project-code --milestone-id # Unarchive milestone +│ └── delete --project-code --milestone-id # Delete milestone with its plans and runs (to trash) ├── projects -│ ├── list # List all projects -│ ├── get --project-code # Get project by code -│ ├── create --code --title # Create project -│ ├── update --project-code # Update project (owner/admin) +│ ├── list # List all projects +│ ├── get --project-code # Get project +│ ├── create --code --title # Create project +│ ├── update --project-code # Update project (owner/admin) │ └── users -│ └── list --project-code # List users with access to project +│ └── list --project-code # List users with access to project ├── requirements -│ ├── list --project-code # List requirements -│ ├── get --project-code --requirement-id # Get requirement -│ ├── create --project-code --text # Create requirement -│ ├── update --project-code --requirement-id # Update requirement -│ └── delete --project-code --requirement-id # Delete requirement +│ ├── list --project-code # List requirements +│ ├── get --project-code --requirement-id # Get requirement +│ ├── create --project-code --text # Create requirement +│ ├── update --project-code --requirement-id # Update requirement +│ └── delete --project-code --requirement-id # Delete requirement ├── results -│ ├── create --project-code --run-id --tcase-id --status # Create result -│ └── batch-create --project-code --run-id --items # Batch create results +│ ├── get --project-code --run-id --result-id # Get result +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ ├── batch-create --project-code --run-id --items # Batch create results +│ └── update --project-code --run-id --result-id # Edit a result's comment, time taken or links ├── runs -│ ├── create --project-code --title --type --query-plans # Create run -│ ├── list --project-code # List runs -│ ├── clone --project-code --run-id --title # Clone run -│ ├── close --project-code --run-id # Close run +│ ├── list --project-code # List runs +│ ├── list-assigned # List open runs assigned to you +│ ├── open-status-counts --project-code # Status counts of open runs +│ ├── get --project-code --run-id # Get run +│ ├── selections --project-code --run-id # Get a run's query plans +│ ├── folders --project-code --run-id # Get a run's folder tree +│ ├── status-counts --project-code --run-id # Count a run's test cases by status +│ ├── preview --project-code --run-type --query-plans # Preview a selection's test cases +│ ├── create --project-code --title --type --query-plans # Create run +│ ├── clone --project-code --run-id --title # Clone run +│ ├── update --project-code --run-id # Update run +│ ├── close --project-code --run-id # Close run +│ ├── reopen --project-code --run-id # Reopen run +│ ├── bulk-close --project-code --run-ids # Close runs +│ ├── bulk-reopen --project-code --run-ids # Reopen runs +│ ├── move --project-code --run-ids --milestone-id # Move runs to milestone +│ ├── delete --project-code --run-ids # Delete runs (to trash) │ ├── test-cases -│ │ ├── list --project-code --run-id # List test cases in run -│ │ └── get --project-code --run-id --tcase-id # Get test case in run +│ │ ├── list --project-code --run-id # List test cases in run +│ │ ├── get --project-code --run-id --tcase-id # Get test case in run +│ │ └── update-version --project-code --run-id --tcase-id # Move to the latest test case version │ └── logs -│ └── create --project-code --run-id --comment # Create run log +│ ├── list --project-code --run-id # List run logs +│ └── create --project-code --run-id --comment # Create run log ├── settings -│ ├── list-statuses # List result statuses -│ └── update-statuses --statuses # Update custom statuses +│ ├── list-statuses # List result statuses +│ └── update-statuses --statuses # Update custom statuses ├── shared-preconditions -│ ├── list --project-code # List shared preconditions -│ ├── get --project-code --id # Get shared precondition -│ ├── create --project-code --title --text # Create shared precondition -│ ├── update --project-code --id # Update shared precondition -│ └── delete --project-code --id # Delete shared precondition +│ ├── list --project-code # List shared preconditions +│ ├── get --project-code --id # Get shared precondition +│ ├── create --project-code --title --text # Create shared precondition +│ ├── update --project-code --id # Update shared precondition +│ └── delete --project-code --id # Delete shared precondition ├── shared-steps -│ ├── list --project-code # List shared steps -│ ├── get --project-code --id # Get shared step -│ ├── create --project-code --title --sub-steps # Create shared step -│ ├── update --project-code --id # Update shared step -│ └── delete --project-code --id # Delete shared step +│ ├── list --project-code # List shared steps +│ ├── get --project-code --id # Get shared step +│ ├── create --project-code --title --sub-steps # Create shared step +│ ├── update --project-code --id # Update shared step +│ └── delete --project-code --id # Delete shared step ├── tags -│ ├── list --project-code # List tags -│ ├── create --project-code --title # Create tag -│ ├── update --project-code --tag-id --title # Update tag -│ ├── rename --project-code --tags # Rename several tags -│ └── delete --project-code --tag-ids # Delete tags +│ ├── list --project-code # List tags +│ ├── create --project-code --title # Create tag +│ ├── update --project-code --tag-id --title # Update tag +│ ├── rename --project-code --tags # Rename several tags +│ └── delete --project-code --tag-ids # Delete tags ├── test-cases -│ ├── list --project-code # List test cases -│ ├── get --project-code --tcase-id # Get test case -│ ├── get-version --project-code --tcase-id --tcase-version # Get a test case version -│ ├── count --project-code # Count test cases -│ ├── create --project-code --title --type --folder-id --priority # Create test case -│ ├── update --project-code --tcase-id # Update test case -│ ├── rollback --project-code --tcase-id --tcase-version # Roll back to a version -│ ├── move --project-code --new-parent-id --items # Move test cases -│ ├── copy --project-code --tcase-ids --target-folder-id # Copy test cases -│ ├── update-priority --project-code --tcase-ids --priority # Set priority -│ ├── update-tags --project-code --tcase-ids # Add/remove tags -│ ├── update-requirements --project-code --tcase-ids # Add/remove requirements -│ ├── delete --project-code --tcase-ids # Delete test cases (to trash) +│ ├── list --project-code # List test cases +│ ├── count --project-code # Count test cases +│ ├── get --project-code --tcase-id # Get test case +│ ├── get-version --project-code --tcase-id --tcase-version # Get a test case version +│ ├── create --project-code --title --type --folder-id --priority # Create test case +│ ├── copy --project-code --tcase-ids --target-folder-id # Copy test cases +│ ├── update --project-code --tcase-id # Update test case +│ ├── rollback --project-code --tcase-id --tcase-version # Roll back to a version +│ ├── move --project-code --new-parent-id --items # Move test cases +│ ├── update-priority --project-code --tcase-ids --priority # Set priority +│ ├── update-tags --project-code --tcase-ids # Add/remove tags +│ ├── update-requirements --project-code --tcase-ids # Add/remove requirements +│ ├── delete --project-code --tcase-ids # Delete test cases (to trash) │ └── results -│ └── list --project-code --tcase-id # List a test case's result history +│ └── list --project-code --tcase-id # List a test case's result history ├── test-plans -│ └── create --project-code --title --runs # Create test plan +│ ├── list --project-code # List test plans +│ ├── get --project-code --plan-id # Get test plan with its runs +│ ├── create --project-code --title --runs # Create test plan +│ ├── update --project-code --plan-id # Update test plan +│ ├── update-run --project-code --plan-id --run-id # Update a run of a test plan +│ ├── close --project-code --plan-id # Close test plan and its runs +│ ├── reopen --project-code --plan-id # Reopen test plan and its runs +│ ├── move --project-code --plan-ids --milestone-id # Move test plans to milestone +│ └── delete --project-code --plan-id # Delete test plan with its runs (to trash) ├── trash -│ ├── list --project-code # List trash entries -│ └── restore --project-code --ids # Restore trash entries +│ ├── list --project-code # List trash entries +│ └── restore --project-code --ids # Restore trash entries └── users - └── list # List all users + └── list # List all users ``` Note: `qasphere api files upload --file ...` uses the public batch upload endpoint internally and returns the first uploaded file from that response. -Deleted folders and test cases go to the project's trash, where `qasphere api trash restore` brings them back within 60 days. Deleted tags, requirements, shared steps, shared preconditions and configurations can't be restored. +Deleted folders, test cases, runs, test plans and milestones go to the project's trash, where `qasphere api trash restore` brings them back within 60 days. A test plan comes back with its runs, and a milestone with its test plans and runs. Deleted tags, requirements, shared steps, shared preconditions and configurations can't be restored. + +`test-cases list` and `test-cases count` also filter by custom field, with `--cf- ` (system names come from `custom-fields list`). Repeat the option for several values of one field, which match with OR: `--cf-automation Automated --cf-automation Partial`. ### Pagination -List commands that take `--offset` and `--limit` return one page, as `{"total": N, "offset": O, "limit": L, "data": [...]}`. `--offset` defaults to 0 and `--limit` to 100, at most 1000; `--limit 0` returns only the total. Use `--offset` to read further pages. Most also take `--sort-field` and `--sort-order` (asc/desc). The `audit-logs list` command uses cursor-based pagination with `--after` and `--count` instead. +List commands that take `--offset` and `--limit` return one page, as `{"total": N, "offset": O, "limit": L, "data": [...]}`. `--offset` defaults to 0 and `--limit` to 100, at most 1000; `--limit 0` returns only the total. Use `--offset` to read further pages. Most also take `--sort-field` and `--sort-order` (asc/desc). The `audit-logs list` command uses cursor-based pagination with `--after` and `--count` instead, and the Jira and Linear issue searches take `--next-page-token`: pass each response's `nextPageToken` until `isLast` is `true`. ## Commands: `junit-upload`, `playwright-json-upload`, `allure-upload` diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md index d26edde..bc983bf 100644 --- a/skills/qas-cli/SKILL.md +++ b/skills/qas-cli/SKILL.md @@ -128,112 +128,164 @@ Each line lists the options a command requires. Commands that send a request bod ``` qasphere api ├── ai-rules -│ └── list --project-code # List AI rules that apply in a project +│ └── list --project-code # List AI rules that apply in a project ├── audit-logs -│ └── list # List audit log entries +│ └── list # List audit log entries ├── configurations -│ ├── list --project-code # List configurations -│ ├── get --project-code --configuration-id # Get configuration -│ ├── create --project-code --title # Create configuration -│ ├── update --project-code --configuration-id --title # Rename configuration -│ └── delete --project-code --configuration-id # Delete configuration +│ ├── list --project-code # List configurations +│ ├── get --project-code --configuration-id # Get configuration +│ ├── create --project-code --title # Create configuration +│ ├── update --project-code --configuration-id --title # Rename configuration +│ └── delete --project-code --configuration-id # Delete configuration ├── custom-fields -│ └── list --project-code # List custom fields +│ └── list --project-code # List custom fields ├── files -│ └── upload --file # Upload a file attachment +│ └── upload --file # Upload a file attachment ├── folders -│ ├── list --project-code # List folders -│ ├── get --project-code --folder-id # Get folder -│ ├── tree --project-code # Get folder tree -│ ├── path --project-code --folder-id # Get folder path -│ ├── create --project-code --title # Create folder -│ ├── update --project-code --folder-id # Rename folder or edit its comment -│ ├── move --project-code --new-parent-id --items # Move folders -│ ├── copy --project-code --folder-ids # Copy folders -│ ├── delete --project-code --folder-ids # Delete folders (to trash) -│ └── bulk-create --project-code --folders # Create/update folders +│ ├── list --project-code # List folders +│ ├── get --project-code --folder-id # Get folder +│ ├── tree --project-code # Get folder tree +│ ├── path --project-code --folder-id # Get folder path +│ ├── create --project-code --title # Create folder +│ ├── update --project-code --folder-id # Rename folder or edit its comment +│ ├── move --project-code --new-parent-id --items # Move folders +│ ├── copy --project-code --folder-ids # Copy folders +│ ├── delete --project-code --folder-ids # Delete folders (to trash) +│ └── bulk-create --project-code --folders # Create/update folders +├── integrations +│ ├── list --project-code # List a project's integrations +│ ├── get --project-code --integration-id # Get integration +│ ├── jira +│ │ ├── issues +│ │ │ ├── search --project-code --integration-id # Search Jira issues +│ │ │ ├── get --project-code --integration-id --issue-id # Get Jira issue +│ │ │ └── create --project-code --integration-id --summary --issue-type-id # Create Jira issue +│ │ └── issue-types +│ │ ├── list --project-code --integration-id # List Jira issue types +│ │ └── fields --project-code --integration-id --issue-type-id # List an issue type's fields +│ ├── github +│ │ └── issues +│ │ ├── get --project-code --integration-id --issue-number # Get GitHub issue +│ │ └── create --project-code --integration-id --summary # Create GitHub issue +│ └── linear +│ └── issues +│ ├── search --project-code --integration-id # Search Linear issues +│ ├── get --project-code --integration-id --issue-id # Get Linear issue +│ └── create --project-code --integration-id --summary # Create Linear issue ├── milestones -│ ├── list --project-code # List milestones -│ └── create --project-code --title # Create milestone +│ ├── list --project-code # List milestones +│ ├── get --project-code --milestone-id # Get milestone +│ ├── create --project-code --title # Create milestone +│ ├── update --project-code --milestone-id # Rename milestone +│ ├── archive --project-code --milestone-id # Archive milestone, closing its plans and runs +│ ├── unarchive --project-code --milestone-id # Unarchive milestone +│ └── delete --project-code --milestone-id # Delete milestone with its plans and runs (to trash) ├── projects -│ ├── list # List all projects -│ ├── get --project-code # Get project by code -│ ├── create --code --title # Create project -│ ├── update --project-code # Update project (owner/admin) +│ ├── list # List all projects +│ ├── get --project-code # Get project +│ ├── create --code --title # Create project +│ ├── update --project-code # Update project (owner/admin) │ └── users -│ └── list --project-code # List users with access to project +│ └── list --project-code # List users with access to project ├── requirements -│ ├── list --project-code # List requirements -│ ├── get --project-code --requirement-id # Get requirement -│ ├── create --project-code --text # Create requirement -│ ├── update --project-code --requirement-id # Update requirement -│ └── delete --project-code --requirement-id # Delete requirement +│ ├── list --project-code # List requirements +│ ├── get --project-code --requirement-id # Get requirement +│ ├── create --project-code --text # Create requirement +│ ├── update --project-code --requirement-id # Update requirement +│ └── delete --project-code --requirement-id # Delete requirement ├── results -│ ├── create --project-code --run-id --tcase-id --status # Create result -│ └── batch-create --project-code --run-id --items # Batch create results +│ ├── get --project-code --run-id --result-id # Get result +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ ├── batch-create --project-code --run-id --items # Batch create results +│ └── update --project-code --run-id --result-id # Edit a result's comment, time taken or links ├── runs -│ ├── create --project-code --title --type --query-plans # Create run -│ ├── list --project-code # List runs -│ ├── clone --project-code --run-id --title # Clone run -│ ├── close --project-code --run-id # Close run +│ ├── list --project-code # List runs +│ ├── list-assigned # List open runs assigned to you +│ ├── open-status-counts --project-code # Status counts of open runs +│ ├── get --project-code --run-id # Get run +│ ├── selections --project-code --run-id # Get a run's query plans +│ ├── folders --project-code --run-id # Get a run's folder tree +│ ├── status-counts --project-code --run-id # Count a run's test cases by status +│ ├── preview --project-code --run-type --query-plans # Preview a selection's test cases +│ ├── create --project-code --title --type --query-plans # Create run +│ ├── clone --project-code --run-id --title # Clone run +│ ├── update --project-code --run-id # Update run +│ ├── close --project-code --run-id # Close run +│ ├── reopen --project-code --run-id # Reopen run +│ ├── bulk-close --project-code --run-ids # Close runs +│ ├── bulk-reopen --project-code --run-ids # Reopen runs +│ ├── move --project-code --run-ids --milestone-id # Move runs to milestone +│ ├── delete --project-code --run-ids # Delete runs (to trash) │ ├── test-cases -│ │ ├── list --project-code --run-id # List test cases in run -│ │ └── get --project-code --run-id --tcase-id # Get test case in run +│ │ ├── list --project-code --run-id # List test cases in run +│ │ ├── get --project-code --run-id --tcase-id # Get test case in run +│ │ └── update-version --project-code --run-id --tcase-id # Move to the latest test case version │ └── logs -│ └── create --project-code --run-id --comment # Append a run-level log message (e.g. CI/CD or automation framework output) to a test run. +│ ├── list --project-code --run-id # List run logs +│ └── create --project-code --run-id --comment # Append a run-level log message (e.g. CI/CD or automation framework output) to a test run. ├── settings -│ ├── list-statuses # List result statuses -│ └── update-statuses --statuses # Update custom statuses +│ ├── list-statuses # List result statuses +│ └── update-statuses --statuses # Update custom statuses ├── shared-preconditions -│ ├── list --project-code # List shared preconditions -│ ├── get --project-code --id # Get shared precondition -│ ├── create --project-code --title --text # Create shared precondition -│ ├── update --project-code --id # Update shared precondition -│ └── delete --project-code --id # Delete shared precondition +│ ├── list --project-code # List shared preconditions +│ ├── get --project-code --id # Get shared precondition +│ ├── create --project-code --title --text # Create shared precondition +│ ├── update --project-code --id # Update shared precondition +│ └── delete --project-code --id # Delete shared precondition ├── shared-steps -│ ├── list --project-code # List shared steps -│ ├── get --project-code --id # Get shared step -│ ├── create --project-code --title --sub-steps # Create shared step -│ ├── update --project-code --id # Update shared step -│ └── delete --project-code --id # Delete shared step +│ ├── list --project-code # List shared steps +│ ├── get --project-code --id # Get shared step +│ ├── create --project-code --title --sub-steps # Create shared step +│ ├── update --project-code --id # Update shared step +│ └── delete --project-code --id # Delete shared step ├── tags -│ ├── list --project-code # List tags -│ ├── create --project-code --title # Create tag -│ ├── update --project-code --tag-id --title # Update tag -│ ├── rename --project-code --tags # Rename several tags -│ └── delete --project-code --tag-ids # Delete tags +│ ├── list --project-code # List tags +│ ├── create --project-code --title # Create tag +│ ├── update --project-code --tag-id --title # Update tag +│ ├── rename --project-code --tags # Rename several tags +│ └── delete --project-code --tag-ids # Delete tags ├── test-cases -│ ├── list --project-code # List test cases -│ ├── get --project-code --tcase-id # Get test case -│ ├── get-version --project-code --tcase-id --tcase-version # Get a test case version -│ ├── count --project-code # Count test cases -│ ├── create --project-code --title --type --folder-id --priority # Create test case -│ ├── update --project-code --tcase-id # Update test case -│ ├── rollback --project-code --tcase-id --tcase-version # Roll back to a version -│ ├── move --project-code --new-parent-id --items # Move test cases -│ ├── copy --project-code --tcase-ids --target-folder-id # Copy test cases -│ ├── update-priority --project-code --tcase-ids --priority # Set priority -│ ├── update-tags --project-code --tcase-ids # Add/remove tags -│ ├── update-requirements --project-code --tcase-ids # Add/remove requirements -│ ├── delete --project-code --tcase-ids # Delete test cases (to trash) +│ ├── list --project-code # List test cases +│ ├── count --project-code # Count test cases +│ ├── get --project-code --tcase-id # Get test case +│ ├── get-version --project-code --tcase-id --tcase-version # Get a test case version +│ ├── create --project-code --title --type --folder-id --priority # Create test case +│ ├── copy --project-code --tcase-ids --target-folder-id # Copy test cases +│ ├── update --project-code --tcase-id # Update test case +│ ├── rollback --project-code --tcase-id --tcase-version # Roll back to a version +│ ├── move --project-code --new-parent-id --items # Move test cases +│ ├── update-priority --project-code --tcase-ids --priority # Set priority +│ ├── update-tags --project-code --tcase-ids # Add/remove tags +│ ├── update-requirements --project-code --tcase-ids # Add/remove requirements +│ ├── delete --project-code --tcase-ids # Delete test cases (to trash) │ └── results -│ └── list --project-code --tcase-id # List a test case's result history +│ └── list --project-code --tcase-id # List a test case's result history ├── test-plans -│ └── create --project-code --title --runs # Create test plan +│ ├── list --project-code # List test plans +│ ├── get --project-code --plan-id # Get test plan with its runs +│ ├── create --project-code --title --runs # Create test plan +│ ├── update --project-code --plan-id # Update test plan +│ ├── update-run --project-code --plan-id --run-id # Update a run of a test plan +│ ├── close --project-code --plan-id # Close test plan and its runs +│ ├── reopen --project-code --plan-id # Reopen test plan and its runs +│ ├── move --project-code --plan-ids --milestone-id # Move test plans to milestone +│ └── delete --project-code --plan-id # Delete test plan with its runs (to trash) ├── trash -│ ├── list --project-code # List trash entries -│ └── restore --project-code --ids # Restore trash entries +│ ├── list --project-code # List trash entries +│ └── restore --project-code --ids # Restore trash entries └── users - └── list # List all users + └── list # List all users ``` Note: `qasphere api files upload --file ...` uses the public batch upload endpoint internally and returns the first uploaded file from that response. -Deleted folders and test cases go to the project's trash, where `qasphere api trash restore` brings them back within 60 days. Deleted tags, requirements, shared steps, shared preconditions and configurations can't be restored. +Deleted folders, test cases, runs, test plans and milestones go to the project's trash, where `qasphere api trash restore` brings them back within 60 days. A test plan comes back with its runs, and a milestone with its test plans and runs. Deleted tags, requirements, shared steps, shared preconditions and configurations can't be restored. + +`test-cases list` and `test-cases count` also filter by custom field, with `--cf- ` (system names come from `custom-fields list`). Repeat the option for several values of one field, which match with OR: `--cf-automation Automated --cf-automation Partial`. ### Pagination -List commands that take `--offset` and `--limit` return one page, as `{"total": N, "offset": O, "limit": L, "data": [...]}`. `--offset` defaults to 0 and `--limit` to 100, at most 1000; `--limit 0` returns only the total. Use `--offset` to read further pages. Most also take `--sort-field` and `--sort-order` (asc/desc). The `audit-logs list` command uses cursor-based pagination with `--after` and `--count` instead. +List commands that take `--offset` and `--limit` return one page, as `{"total": N, "offset": O, "limit": L, "data": [...]}`. `--offset` defaults to 0 and `--limit` to 100, at most 1000; `--limit 0` returns only the total. Use `--offset` to read further pages. Most also take `--sort-field` and `--sort-order` (asc/desc). The `audit-logs list` command uses cursor-based pagination with `--after` and `--count` instead, and the Jira and Linear issue searches take `--next-page-token`: pass each response's `nextPageToken` until `isLast` is `true`. ### JSON Body Commands @@ -262,13 +314,13 @@ qasphere junit-upload -r https://qas.eu1.qasphere.com/project/PRJ/run/23 ./test- # Create a run and capture the run ID from the JSON response RUN_ID=$(qasphere api runs create \ --project-code PRJ --title "CI Build $BUILD_NUMBER" \ - --type static --query-plans '[{"tcaseIds": ["abc123", "def456"]}]' \ + --type static --query-plans '[{"tcaseIds": ["4", "5"]}]' \ | jq -r '.id') # Upload results using the captured run ID qasphere api results batch-create \ --project-code PRJ --run-id "$RUN_ID" \ - --items '[{"tcaseId": "abc123", "status": "passed"}, {"tcaseId": "def456", "status": "failed", "comment": "Timeout"}]' + --items '[{"tcaseId": "4", "status": "passed"}, {"tcaseId": "5", "status": "failed", "comment": "Timeout"}]' # Close the run qasphere api runs close --project-code PRJ --run-id "$RUN_ID" diff --git a/src/api/index.ts b/src/api/index.ts index ebf5877..1927a12 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,6 +4,7 @@ import { createConfigurationApi } from './configurations' import { createCustomFieldApi } from './custom-fields' import { createFileApi } from './file' import { createFolderApi } from './folders' +import { createIntegrationApi } from './integrations' import { createMilestoneApi } from './milestones' import { createProjectApi } from './projects' import { createRequirementApi } from './requirements' @@ -35,6 +36,7 @@ const getApi = (fetcher: typeof fetch) => { customFields: createCustomFieldApi(fetcher), files: createFileApi(fetcher), folders: createFolderApi(fetcher), + integrations: createIntegrationApi(fetcher), milestones: createMilestoneApi(fetcher), projects: createProjectApi(fetcher), requirements: createRequirementApi(fetcher), diff --git a/src/api/integrations.ts b/src/api/integrations.ts new file mode 100644 index 0000000..0119b15 --- /dev/null +++ b/src/api/integrations.ts @@ -0,0 +1,223 @@ +import { z } from 'zod' +import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface ProjectIntegration { + id: string + name: string + type: 'jira' | 'github' | 'linear' | 'custom' + config: Record | null + relationConfig: Record | null +} + +export interface JiraIssue { + id: string + key: string + summary: string + description: string + url: string +} + +export interface GithubIssue { + id: number + number: number + title: string + body: string + url: string +} + +export interface LinearIssue { + id: string + identifier: string + title: string + url: string +} + +// Jira and Linear page their searches by cursor, so there is no offset or total. +export interface CursorPage { + data: T[] + nextPageToken: string | null + isLast: boolean +} + +const searchText = z.string().max(255, 'search must be at most 255 characters') + +// Jira counts UTF-16 code units, as z.string().max() does. +const jiraSummary = z + .string() + .min(1, 'summary must not be empty') + .max(255, 'summary must be at most 255 characters') + +const jiraDescription = z.string().max(32767, 'description must be at most 32767 characters') + +// GitHub and Linear count characters, so an emoji counts once. +const maxChars = (max: number, field: string) => + z.string().refine((s) => [...s].length <= max, `${field} must be at most ${max} characters`) + +export const SearchJiraIssuesRequestSchema = z.object({ + search: searchText.optional(), + types: z.array(z.string()).optional(), + fields: z.array(z.string()).optional(), + limit: z + .number() + .int() + .min(1, 'limit must be at least 1') + .max(5000, 'limit must be at most 5000') + .optional(), + nextPageToken: z.string().optional(), + sortField: sortFieldParam, + sortOrder: sortOrderParam, +}) + +export type SearchJiraIssuesRequest = z.infer + +export const CreateJiraIssueRequestSchema = z.object({ + summary: jiraSummary, + description: jiraDescription.optional(), + issueTypeId: z.string().min(1, 'issueTypeId must not be empty'), + parentId: z.string().min(1, 'parentId must not be empty').optional(), + otherFields: z.record(z.string(), z.unknown()).optional(), +}) + +export type CreateJiraIssueRequest = z.infer + +export interface CreateJiraIssueResponse { + id: string + key: string + url: string + attachmentUploadErrors: string[] | null + createdAs: 'user' | 'integration' +} + +// GitHub and Linear take the same create body. +export const CreateIssueRequestSchema = z.object({ + summary: maxChars(255, 'summary').refine((s) => s.length > 0, 'summary must not be empty'), + description: maxChars(32767, 'description').optional(), +}) + +export type CreateIssueRequest = z.infer + +export const SearchLinearIssuesRequestSchema = z.object({ + search: searchText.optional(), + limit: z + .number() + .int() + .min(1, 'limit must be at least 1') + .max(250, 'limit must be at most 250') + .optional(), + nextPageToken: z.string().optional(), +}) + +export type SearchLinearIssuesRequest = z.infer + +export const createIntegrationApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + const base = (projectCode: ResourceId) => `/api/public/v0/project/${projectCode}/integration` + const tracker = (projectCode: ResourceId, type: string, integrationId: ResourceId) => + `${base(projectCode)}/${type}/${integrationId}` + + return { + list: (projectCode: ResourceId) => + fetcher(base(projectCode)).then((r) => + jsonResponse<{ integrations: ProjectIntegration[] }>(r) + ), + + get: (projectCode: ResourceId, integrationId: ResourceId) => + fetcher(`${base(projectCode)}/${integrationId}`).then((r) => + jsonResponse(r) + ), + + jira: { + getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueId: ResourceId) => + fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue/${issueId}`).then((r) => + jsonResponse<{ issue: JiraIssue }>(r) + ), + + searchIssues: async ( + projectCode: ResourceId, + integrationId: ResourceId, + params?: SearchJiraIssuesRequest + ) => { + const validated = params ? validateRequest(params, SearchJiraIssuesRequestSchema) : {} + return fetcher( + appendSearchParams(`${tracker(projectCode, 'jira', integrationId)}/issue`, validated) + ).then((r) => jsonResponse>(r)) + }, + + listIssueTypes: (projectCode: ResourceId, integrationId: ResourceId) => + fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue-type`).then((r) => + jsonResponse<{ issueTypes: Record[] }>(r) + ), + + getIssueFields: ( + projectCode: ResourceId, + integrationId: ResourceId, + issueTypeId: ResourceId + ) => + fetcher( + `${tracker(projectCode, 'jira', integrationId)}/issue-type/${issueTypeId}/field-metadata` + ).then((r) => jsonResponse<{ fields: Record[] }>(r)), + + createIssue: async ( + projectCode: ResourceId, + integrationId: ResourceId, + req: CreateJiraIssueRequest + ) => { + const validated = validateRequest(req, CreateJiraIssueRequestSchema) + return fetcher(`${tracker(projectCode, 'jira', integrationId)}/issue`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + }, + + github: { + getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueNumber: ResourceId) => + fetcher(`${tracker(projectCode, 'github', integrationId)}/issue/${issueNumber}`).then((r) => + jsonResponse<{ issue: GithubIssue }>(r) + ), + + createIssue: async ( + projectCode: ResourceId, + integrationId: ResourceId, + req: CreateIssueRequest + ) => { + const validated = validateRequest(req, CreateIssueRequestSchema) + return fetcher(`${tracker(projectCode, 'github', integrationId)}/issue`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: number; url: string }>(r)) + }, + }, + + linear: { + getIssue: (projectCode: ResourceId, integrationId: ResourceId, issueId: ResourceId) => + fetcher(`${tracker(projectCode, 'linear', integrationId)}/issue/${issueId}`).then((r) => + jsonResponse<{ issue: LinearIssue }>(r) + ), + + searchIssues: async ( + projectCode: ResourceId, + integrationId: ResourceId, + params?: SearchLinearIssuesRequest + ) => { + const validated = params ? validateRequest(params, SearchLinearIssuesRequestSchema) : {} + return fetcher( + appendSearchParams(`${tracker(projectCode, 'linear', integrationId)}/issue`, validated) + ).then((r) => jsonResponse>(r)) + }, + + createIssue: async ( + projectCode: ResourceId, + integrationId: ResourceId, + req: CreateIssueRequest + ) => { + const validated = validateRequest(req, CreateIssueRequestSchema) + return fetcher(`${tracker(projectCode, 'linear', integrationId)}/issue`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ id: string; identifier: string; url: string }>(r)) + }, + }, + } +} diff --git a/src/api/milestones.ts b/src/api/milestones.ts index 46a1017..cde7f0d 100644 --- a/src/api/milestones.ts +++ b/src/api/milestones.ts @@ -1,5 +1,11 @@ import { z } from 'zod' -import { PaginatedResponse, ResourceId, paginationParams, validateRequest } from './schemas' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' export interface Milestone { @@ -8,6 +14,12 @@ export interface Milestone { createdAt: string updatedAt: string archivedAt: string | null + archivedBy: number | null +} + +export interface ArchivedMilestone extends Milestone { + closedPlans: number + closedRuns: number } export const ListMilestonesRequestSchema = z.object({ @@ -26,6 +38,12 @@ export const CreateMilestoneRequestSchema = z.object({ export type CreateMilestoneRequest = z.infer +export const UpdateMilestoneRequestSchema = z.object({ + title: CreateMilestoneRequestSchema.shape.title.optional(), +}) + +export type UpdateMilestoneRequest = z.infer + export const createMilestoneApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { @@ -43,5 +61,37 @@ export const createMilestoneApi = (fetcher: typeof fetch) => { body: JSON.stringify(validated), }).then((r) => jsonResponse<{ id: number }>(r)) }, + + get: (projectCode: ResourceId, milestoneId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`).then((r) => + jsonResponse(r) + ), + + update: async ( + projectCode: ResourceId, + milestoneId: ResourceId, + req: UpdateMilestoneRequest + ) => { + const validated = validateRequest(req, UpdateMilestoneRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + archive: (projectCode: ResourceId, milestoneId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}/archive`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + unarchive: (projectCode: ResourceId, milestoneId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}/unarchive`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + delete: (projectCode: ResourceId, milestoneId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/milestone/${milestoneId}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), } } diff --git a/src/api/results.ts b/src/api/results.ts index 00f1724..a6731f4 100644 --- a/src/api/results.ts +++ b/src/api/results.ts @@ -1,25 +1,32 @@ import { z } from 'zod' -import { ResourceId, validateRequest } from './schemas' +import { + MAX_BULK_ITEMS, + ResourceId, + httpUrlSchema, + resultStatusEnum, + validateRequest, +} from './schemas' import { jsonResponse, withJson } from './utils' -const resultStatusEnum = z.enum([ - 'passed', - 'failed', - 'blocked', - 'skipped', - 'open', - 'custom1', - 'custom2', - 'custom3', - 'custom4', -]) - -export const resultLinksSchema = z.array( - z.object({ - text: z.string(), - url: z.string().url(), - }) -) +// A result holds at most 5 links, and a batch request at most 100 across its results. +export const MAX_RESULT_LINKS = 5 +export const MAX_REQUEST_LINKS = 100 + +export const resultLinksSchema = z + .array( + z.object({ + integrationId: z.string().min(1, 'integrationId must not be empty'), + url: httpUrlSchema, + text: z.string(), + // The issue's ID in Jira, GitHub (the issue number) or Linear, which they need for the + // reference back to the result; custom integrations' links don't need it. + meta: z + .object({ id: z.string().min(1, 'meta.id must not be empty') }) + .strict() + .optional(), + }) + ) + .max(MAX_RESULT_LINKS, `Must contain at most ${MAX_RESULT_LINKS} links`) export const CreateResultsRequestItemSchema = z.object({ tcaseId: z.string(), @@ -32,7 +39,14 @@ export const CreateResultsRequestItemSchema = z.object({ export type CreateResultsRequestItem = z.infer export const CreateResultsRequestSchema = z.object({ - items: z.array(CreateResultsRequestItemSchema).min(1, 'Must contain at least one result item'), + items: z + .array(CreateResultsRequestItemSchema) + .min(1, 'Must contain at least one result item') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} result items`) + .refine( + (items) => items.reduce((n, item) => n + (item.links?.length ?? 0), 0) <= MAX_REQUEST_LINKS, + `Must contain at most ${MAX_REQUEST_LINKS} links across the result items` + ), }) export type CreateResultsRequest = z.infer @@ -50,6 +64,34 @@ export interface CreateResultResponse { id: number } +export interface Result { + id: number + tcaseId: string + status: string + comment: string + links: { + id: number + resultId: number + integrationId: string | null + integration: Record | null + url: string + text: string + meta: Record | null + }[] + [key: string]: unknown +} + +export const UpdateResultRequestSchema = z.object({ + comment: z.string().optional(), + timeTaken: z.number().int().nonnegative('timeTaken must be a non-negative integer').optional(), + createLinks: resultLinksSchema.optional(), + deleteLinkIds: z + .array(z.number().int().positive('link IDs must be positive integers')) + .optional(), +}) + +export type UpdateResultRequest = z.infer + export const createResultApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { @@ -73,5 +115,23 @@ export const createResultApi = (fetcher: typeof fetch) => { body: JSON.stringify(validated), }).then((r) => jsonResponse(r)) }, + + get: (projectCode: ResourceId, runId: ResourceId, resultId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/result/${resultId}`).then((r) => + jsonResponse(r) + ), + + update: async ( + projectCode: ResourceId, + runId: ResourceId, + resultId: ResourceId, + req: UpdateResultRequest + ) => { + const validated = validateRequest(req, UpdateResultRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/result/${resultId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, } } diff --git a/src/api/runs.ts b/src/api/runs.ts index 81ce434..3d0bd40 100644 --- a/src/api/runs.ts +++ b/src/api/runs.ts @@ -3,8 +3,13 @@ import { MessageResponse, PaginatedResponse, ResourceId, + automationResultEnum, + filterOpEnum, + limitParam, + offsetParam, paginationParams, priorityEnum, + resultStatusEnum, validateRequest, } from './schemas' import type { Folder } from './folders' @@ -22,64 +27,90 @@ export interface RunTCase { folder?: Folder } -export const QueryPlanSchema = z - .object({ - tcaseIds: z.array(z.string()).optional(), - folderIds: z.array(z.number().int().positive()).optional(), - tagIds: z.array(z.number().int().positive()).optional(), - priorities: z.array(z.enum(['low', 'medium', 'high'])).optional(), - }) - .strict() - .refine((plan) => Object.values(plan).some((v) => v !== undefined), { - message: - 'Each query plan must specify at least one filter (tcaseIds, folderIds, tagIds, or priorities)', - }) +const queryPlanFields = { + tcaseIds: z.array(z.string()).optional(), + folderIds: z.array(z.number().int().positive()).optional(), + tagIds: z.array(z.number().int().positive()).optional(), + priorities: z.array(z.enum(['low', 'medium', 'high'])).optional(), +} -export const QueryPlansSchema = z.array(QueryPlanSchema).min(1, { - message: - 'Must contain at least one query plan. Each plan selects test cases to include in the run. ' + - 'Example: [{"tcaseIds": ["abc123"]}]', -}) +type QueryPlan = z.infer> -function validateQueryPlans( - run: { type: string; queryPlans: z.infer[] }, - ctx: z.RefinementCtx -) { - if (run.type !== 'live' && run.queryPlans.length > 1) { +// The API's selection rules for every run type. A filter counts once sent, even empty. +function validateSelection(plans: QueryPlan[], ctx: z.RefinementCtx) { + plans.forEach((plan, i) => { + const hasFilters = [plan.folderIds, plan.tagIds, plan.priorities].some((v) => v !== undefined) + if (plan.tcaseIds !== undefined && hasFilters) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [i, 'tcaseIds'], + message: + "tcaseIds can't be combined with folderIds, tagIds or priorities in a query plan, even empty ones", + }) + } + }) + if (plans.length > 1 && plans.some((plan) => plan.tcaseIds !== undefined)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ['queryPlans'], - message: `Run type "${run.type}" supports exactly one query plan, but ${run.queryPlans.length} were provided. Only "live" runs support multiple query plans.`, - }) - } - if (run.type === 'live') { - run.queryPlans.forEach((plan, i) => { - if (plan.tcaseIds !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['queryPlans', i, 'tcaseIds'], - message: - 'tcaseIds is not allowed for "live" runs. Live runs only support filter-based selection (folderIds, tagIds, priorities).', - }) - } + message: 'tcaseIds are only allowed in a single query plan', }) } } +// Live runs select test cases by filters only. +function rejectLiveTCaseIds(runType: string, plans: QueryPlan[], ctx: z.RefinementCtx) { + if (runType !== 'live') return + plans.forEach((plan, i) => { + if (plan.tcaseIds !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['queryPlans', i, 'tcaseIds'], + message: + 'tcaseIds is not allowed for "live" runs. Live runs only support filter-based selection (folderIds, tagIds, priorities).', + }) + } + }) +} + +export const QueryPlanSchema = z.object(queryPlanFields).strict() + +// Update Run takes the id of a query plan to replace (see `runs selections`). +export const UpdateQueryPlanSchema = z + .object({ + id: z.number().int().positive('id must be a positive integer').optional(), + ...queryPlanFields, + }) + .strict() + +export const QueryPlansSchema = z + .array(QueryPlanSchema) + .min(1, { + message: + 'Must contain at least one query plan. Each plan selects test cases to include in the run. ' + + 'Example: [{"tcaseIds": ["4"]}]', + }) + .superRefine(validateSelection) + +// An issue in the project's Jira or Linear integration, by ID or key. +export const integrationIssueIdSchema = z + .string() + .max(255, 'integrationIssueId must be at most 255 characters') + export const CreateRunRequestSchema = z .object({ title: z .string() .min(1, 'title must not be empty') .max(255, 'title must be at most 255 characters'), - description: z.string().max(512, 'description must be at most 512 characters').optional(), + description: z.string().optional(), type: z.enum(['static', 'static_struct', 'live']), milestoneId: z.number().int().positive().optional(), configurationId: z.string().optional(), assignmentId: z.number().int().positive().optional(), + integrationIssueId: integrationIssueIdSchema.optional(), queryPlans: QueryPlansSchema, }) - .superRefine(validateQueryPlans) + .superRefine((run, ctx) => rejectLiveTCaseIds(run.type, run.queryPlans, ctx)) export type CreateRunRequest = z.infer @@ -91,6 +122,8 @@ export const ListRunsRequestSchema = z.object({ ...paginationParams, closed: z.boolean().optional(), milestoneIds: z.array(z.number().int().positive()).optional(), + planIds: z.array(z.number().int().positive()).optional(), + configurationIds: z.array(z.string()).optional(), include: z.array(z.string()).optional(), }) @@ -117,9 +150,11 @@ export const CloneRunRequestSchema = z.object({ .string() .min(1, 'title must not be empty') .max(255, 'title must be at most 255 characters'), - description: z.string().max(512, 'description must be at most 512 characters').optional(), + description: z.string().optional(), milestoneId: z.number().int().positive().optional(), + configurationId: z.string().optional(), assignmentId: z.number().int().positive().optional(), + integrationIssueId: integrationIssueIdSchema.optional(), }) export type CloneRunRequest = z.infer @@ -128,26 +163,122 @@ export interface CloneRunResponse { id: number } -export const ListRunTCasesRequestSchema = z.object({ - ...paginationParams, - search: z.string().optional(), +// Filters shared by a run's test case list, folder tree and status counts. +export const RunTCaseFiltersSchema = z.object({ + search: z.string().max(255, 'search must be at most 255 characters').optional(), tags: z.array(z.number().int().positive()).optional(), + tagsFilterOp: filterOpEnum.optional(), priorities: z.array(priorityEnum).optional(), + status: z.array(resultStatusEnum).optional(), + automationResults: z.array(automationResultEnum).optional(), + automationResultsFilterOp: filterOpEnum.optional(), +}) + +export type RunTCaseFilters = z.infer + +export const ListRunTCasesRequestSchema = RunTCaseFiltersSchema.extend({ + ...paginationParams, include: z.array(z.string()).optional(), }) export type ListRunTCasesRequest = z.infer -export const RunSchema = z +export interface RunDetails { + id: number + title: string + type: string + closedAt: string | null + [key: string]: unknown +} + +export const UpdateRunRequestSchema = z.object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + .optional(), + description: z.string().optional(), + type: z.enum(['static', 'static_struct', 'live']).optional(), + // 0 takes the run out of its milestone, or unassigns it. + milestoneId: z + .number() + .int() + .nonnegative('milestoneId must be a non-negative integer') + .optional(), + assignmentId: z + .number() + .int() + .nonnegative('assignmentId must be a non-negative integer') + .optional(), + // "" removes the configuration, or the issue link. + configurationId: z.string().optional(), + integrationIssueId: integrationIssueIdSchema.optional(), + queryPlans: z + .array(UpdateQueryPlanSchema) + .min(1, 'Must contain at least one query plan') + .superRefine(validateSelection) + .optional(), +}) + +export type UpdateRunRequest = z.infer + +export const RunIdsSchema = z + .array(z.number().int().positive('run IDs must be positive integers')) + .min(1, 'Must contain at least one run ID') + .max(100, 'Must contain at most 100 run IDs') + +export const RunIdsRequestSchema = z.object({ runIds: RunIdsSchema }) + +export type RunIdsRequest = z.infer + +export const MoveRunsRequestSchema = z.object({ + runIds: RunIdsSchema, + // 0 takes the runs out of their milestone. + milestoneId: z.number().int().nonnegative('milestoneId must be a non-negative integer'), +}) + +export type MoveRunsRequest = z.infer + +export const PreviewRunTCasesRequestSchema = z .object({ - title: z.string().min(1).max(255), - description: z.string().optional(), - type: z.enum(['static', 'static_struct', 'live']), - configurationId: z.string().optional(), - assignmentId: z.number().int().positive().optional(), - queryPlans: z.array(QueryPlanSchema).min(1), + runType: z.enum(['static', 'static_struct', 'live']), + queryPlans: QueryPlansSchema, + runId: z.number().int().positive('runId must be a positive integer').optional(), + offset: offsetParam, + limit: limitParam, }) - .superRefine(validateQueryPlans) + .superRefine((req, ctx) => rejectLiveTCaseIds(req.runType, req.queryPlans, ctx)) + +export type PreviewRunTCasesRequest = z.infer + +export const ListRunLogsRequestSchema = z.object(paginationParams) + +export type ListRunLogsRequest = z.infer + +export interface RunLog { + id: string + runId: number + comment: string + apiKey: { id: string; name: string } | null + dismissed: boolean + createdAt: string +} + +export const ListAssignedRunsRequestSchema = z.object(paginationParams) + +export type ListAssignedRunsRequest = z.infer + +export const UpdateRunTCaseVersionRequestSchema = z.object({ + updateToLatestVersion: z.boolean().optional(), + allowRunTCaseRemoval: z.boolean().optional(), +}) + +export type UpdateRunTCaseVersionRequest = z.infer + +export interface UpdateRunTCaseVersionResponse { + message: string + removed: boolean +} export const createRunApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) @@ -207,5 +338,127 @@ export const createRunApi = (fetcher: typeof fetch) => { fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}`).then((r) => jsonResponse(r) ), + + updateTCaseVersion: async ( + projectCode: ResourceId, + runId: ResourceId, + tcaseId: ResourceId, + req: UpdateRunTCaseVersionRequest + ) => { + const validated = validateRequest(req, UpdateRunTCaseVersionRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase/${tcaseId}`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + get: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}`).then((r) => + jsonResponse(r) + ), + + update: async (projectCode: ResourceId, runId: ResourceId, req: UpdateRunRequest) => { + const validated = validateRequest(req, UpdateRunRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/${runId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + reopen: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/reopen`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + closeMany: async (projectCode: ResourceId, req: RunIdsRequest) => { + const validated = validateRequest(req, RunIdsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/close`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + reopenMany: async (projectCode: ResourceId, req: RunIdsRequest) => { + const validated = validateRequest(req, RunIdsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/reopen`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + moveToMilestone: async (projectCode: ResourceId, req: MoveRunsRequest) => { + const validated = validateRequest(req, MoveRunsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/milestone`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + deleteMany: async (projectCode: ResourceId, req: RunIdsRequest) => { + const validated = validateRequest(req, RunIdsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/delete`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + getSelections: (projectCode: ResourceId, runId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/selections`).then((r) => + jsonResponse<{ selections: Record[] }>(r) + ), + + getFolderTree: async (projectCode: ResourceId, runId: ResourceId, params?: RunTCaseFilters) => { + const validated = params ? validateRequest(params, RunTCaseFiltersSchema) : {} + return fetcher( + appendSearchParams( + `/api/public/v0/project/${projectCode}/run/${runId}/folder/tree`, + validated + ) + ).then((r) => jsonResponse<{ folders: Folder[] }>(r)) + }, + + getStatusCounts: async ( + projectCode: ResourceId, + runId: ResourceId, + params?: RunTCaseFilters + ) => { + const validated = params ? validateRequest(params, RunTCaseFiltersSchema) : {} + return fetcher( + appendSearchParams( + `/api/public/v0/project/${projectCode}/run/${runId}/status-counts`, + validated + ) + ).then((r) => jsonResponse<{ statusCounts: Record }>(r)) + }, + + getOpenStatusCounts: (projectCode: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/run/open-status`).then((r) => + jsonResponse<{ items: { runId: number; counts: Record }[] }>(r) + ), + + previewTCases: async (projectCode: ResourceId, req: PreviewRunTCasesRequest) => { + const validated = validateRequest(req, PreviewRunTCasesRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/run/preview-tcases`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse>(r)) + }, + + listAssigned: async (params?: ListAssignedRunsRequest) => { + const validated = params ? validateRequest(params, ListAssignedRunsRequestSchema) : {} + return fetcher(appendSearchParams(`/api/public/v0/run/assigned/paginated`, validated)).then( + (r) => jsonResponse>(r) + ) + }, + + listLogs: async (projectCode: ResourceId, runId: ResourceId, params?: ListRunLogsRequest) => { + const validated = params ? validateRequest(params, ListRunLogsRequestSchema) : {} + return fetcher( + appendSearchParams( + `/api/public/v0/project/${projectCode}/run/${runId}/log/paginated`, + validated + ) + ).then((r) => jsonResponse>(r)) + }, } } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 245f785..956855f 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -75,6 +75,25 @@ export const linkSchema = z.object({ url: httpUrlSchema, }) export const priorityEnum = z.enum(['low', 'medium', 'high']) +export const resultStatusEnum = z.enum([ + 'passed', + 'failed', + 'blocked', + 'skipped', + 'open', + 'custom1', + 'custom2', + 'custom3', + 'custom4', +]) +// How several values of one filter combine. +export const filterOpEnum = z.enum(['or', 'and', 'not']) +export const automationResultEnum = z.enum([ + 'automated_passing', + 'automated_failing', + 'has_automated', + 'has_manual', +]) export const tcaseTypeEnum = z.enum(['standalone', 'template', 'filled']) export const resourceIdSchema = z .string() diff --git a/src/api/tcases.ts b/src/api/tcases.ts index d976e98..b755a4b 100644 --- a/src/api/tcases.ts +++ b/src/api/tcases.ts @@ -5,6 +5,7 @@ import { ResourceId, httpUrlSchema, linkSchema, + filterOpEnum, paginationParams, priorityEnum, tcaseTypeEnum, @@ -143,29 +144,54 @@ export const GetTCasesBySeqRequestSchema = z.object({ export type GetTCasesBySeqRequest = z.infer -export const ListTCasesRequestSchema = z.object({ - ...paginationParams, - folders: z.array(z.number().int().positive()).optional(), - tags: z.array(z.number().int().positive()).optional(), - priorities: z.array(priorityEnum).optional(), - search: z.string().optional(), +const idsSchema = z.array(z.number().int().positive()).optional() + +// The filters test-cases list and count share. +const TCaseFiltersSchema = z.object({ + search: z.string().max(255, 'search must be at most 255 characters').optional(), types: z.array(tcaseTypeEnum).optional(), + folders: idsSchema, + recursive: z.boolean().optional(), + tags: idsSchema, + tagsFilterOp: filterOpEnum.optional(), + priorities: z.array(priorityEnum).optional(), draft: z.boolean().optional(), + tcaseIds: z.array(z.string()).optional(), + templateTCaseIds: z.array(z.string()).optional(), + requirementIds: z.array(z.string()).optional(), + preconditionIds: idsSchema, + stepIds: idsSchema, + authorIds: idsSchema, + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), + /** Values by custom field system name. */ + customFields: z.record(z.string(), z.array(z.string())).optional(), +}) + +export const ListTCasesRequestSchema = TCaseFiltersSchema.extend({ + ...paginationParams, include: z.array(z.string()).optional(), }) export type ListTCasesRequest = z.infer -export const CountTCasesRequestSchema = z.object({ - folders: z.array(z.number().int().positive()).optional(), - recursive: z.boolean().optional(), - tags: z.array(z.number().int().positive()).optional(), - priorities: z.array(priorityEnum).optional(), - draft: z.boolean().optional(), -}) +export const CountTCasesRequestSchema = TCaseFiltersSchema export type CountTCasesRequest = z.infer +// The API takes each custom field filter as a cf_ parameter. +const withCustomFieldParams = ({ + customFields, + ...params +}: { + customFields?: Record +}) => ({ + ...params, + ...Object.fromEntries( + Object.entries(customFields ?? {}).map(([name, values]) => [`cf_${name}`, values]) + ), +}) + const preconditionSchema = z.union([ z.object({ text: z.string() }), z.object({ sharedPreconditionId: z.number().int().positive() }), @@ -410,7 +436,10 @@ export const createTCaseApi = (fetcher: typeof fetch) => { list: async (projectCode: ResourceId, params?: ListTCasesRequest) => { const validated = params ? validateRequest(params, ListTCasesRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/paginated`, validated) + appendSearchParams( + `/api/public/v0/project/${projectCode}/tcase/paginated`, + withCustomFieldParams(validated) + ) ).then((r) => jsonResponse>(r)) }, @@ -422,7 +451,10 @@ export const createTCaseApi = (fetcher: typeof fetch) => { count: async (projectCode: ResourceId, params?: CountTCasesRequest) => { const validated = params ? validateRequest(params, CountTCasesRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/count`, validated) + appendSearchParams( + `/api/public/v0/project/${projectCode}/tcase/count`, + withCustomFieldParams(validated) + ) ).then((r) => jsonResponse<{ count: number }>(r)) }, diff --git a/src/api/test-plans.ts b/src/api/test-plans.ts index 137aaaa..d9dcde5 100644 --- a/src/api/test-plans.ts +++ b/src/api/test-plans.ts @@ -1,16 +1,52 @@ import { z } from 'zod' -import { ResourceId, validateRequest } from './schemas' -import { RunSchema } from './runs' -import { jsonResponse, withJson } from './utils' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' +import { integrationIssueIdSchema } from './runs' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +const planTitle = z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + +// A plan's run has no type, and exactly one query plan that lists its test cases. +const planRunFields = { + title: planTitle, + assignmentId: z.number().int().positive('assignmentId must be a positive integer').optional(), + configurationId: z.string().optional(), +} + +const PlanQueryPlansSchema = z + .array( + z + .object({ tcaseIds: z.array(z.string().min(1, 'test case reference must not be empty')) }) + .strict() + ) + .length(1, 'A run of a test plan takes exactly one query plan') + +export const PlanRunSchema = z.object({ + ...planRunFields, + queryPlans: PlanQueryPlansSchema, +}) + +// Update Test Plan takes the ids of the runs to keep (see `test-plans get`). +export const UpdatePlanRunSchema = z.object({ + id: z.number().int().positive('id must be a positive integer').optional(), + ...planRunFields, + queryPlans: PlanQueryPlansSchema, +}) export const CreateTestPlanRequestSchema = z.object({ - title: z - .string() - .min(1, 'title must not be empty') - .max(255, 'title must be at most 255 characters'), + title: planTitle, description: z.string().optional(), milestoneId: z.number().int().positive().optional(), - runs: z.array(RunSchema).min(1, 'Must contain at least one run'), + integrationIssueId: integrationIssueIdSchema.optional(), + runs: z.array(PlanRunSchema).min(1, 'Must contain at least one run'), }) export type CreateTestPlanRequest = z.infer @@ -19,6 +55,64 @@ export interface CreateTestPlanResponse { id: number } +export interface TestPlan { + id: number + title: string + milestoneId: number | null + closedAt: string | null + [key: string]: unknown +} + +export const ListTestPlansRequestSchema = z.object({ + ...paginationParams, + closed: z.boolean().optional(), + milestoneIds: z.array(z.number().int().positive()).optional(), +}) + +export type ListTestPlansRequest = z.infer + +export const UpdateTestPlanRequestSchema = z.object({ + title: planTitle.optional(), + description: z.string().optional(), + // 0 takes the plan and its runs out of their milestone. + milestoneId: z + .number() + .int() + .nonnegative('milestoneId must be a non-negative integer') + .optional(), + // "" removes the issue link. + integrationIssueId: integrationIssueIdSchema.optional(), + runs: z.array(UpdatePlanRunSchema).min(1, 'Must contain at least one run').optional(), +}) + +export type UpdateTestPlanRequest = z.infer + +export const UpdateTestPlanRunRequestSchema = z.object({ + title: planTitle.optional(), + // 0 unassigns the run. + assignmentId: z + .number() + .int() + .nonnegative('assignmentId must be a non-negative integer') + .optional(), + // "" removes the configuration. + configurationId: z.string().optional(), + queryPlans: PlanQueryPlansSchema.optional(), +}) + +export type UpdateTestPlanRunRequest = z.infer + +export const MoveTestPlansRequestSchema = z.object({ + planIds: z + .array(z.number().int().positive('plan IDs must be positive integers')) + .min(1, 'Must contain at least one plan ID') + .max(100, 'Must contain at most 100 plan IDs'), + // 0 takes the plans out of their milestone. + milestoneId: z.number().int().nonnegative('milestoneId must be a non-negative integer'), +}) + +export type MoveTestPlansRequest = z.infer + export const createTestPlanApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { @@ -29,5 +123,61 @@ export const createTestPlanApi = (fetcher: typeof fetch) => { body: JSON.stringify(validated), }).then((r) => jsonResponse(r)) }, + + list: async (projectCode: ResourceId, params?: ListTestPlansRequest) => { + const validated = params ? validateRequest(params, ListTestPlansRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/plan/paginated`, validated) + ).then((r) => jsonResponse>(r)) + }, + + get: (projectCode: ResourceId, planId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}`).then((r) => + jsonResponse(r) + ), + + update: async (projectCode: ResourceId, planId: ResourceId, req: UpdateTestPlanRequest) => { + const validated = validateRequest(req, UpdateTestPlanRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + updateRun: async ( + projectCode: ResourceId, + planId: ResourceId, + runId: ResourceId, + req: UpdateTestPlanRunRequest + ) => { + const validated = validateRequest(req, UpdateTestPlanRunRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}/run/${runId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse>(r)) + }, + + close: (projectCode: ResourceId, planId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}/close`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + reopen: (projectCode: ResourceId, planId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}/reopen`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + moveToMilestone: async (projectCode: ResourceId, req: MoveTestPlansRequest) => { + const validated = validateRequest(req, MoveTestPlansRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/plan/milestone`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + delete: (projectCode: ResourceId, planId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/plan/${planId}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), } } diff --git a/src/commands/api/builder.ts b/src/commands/api/builder.ts index 7d2f79f..0cd48f4 100644 --- a/src/commands/api/builder.ts +++ b/src/commands/api/builder.ts @@ -1,6 +1,7 @@ import type { Argv, CommandModule, Options } from 'yargs' +import { Parser } from 'yargs/helpers' import { executeCommand } from './executor' -import { formatApiError } from './utils' +import { formatApiError, isCustomFieldOption } from './utils' import type { ApiEndpointSpec, ApiFieldSpec, ApiPathParamSpec, ApiQueryOptionSpec } from './types' interface CommandNode { @@ -61,6 +62,37 @@ function buildOptions(specs: (ApiQueryOptionSpec | ApiFieldSpec)[]): Record + alias: Record +} + +/** + * Lets a command take --cf- options. Strict mode would reject them as unknown, so the + * command turns it off and rejects other unknown arguments itself, with strict mode's message. + */ +function allowCustomFieldOptions(yargs: Argv, spec: ApiEndpointSpec): void { + // Custom field values are text: "007" must not become 7. Number options still parse. + yargs.strict(false).parserConfiguration({ 'parse-numbers': false }) + yargs.check((argv) => { + // Missing from yargs' types; it lists every option the command takes, global ones included. + const { key, alias } = (yargs as unknown as { getOptions(): YargsOptions }).getOptions() + const custom = Object.keys(argv).filter(isCustomFieldOption) + // yargs adds a camelCase copy of each option given. A camelCase spelling of a --cf-* option + // (--cfAutomation) has no --cf-* original, so it is rejected rather than silently dropped. + const known = new Set(['_', '$0']) + for (const name of [...Object.keys(key), ...Object.values(alias).flat(), ...custom]) { + known.add(name).add(Parser.camelCase(name)) + } + const unknown = [ + ...argv._.slice(spec.commandPath.length + 1).map(String), + ...Object.keys(argv).filter((name) => !known.has(name)), + ] + if (unknown.length === 0) return true + return `Unknown argument${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}` + }) +} + /** * Creates a yargs CommandModule for a leaf node (actual endpoint). */ @@ -101,6 +133,10 @@ function createEndpointCommand(spec: ApiEndpointSpec): CommandModule { }) } + if (spec.customFieldFilters) { + allowCustomFieldOptions(yargs, spec) + } + if (spec.check) { yargs.check((argv) => spec.check!(argv as Record)) } diff --git a/src/commands/api/executor.ts b/src/commands/api/executor.ts index d958009..fbd7122 100644 --- a/src/commands/api/executor.ts +++ b/src/commands/api/executor.ts @@ -4,6 +4,7 @@ import { createApi } from '../../api/index' import { ArgumentValidationError, type ArgumentValidationIssue, + collectCustomFieldFilters, collectFieldValues, collectPathParamValues, collectQueryValues, @@ -93,6 +94,9 @@ export async function executeCommand( } const transformQuery = spec.transformQuery ?? kebabToCamelCaseKeys const query = transformQuery(rawQuery) + if (spec.customFieldFilters) { + query.customFields = collectCustomFieldFilters(args) + } // 4. Connect to API (lazy auth resolution) const auth = await resolveAuth() diff --git a/src/commands/api/manifests/index.ts b/src/commands/api/manifests/index.ts index 262c031..d8b3363 100644 --- a/src/commands/api/manifests/index.ts +++ b/src/commands/api/manifests/index.ts @@ -5,6 +5,7 @@ import { configurationSpecs } from './configurations' import { customFieldSpecs } from './custom-fields' import { fileSpecs } from './files' import { folderSpecs } from './folders' +import { integrationSpecs } from './integrations' import { milestoneSpecs } from './milestones' import { projectSpecs } from './projects' import { requirementSpecs } from './requirements' @@ -26,6 +27,7 @@ export const allSpecs: ApiEndpointSpec[] = [ ...customFieldSpecs, ...fileSpecs, ...folderSpecs, + ...integrationSpecs, ...milestoneSpecs, ...projectSpecs, ...requirementSpecs, diff --git a/src/commands/api/manifests/integrations.ts b/src/commands/api/manifests/integrations.ts new file mode 100644 index 0000000..3a1010d --- /dev/null +++ b/src/commands/api/manifests/integrations.ts @@ -0,0 +1,493 @@ +import { z } from 'zod' +import { + CreateIssueRequestSchema, + CreateJiraIssueRequestSchema, + type CreateIssueRequest, + type CreateJiraIssueRequest, + type SearchJiraIssuesRequest, + type SearchLinearIssuesRequest, +} from '../../../api/integrations' +import { resourceIdSchema, sortOrderParam } from '../../../api/schemas' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { intIdParam, projectCodeParam, requireFieldsOrBody, splitList } from './utils' +import type { ApiEndpointSpec, ApiFieldSpec, ApiPathParamSpec } from '../types' + +const help = { + 'integration-id': 'Integration ID (see integrations list).', + 'jira-issue-id': 'Jira issue ID or key (e.g., "QA-12").', + 'github-issue-number': 'Issue number in the linked GitHub repository.', + 'linear-issue-id': 'Linear issue ID or identifier (e.g., "ENG-34").', + 'jira-issue-type-id': 'Jira issue type ID (see integrations jira issue-types list).', + 'linear-search': + 'Text in issue titles or descriptions, or an issue identifier (max 255 characters).', + 'jira-search': + 'Words the issue titles start with, or an issue key or number (max 255 characters).', + 'jira-types': 'Comma-separated Jira issue type names to filter by (e.g., "Bug,Story").', + 'jira-fields': + 'Comma-separated Jira fields to return, such as "summary,description". Defaults to the summary.', + 'jira-limit': 'Maximum number of issues to return (1-5000, defaults to 20).', + 'linear-limit': 'Maximum number of issues to return (1-250, defaults to 20).', + 'next-page-token': + 'The nextPageToken of the previous page. Searches page by cursor: repeat until isLast is true.', + 'jira-sort-field': 'Field to sort by; created by default.', + 'jira-sort-order': + 'Sort direction (asc or desc). Without --sort-field, the newest issues come first.', + summary: 'Issue title, 1-255 characters.', + 'jira-description': "Issue description in Jira's text format (max 32,767 characters).", + description: 'Issue description in Markdown (max 32,767 characters).', + 'jira-parent-id': 'ID of the parent issue, which sub-task types need.', + 'jira-other-fields': `JSON object of other Jira fields by key, in Jira's REST API format (see integrations jira issue-types fields). It can't set summary, description, issuetype, parent, project or reporter.\nExample: '{"priority": {"name": "High"}}'`, + list: { + describe: + "List a project's integrations: their type, settings (no credentials) and project link.", + epilog: apiDocsEpilog('integrations', 'list-integrations'), + examples: [ + { + usage: '$0 api integrations list --project-code PRJ', + description: "List a project's integrations", + }, + ], + }, + get: { + describe: 'Get an integration linked to a project.', + epilog: apiDocsEpilog('integrations', 'get-integration'), + examples: [ + { + usage: + '$0 api integrations get --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F', + description: 'Get an integration', + }, + ], + }, + jiraGet: { + describe: 'Get an issue of the linked Jira project, by its ID or key.', + epilog: apiDocsEpilog('integrations', 'get-jira-issue'), + examples: [ + { + usage: + '$0 api integrations jira issues get --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --issue-id QA-12', + description: 'Get a Jira issue', + }, + ], + }, + jiraSearch: { + describe: 'Search the issues of the linked Jira project.', + epilog: apiDocsEpilog('integrations', 'search-jira-issues'), + examples: [ + { + usage: + '$0 api integrations jira issues search --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --search login', + description: 'Search Jira issues', + }, + ], + }, + jiraCreate: { + describe: + "Create an issue in the linked Jira project, under your own Jira account if you have connected it to QA Sphere, otherwise under the integration's account. Link it to a result with results update --create-links.", + epilog: apiDocsEpilog('integrations', 'create-jira-issue'), + examples: [ + { + usage: + '$0 api integrations jira issues create --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --summary "Login fails" --issue-type-id 10001', + description: 'Create a Jira issue', + }, + ], + }, + jiraIssueTypes: { + describe: 'List the issue types of the linked Jira project.', + epilog: apiDocsEpilog('integrations', 'list-jira-issue-types'), + examples: [ + { + usage: + '$0 api integrations jira issue-types list --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F', + description: 'List Jira issue types', + }, + ], + }, + jiraIssueFields: { + describe: + 'List the fields a Jira issue type takes. Required ones other than summary, description, issue type and parent go in --other-fields of integrations jira issues create; QA Sphere sets project and reporter itself.', + epilog: apiDocsEpilog('integrations', 'get-jira-issue-fields'), + examples: [ + { + usage: + '$0 api integrations jira issue-types fields --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --issue-type-id 10001', + description: 'List the fields of a Jira issue type', + }, + ], + }, + githubGet: { + describe: 'Get an issue of the linked GitHub repository, by its number.', + epilog: apiDocsEpilog('integrations', 'get-github-issue'), + examples: [ + { + usage: + '$0 api integrations github issues get --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --issue-number 12', + description: 'Get a GitHub issue', + }, + ], + }, + githubCreate: { + describe: + "Create an issue in the linked GitHub repository, as the integration's GitHub App. Link it to a result with results update --create-links.", + epilog: apiDocsEpilog('integrations', 'create-github-issue'), + examples: [ + { + usage: + '$0 api integrations github issues create --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --summary "Login fails"', + description: 'Create a GitHub issue', + }, + ], + }, + linearGet: { + describe: 'Get an issue of the linked Linear team, by its ID or identifier.', + epilog: apiDocsEpilog('integrations', 'get-linear-issue'), + examples: [ + { + usage: + '$0 api integrations linear issues get --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --issue-id ENG-34', + description: 'Get a Linear issue', + }, + ], + }, + linearSearch: { + describe: 'Search the issues of the linked Linear team.', + epilog: apiDocsEpilog('integrations', 'search-linear-issues'), + examples: [ + { + usage: + '$0 api integrations linear issues search --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --search login', + description: 'Search Linear issues', + }, + ], + }, + linearCreate: { + describe: + "Create an issue in the linked Linear team, as the integration's Linear account. Link it to a result with results update --create-links.", + epilog: apiDocsEpilog('integrations', 'create-linear-issue'), + examples: [ + { + usage: + '$0 api integrations linear issues create --project-code PRJ --integration-id 1CSWAh3ys_YniqfXepnwT8F --summary "Login fails"', + description: 'Create a Linear issue', + }, + ], + }, +} as const + +const resourceParam = (name: string, describe: string): ApiPathParamSpec => ({ + name, + type: 'string', + describe, + schema: resourceIdSchema, +}) + +const integrationIdParam = resourceParam('integration-id', help['integration-id']) + +const summaryField: ApiFieldSpec = { + name: 'summary', + type: 'string', + describe: help.summary, + schema: CreateIssueRequestSchema.shape.summary, +} + +// GitHub and Linear take the same create body. +const createIssueSpec = ( + tracker: 'github' | 'linear', + text: { describe: string; epilog: string; examples: ApiEndpointSpec['examples'] } +): ApiEndpointSpec => ({ + id: `integrations.${tracker}.issues.create`, + commandPath: ['integrations', tracker, 'issues', 'create'], + describe: text.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, integrationIdParam], + fieldOptions: [ + summaryField, + { + name: 'description', + type: 'string', + describe: help.description, + schema: CreateIssueRequestSchema.shape.description.unwrap(), + }, + ], + check: requireFieldsOrBody('summary'), + epilog: text.epilog, + examples: text.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.integrations[tracker].createIssue( + pathParams['project-code'], + pathParams['integration-id'], + body as CreateIssueRequest + ) + ) + }, +}) + +const list: ApiEndpointSpec = { + id: 'integrations.list', + commandPath: ['integrations', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams }) => { + printJson(await api.integrations.list(pathParams['project-code'])) + }, +} + +const get: ApiEndpointSpec = { + id: 'integrations.get', + commandPath: ['integrations', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, integrationIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.integrations.get(pathParams['project-code'], pathParams['integration-id'])) + }, +} + +const jiraSearch: ApiEndpointSpec = { + id: 'integrations.jira.issues.search', + commandPath: ['integrations', 'jira', 'issues', 'search'], + describe: help.jiraSearch.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, integrationIdParam], + queryOptions: [ + { name: 'search', type: 'string', describe: help['jira-search'] }, + { name: 'types', type: 'string', describe: help['jira-types'] }, + { name: 'fields', type: 'string', describe: help['jira-fields'] }, + { name: 'limit', type: 'number', describe: help['jira-limit'] }, + { name: 'next-page-token', type: 'string', describe: help['next-page-token'] }, + { + name: 'sort-field', + type: 'string', + describe: help['jira-sort-field'], + helpChoices: ['created', 'updated', 'priority', 'status', 'key', 'summary', 'issuetype'], + }, + { + name: 'sort-order', + type: 'string', + describe: help['jira-sort-order'], + schema: sortOrderParam, + choices: ['asc', 'desc'], + }, + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + types: splitList(query.types), + fields: splitList(query.fields), + }), + epilog: help.jiraSearch.epilog, + examples: help.jiraSearch.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.integrations.jira.searchIssues( + pathParams['project-code'], + pathParams['integration-id'], + query as SearchJiraIssuesRequest + ) + ) + }, +} + +const jiraGet: ApiEndpointSpec = { + id: 'integrations.jira.issues.get', + commandPath: ['integrations', 'jira', 'issues', 'get'], + describe: help.jiraGet.describe, + bodyMode: 'none', + pathParams: [ + projectCodeParam, + integrationIdParam, + resourceParam('issue-id', help['jira-issue-id']), + ], + epilog: help.jiraGet.epilog, + examples: help.jiraGet.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.integrations.jira.getIssue( + pathParams['project-code'], + pathParams['integration-id'], + pathParams['issue-id'] + ) + ) + }, +} + +const jiraCreate: ApiEndpointSpec = { + id: 'integrations.jira.issues.create', + commandPath: ['integrations', 'jira', 'issues', 'create'], + describe: help.jiraCreate.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, integrationIdParam], + fieldOptions: [ + summaryField, + { + name: 'description', + type: 'string', + describe: help['jira-description'], + schema: CreateJiraIssueRequestSchema.shape.description.unwrap(), + }, + { + name: 'issue-type-id', + type: 'string', + describe: help['jira-issue-type-id'], + schema: CreateJiraIssueRequestSchema.shape.issueTypeId, + }, + { + name: 'parent-id', + type: 'string', + describe: help['jira-parent-id'], + schema: CreateJiraIssueRequestSchema.shape.parentId.unwrap(), + }, + { + name: 'other-fields', + type: 'string', + describe: help['jira-other-fields'], + schema: z.record(z.string(), z.unknown()), + jsonParse: true, + }, + ], + check: requireFieldsOrBody('summary', 'issue-type-id'), + epilog: help.jiraCreate.epilog, + examples: help.jiraCreate.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.integrations.jira.createIssue( + pathParams['project-code'], + pathParams['integration-id'], + body as CreateJiraIssueRequest + ) + ) + }, +} + +const jiraIssueTypes: ApiEndpointSpec = { + id: 'integrations.jira.issue-types.list', + commandPath: ['integrations', 'jira', 'issue-types', 'list'], + describe: help.jiraIssueTypes.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, integrationIdParam], + epilog: help.jiraIssueTypes.epilog, + examples: help.jiraIssueTypes.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.integrations.jira.listIssueTypes( + pathParams['project-code'], + pathParams['integration-id'] + ) + ) + }, +} + +const jiraIssueFields: ApiEndpointSpec = { + id: 'integrations.jira.issue-types.fields', + commandPath: ['integrations', 'jira', 'issue-types', 'fields'], + describe: help.jiraIssueFields.describe, + bodyMode: 'none', + pathParams: [ + projectCodeParam, + integrationIdParam, + intIdParam('issue-type-id', help['jira-issue-type-id']), + ], + epilog: help.jiraIssueFields.epilog, + examples: help.jiraIssueFields.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.integrations.jira.getIssueFields( + pathParams['project-code'], + pathParams['integration-id'], + pathParams['issue-type-id'] + ) + ) + }, +} + +const githubGet: ApiEndpointSpec = { + id: 'integrations.github.issues.get', + commandPath: ['integrations', 'github', 'issues', 'get'], + describe: help.githubGet.describe, + bodyMode: 'none', + pathParams: [ + projectCodeParam, + integrationIdParam, + intIdParam('issue-number', help['github-issue-number']), + ], + epilog: help.githubGet.epilog, + examples: help.githubGet.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.integrations.github.getIssue( + pathParams['project-code'], + pathParams['integration-id'], + pathParams['issue-number'] + ) + ) + }, +} + +const linearSearch: ApiEndpointSpec = { + id: 'integrations.linear.issues.search', + commandPath: ['integrations', 'linear', 'issues', 'search'], + describe: help.linearSearch.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, integrationIdParam], + queryOptions: [ + { name: 'search', type: 'string', describe: help['linear-search'] }, + { name: 'limit', type: 'number', describe: help['linear-limit'] }, + { name: 'next-page-token', type: 'string', describe: help['next-page-token'] }, + ], + transformQuery: (query) => kebabToCamelCaseKeys(query), + epilog: help.linearSearch.epilog, + examples: help.linearSearch.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.integrations.linear.searchIssues( + pathParams['project-code'], + pathParams['integration-id'], + query as SearchLinearIssuesRequest + ) + ) + }, +} + +const linearGet: ApiEndpointSpec = { + id: 'integrations.linear.issues.get', + commandPath: ['integrations', 'linear', 'issues', 'get'], + describe: help.linearGet.describe, + bodyMode: 'none', + pathParams: [ + projectCodeParam, + integrationIdParam, + resourceParam('issue-id', help['linear-issue-id']), + ], + epilog: help.linearGet.epilog, + examples: help.linearGet.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.integrations.linear.getIssue( + pathParams['project-code'], + pathParams['integration-id'], + pathParams['issue-id'] + ) + ) + }, +} + +export const integrationSpecs: ApiEndpointSpec[] = [ + list, + get, + jiraSearch, + jiraGet, + jiraCreate, + jiraIssueTypes, + jiraIssueFields, + githubGet, + createIssueSpec('github', help.githubCreate), + linearSearch, + linearGet, + createIssueSpec('linear', help.linearCreate), +] diff --git a/src/commands/api/manifests/milestones.ts b/src/commands/api/manifests/milestones.ts index cc15c23..0fe9993 100644 --- a/src/commands/api/manifests/milestones.ts +++ b/src/commands/api/manifests/milestones.ts @@ -1,12 +1,77 @@ -import { CreateMilestoneRequestSchema, type ListMilestonesRequest } from '../../../api/milestones' +import type { Api } from '../../../api/index' +import { + CreateMilestoneRequestSchema, + type ListMilestonesRequest, + type UpdateMilestoneRequest, +} from '../../../api/milestones' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, paginationOptions, projectCodeParam } from './utils' +import { + commonHelp, + intIdParam, + paginationOptions, + projectCodeParam, + requireAnyFieldOrBody, +} from './utils' import type { ApiEndpointSpec } from '../types' const help = { ...commonHelp, archived: 'Filter by archived status. If omitted, returns all milestones.', title: 'Display title for the milestone (max 255 characters).', + 'milestone-id': 'Milestone ID.', + get: { + describe: 'Get a milestone.', + epilog: apiDocsEpilog('milestone', 'get-milestone'), + examples: [ + { + usage: '$0 api milestones get --project-code PRJ --milestone-id 1', + description: 'Get a milestone', + }, + ], + }, + update: { + describe: 'Update a milestone. Fields left out keep their values.', + epilog: apiDocsEpilog('milestone', 'update-milestone'), + examples: [ + { + usage: '$0 api milestones update --project-code PRJ --milestone-id 1 --title "v1.1"', + description: 'Rename a milestone', + }, + ], + }, + archive: { + describe: + 'Archive a milestone, closing every open test plan and test run in it. Reports how many test plans and standalone runs it closed.', + epilog: apiDocsEpilog('milestone', 'archive-milestone'), + examples: [ + { + usage: '$0 api milestones archive --project-code PRJ --milestone-id 1', + description: 'Archive a milestone', + }, + ], + }, + unarchive: { + describe: + 'Unarchive a milestone. The test plans and runs that archiving closed stay closed; reopen them one by one.', + epilog: apiDocsEpilog('milestone', 'unarchive-milestone'), + examples: [ + { + usage: '$0 api milestones unarchive --project-code PRJ --milestone-id 1', + description: 'Unarchive a milestone', + }, + ], + }, + delete: { + describe: + 'Delete a milestone with every test plan and test run in it. They go to the trash as one entry, which trash restore brings back together.', + epilog: apiDocsEpilog('milestone', 'delete-milestone'), + examples: [ + { + usage: '$0 api milestones delete --project-code PRJ --milestone-id 1', + description: 'Delete a milestone', + }, + ], + }, list: { describe: 'List milestones in a project.', epilog: apiDocsEpilog('milestone', 'list-milestones'), @@ -33,6 +98,25 @@ const help = { }, } as const +const milestoneIdParam = intIdParam('milestone-id', help['milestone-id']) + +const stateSpec = ( + action: 'archive' | 'unarchive' | 'delete', + text: { describe: string; epilog: string; examples: ApiEndpointSpec['examples'] }, + call: (api: Api, projectCode: string | number, milestoneId: string | number) => Promise +): ApiEndpointSpec => ({ + id: `milestones.${action}`, + commandPath: ['milestones', action], + describe: text.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, milestoneIdParam], + epilog: text.epilog, + examples: text.examples, + execute: async (api, { pathParams }) => { + printJson(await call(api, pathParams['project-code'], pathParams['milestone-id'])) + }, +}) + const list: ApiEndpointSpec = { id: 'milestones.list', commandPath: ['milestones', 'list'], @@ -50,6 +134,19 @@ const list: ApiEndpointSpec = { }, } +const get: ApiEndpointSpec = { + id: 'milestones.get', + commandPath: ['milestones', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, milestoneIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.milestones.get(pathParams['project-code'], pathParams['milestone-id'])) + }, +} + const create: ApiEndpointSpec = { id: 'milestones.create', commandPath: ['milestones', 'create'], @@ -81,4 +178,50 @@ const create: ApiEndpointSpec = { }, } -export const milestoneSpecs: ApiEndpointSpec[] = [list, create] +const update: ApiEndpointSpec = { + id: 'milestones.update', + commandPath: ['milestones', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, milestoneIdParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateMilestoneRequestSchema.shape.title, + }, + ], + check: requireAnyFieldOrBody('title'), + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.milestones.update( + pathParams['project-code'], + pathParams['milestone-id'], + body as UpdateMilestoneRequest + ) + ) + }, +} + +const archive = stateSpec('archive', help.archive, (api, code, id) => + api.milestones.archive(code, id) +) + +const unarchive = stateSpec('unarchive', help.unarchive, (api, code, id) => + api.milestones.unarchive(code, id) +) + +const remove = stateSpec('delete', help.delete, (api, code, id) => api.milestones.delete(code, id)) + +export const milestoneSpecs: ApiEndpointSpec[] = [ + list, + get, + create, + update, + archive, + unarchive, + remove, +] diff --git a/src/commands/api/manifests/results.ts b/src/commands/api/manifests/results.ts index c30c327..e8bc1fc 100644 --- a/src/commands/api/manifests/results.ts +++ b/src/commands/api/manifests/results.ts @@ -3,10 +3,18 @@ import { CreateResultsRequestSchema, resultLinksSchema, type CreateResultRequest, + type UpdateResultRequest, } from '../../../api/results' import { resourceIdSchema } from '../../../api/schemas' -import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { + commonHelp, + intIdParam, + intListSchema, + projectCodeParam, + requireAnyFieldOrBody, + splitIntList, +} from './utils' import type { ApiEndpointSpec } from '../types' /** @@ -21,23 +29,48 @@ const batchCreateResultsInputSchema = z const help = { ...commonHelp, 'run-id': 'Test run ID.', - 'tcase-id': 'Test case ID within the run.', + 'tcase-id': 'Test case ID, sequence number or legacy ID.', status: 'Result status: passed, failed, blocked, skipped, open, custom1-4.', comment: 'Result comment (supports HTML).', 'time-taken': 'Time taken in milliseconds.', - links: `JSON array of result links. Each link has "text" and "url" fields.\nExample: '[{"text": "Log", "url": "https://ci.example.com/123"}]'`, - items: `JSON array of result items for batch creation.\nEach item has: tcaseId (string), status (string), comment? (string, supports HTML), timeTaken? (number|null), links? (array).\nExample: '[{"tcaseId": "abc", "status": "passed"}]'`, + links: `JSON array of at most 5 issues to link to the result, each with the "integrationId" (see integrations list), the issue's "url" and "text" (its title), and for a Jira, GitHub or Linear issue "meta": {"id": ...} with its ID (for GitHub, its number). The issue must be in the project's linked Jira project, GitHub repository or Linear team, and gets a reference back to the result.\nExample: '[{"integrationId": "1CSWAh3ys_YniqfXepnwT8F", "url": "https://acme.atlassian.net/browse/QA-12", "text": "Login fails", "meta": {"id": "10394"}}]'`, + 'create-links': `JSON array of issues to link to the result, as --links on results create; the result then holds at most 5 links.`, + 'delete-link-ids': "Comma-separated IDs of the result's links to remove (see results get).", + 'result-id': 'Result ID.', + items: `JSON array of at most 1000 result items for batch creation, with at most 100 links across them.\nEach item has: tcaseId (test case ID, sequence number or legacy ID), status (string), comment? (string, supports HTML), timeTaken? (number|null), links? (array, as --links on results create).\nExample: '[{"tcaseId": "4", "status": "passed"}]'`, + get: { + describe: 'Get a result of a run, with its links.', + epilog: apiDocsEpilog('result', 'get-result'), + examples: [ + { + usage: '$0 api results get --project-code PRJ --run-id 1 --result-id 10', + description: 'Get a result', + }, + ], + }, + update: { + describe: + "Change a result's comment or time taken, or add or remove its links. Fields left out keep their values. Only the result's author can update it, and its status can't change: add a new result instead.", + epilog: apiDocsEpilog('result', 'update-result'), + examples: [ + { + usage: + '$0 api results update --project-code PRJ --run-id 1 --result-id 10 --comment "Retried: flaky network"', + description: "Change a result's comment", + }, + ], + }, create: { describe: 'Create a result for a test case in a run.', epilog: apiDocsEpilog('result', 'add-result'), examples: [ { - usage: '$0 api results create --project-code PRJ --run-id 1 --tcase-id abc --status passed', + usage: '$0 api results create --project-code PRJ --run-id 1 --tcase-id 4 --status passed', description: 'Create a passed result', }, { usage: - '$0 api results create --project-code PRJ --run-id 1 --tcase-id abc --body \'{"status": "passed", "comment": "All checks passed"}\'', + '$0 api results create --project-code PRJ --run-id 1 --tcase-id 4 --body \'{"status": "passed", "comment": "All checks passed"}\'', description: 'Create a result using --body', }, ], @@ -48,13 +81,41 @@ const help = { examples: [ { usage: - '$0 api results batch-create --project-code PRJ --run-id 1 --body \'{"items": [{"tcaseId": "abc", "status": "passed"}, {"tcaseId": "def", "status": "failed"}]}\'', + '$0 api results batch-create --project-code PRJ --run-id 1 --body \'{"items": [{"tcaseId": "4", "status": "passed"}, {"tcaseId": "5", "status": "failed"}]}\'', description: 'Batch create results using --body', }, ], }, } as const +const runIdParam = { + name: 'run-id', + type: 'number' as const, + describe: help['run-id'], + schema: z.number().int().positive('must be a positive integer'), +} + +const resultIdParam = intIdParam('result-id', help['result-id']) + +const get: ApiEndpointSpec = { + id: 'results.get', + commandPath: ['results', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam, resultIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.results.get( + pathParams['project-code'], + pathParams['run-id'], + pathParams['result-id'] + ) + ) + }, +} + const create: ApiEndpointSpec = { id: 'results.create', commandPath: ['results', 'create'], @@ -168,4 +229,51 @@ const batchCreate: ApiEndpointSpec = { }, } -export const resultSpecs: ApiEndpointSpec[] = [create, batchCreate] +const update: ApiEndpointSpec = { + id: 'results.update', + commandPath: ['results', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, runIdParam, resultIdParam], + fieldOptions: [ + { name: 'comment', type: 'string', describe: help.comment }, + { + name: 'time-taken', + type: 'number', + describe: help['time-taken'], + schema: z.number().int().nonnegative(), + }, + { + name: 'create-links', + type: 'string', + describe: help['create-links'], + schema: resultLinksSchema, + jsonParse: true, + }, + { + name: 'delete-link-ids', + type: 'string', + describe: help['delete-link-ids'], + schema: intListSchema, + }, + ], + transformFields: (fields) => ({ + ...kebabToCamelCaseKeys(fields), + deleteLinkIds: splitIntList(fields['delete-link-ids']), + }), + check: requireAnyFieldOrBody('comment', 'time-taken', 'create-links', 'delete-link-ids'), + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.results.update( + pathParams['project-code'], + pathParams['run-id'], + pathParams['result-id'], + body as UpdateResultRequest + ) + ) + }, +} + +export const resultSpecs: ApiEndpointSpec[] = [get, create, batchCreate, update] diff --git a/src/commands/api/manifests/runs.ts b/src/commands/api/manifests/runs.ts index cd54d6c..bd1d8fc 100644 --- a/src/commands/api/manifests/runs.ts +++ b/src/commands/api/manifests/runs.ts @@ -1,54 +1,266 @@ import { z } from 'zod' +import type { Api } from '../../../api/index' import { CreateRunRequestSchema, CreateRunLogRequestSchema, QueryPlansSchema, CloneRunRequestSchema, + MoveRunsRequestSchema, + PreviewRunTCasesRequestSchema, + UpdateRunRequestSchema, + type ListAssignedRunsRequest, + type ListRunLogsRequest, type ListRunsRequest, type ListRunTCasesRequest, + type MoveRunsRequest, + type PreviewRunTCasesRequest, + type RunIdsRequest, + type RunTCaseFilters, + type UpdateRunRequest, + type UpdateRunTCaseVersionRequest, CreateRunLogRequest, } from '../../../api/runs' -import { resourceIdSchema } from '../../../api/schemas' +import { filterOpEnum, limitParam, offsetParam, resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' -import { commonHelp, paginationOptions, projectCodeParam, splitList } from './utils' -import type { ApiEndpointSpec } from '../types' +import { + commonHelp, + intListSchema, + paginationOptions, + projectCodeParam, + requireAnyFieldOrBody, + requireFieldsOrBody, + splitIntList, + splitList, +} from './utils' +import type { ApiEndpointSpec, ApiFieldSpec, ApiQueryOptionSpec } from '../types' // CreateRunRequestSchema stays a ZodObject despite its .superRefine() refinement, // so its field definitions are available directly via .shape. const CreateRunShape = CreateRunRequestSchema.shape +const UpdateRunShape = UpdateRunRequestSchema.shape +const PreviewShape = PreviewRunTCasesRequestSchema.shape const help = { ...commonHelp, 'run-id': 'Test run ID.', - 'tcase-id': 'Test case ID or legacy ID.', + 'tcase-id': 'Test case ID, sequence number or legacy ID.', title: 'Display title for the test run, 1-255 characters.', type: 'Run type: static, static_struct, or live.', - description: 'Optional description for the test run (max 512 characters).', + description: 'Optional description for the test run (supports HTML).', 'milestone-id': 'ID of the milestone to associate with this run.', 'configuration-id': 'ID of the configuration to associate with this run.', 'assignment-id': 'ID of the user to assign to this run.', 'query-plans': - 'JSON array of query plan objects that select which test cases to include.\nExample: \'[{"tcaseIds": ["abc123"]}]\'', + 'JSON array of query plans that select the test cases of the run: one with "tcaseIds" (test case IDs, sequence numbers or legacy IDs), or one or more with filters ("folderIds", "tagIds", "priorities"). Live runs take filters only. A query plan with no fields selects no test cases, or every one on a live run.\nExample: \'[{"tcaseIds": ["4"]}]\'', closed: 'Filter by closed status. If true, returns only closed runs.', 'milestone-ids': 'Comma-separated milestone IDs to filter by (e.g., "1,2,3").', + 'plan-ids': 'Comma-separated test plan IDs to filter by (e.g., "1,2").', + 'configuration-ids': 'Comma-separated configuration IDs to filter by.', search: 'Search text to filter test cases.', tags: 'Comma-separated tag IDs to filter by.', priorities: 'Comma-separated priorities to filter by (e.g., "low,high").', include: 'Comma-separated additional fields to include.', 'run-include': 'Comma-separated related data to include.', comment: 'Log message body (supports HTML).', + 'integration-issue-id': + "ID or key of an issue in the project's Jira or Linear integration to link the run to.", + 'run-ids': 'Comma-separated run IDs (e.g., "1,2"), at most 100.', + 'tags-filter-op': + 'How several --tags combine: or (any of them, the default), and (all) or not (none).', + status: + 'Comma-separated statuses of the test case in the run: passed, failed, skipped, open, blocked or custom1-custom4.', + 'automation-results': + 'Comma-separated automated result filters: automated_passing (the latest automated result passed), automated_failing, has_automated or has_manual.', + 'automation-results-filter-op': + 'How several --automation-results combine: or (the default), and or not.', + 'update-type': + 'New run type: static, static_struct or live. Switching to or from live also needs --query-plans.', + 'update-description': 'Description of the test run (supports HTML).', + 'update-milestone-id': 'Milestone to move the run to; 0 takes it out of its milestone.', + 'update-assignment-id': 'User to assign the run to; 0 unassigns it.', + 'update-configuration-id': 'Configuration of the run; "" removes it.', + 'update-integration-issue-id': + 'Issue to link the run to in the project\'s Jira or Linear integration; "" removes the link.', + 'update-query-plans': + 'JSON array that replaces the whole selection of the run; a query plan left out is removed. A query plan sent with its "id" (see runs selections) is replaced by the fields sent with it, so send all its current fields to keep it. Test cases that stay keep their results.\nExample: \'[{"id": 3, "tagIds": [1]}, {"folderIds": [2]}]\'', + 'move-milestone-id': 'Milestone to move the runs to; 0 takes them out of their milestone.', + 'run-type': 'Run type to preview: static, static_struct, or live.', + 'preview-run-id': + 'An existing run, to preview a change to its selection: test cases it has keep their version and status.', + 'logs-sort-field': + 'Field to sort by; created_at by default. api_key_id groups the logs of each API key.', + 'assigned-sort-field': + 'Field to sort by; created_at by default. last_activity_at is the latest result or run log.', + 'update-to-latest-version': + 'Move the run test case to the latest version of its test case. Its status in the run goes back to open.', + 'allow-run-tcase-removal': + "Live runs: remove the test case when its latest version no longer matches the run's filters.", + get: { + describe: 'Get a test run.', + epilog: apiDocsEpilog('run', 'get-run'), + examples: [ + { usage: '$0 api runs get --project-code PRJ --run-id 1', description: 'Get a run' }, + ], + }, + update: { + describe: + 'Update a test run. Fields left out keep their values. Runs of a test plan are updated with test-plans update-run, and a closed run must be reopened first.', + epilog: apiDocsEpilog('run', 'update-run'), + examples: [ + { + usage: + '$0 api runs update --project-code PRJ --run-id 1 --title "Sprint 2" --milestone-id 0', + description: 'Rename a run and take it out of its milestone', + }, + ], + }, + reopen: { + describe: + 'Reopen a closed test run. It catches up with the changes made to its test cases while it was closed. Runs of a test plan are reopened with test-plans reopen.', + epilog: apiDocsEpilog('run', 'reopen-run'), + examples: [ + { usage: '$0 api runs reopen --project-code PRJ --run-id 1', description: 'Reopen a run' }, + ], + }, + bulkClose: { + describe: + 'Close several test runs. Runs already closed are left as they are. Runs of a test plan are closed with test-plans close.', + epilog: apiDocsEpilog('run', 'close-runs'), + examples: [ + { + usage: '$0 api runs bulk-close --project-code PRJ --run-ids 1,2', + description: 'Close two runs', + }, + ], + }, + bulkReopen: { + describe: + 'Reopen several test runs. Runs already open are left as they are. Runs of a test plan are reopened with test-plans reopen.', + epilog: apiDocsEpilog('run', 'reopen-runs'), + examples: [ + { + usage: '$0 api runs bulk-reopen --project-code PRJ --run-ids 1,2', + description: 'Reopen two runs', + }, + ], + }, + move: { + describe: + 'Move test runs into a milestone, or out of their milestone. Closed runs stay closed. Runs of a test plan move with it, using test-plans move.', + epilog: apiDocsEpilog('run', 'move-runs-to-milestone'), + examples: [ + { + usage: '$0 api runs move --project-code PRJ --run-ids 1,2 --milestone-id 5', + description: 'Move two runs into milestone 5', + }, + ], + }, + delete: { + describe: + 'Delete test runs. Each goes to the trash, closed, and can be restored with trash restore. Runs of a test plan are deleted with test-plans delete.', + epilog: apiDocsEpilog('run', 'delete-runs'), + examples: [ + { + usage: '$0 api runs delete --project-code PRJ --run-ids 1,2', + description: 'Delete two runs', + }, + ], + }, + selections: { + describe: + "Get a run's selections (its query plans), to change one with runs update, which replaces them all.", + epilog: apiDocsEpilog('run', 'get-run-selections'), + examples: [ + { + usage: '$0 api runs selections --project-code PRJ --run-id 1', + description: "Get a run's query plans", + }, + ], + }, + folders: { + describe: + "Get the folder tree of a run's test cases. With filters, only folders holding a matching test case.", + epilog: apiDocsEpilog('run', 'get-run-folder-tree'), + examples: [ + { + usage: '$0 api runs folders --project-code PRJ --run-id 1 --status failed', + description: 'Get the folders holding failed test cases', + }, + ], + }, + statusCounts: { + describe: "Count a run's test cases in each status, optionally only those matching filters.", + epilog: apiDocsEpilog('run', 'get-run-status-counts'), + examples: [ + { + usage: '$0 api runs status-counts --project-code PRJ --run-id 1', + description: "Count a run's test cases by status", + }, + ], + }, + openStatusCounts: { + describe: 'Get the status counts of every open run in a project.', + epilog: apiDocsEpilog('run', 'get-status-counts-of-open-runs'), + examples: [ + { + usage: '$0 api runs open-status-counts --project-code PRJ', + description: 'Get the status counts of open runs', + }, + ], + }, + preview: { + describe: + 'Preview the test cases a selection would put in a run, without creating or changing one.', + epilog: apiDocsEpilog('run', 'preview-run-test-cases'), + examples: [ + { + usage: + '$0 api runs preview --project-code PRJ --run-type live --query-plans \'[{"tagIds": [1]}]\'', + description: 'Preview a live run filtered by tag', + }, + ], + }, + listAssigned: { + describe: + 'List the open runs assigned to you, across every project you can access, leaving out archived projects.', + epilog: apiDocsEpilog('run', 'list-runs-assigned-to-me'), + examples: [ + { usage: '$0 api runs list-assigned', description: 'List the open runs assigned to you' }, + ], + }, + logsList: { + describe: "List a run's logs, such as CI/CD output added with runs logs create.", + epilog: apiDocsEpilog('run', 'list-run-logs'), + examples: [ + { + usage: '$0 api runs logs list --project-code PRJ --run-id 1', + description: "List a run's logs", + }, + ], + }, + tcasesUpdateVersion: { + describe: 'Move a run test case to the latest version of its test case.', + epilog: apiDocsEpilog('run', 'update-run-test-case-version'), + examples: [ + { + usage: + '$0 api runs test-cases update-version --project-code PRJ --run-id 1 --tcase-id 12 --update-to-latest-version', + description: 'Move a run test case to the latest version', + }, + ], + }, create: { describe: 'Create a new test run.', epilog: apiDocsEpilog('run', 'create-new-run'), examples: [ { usage: - '$0 api runs create --project-code PRJ --title "Sprint 1" --type static --query-plans \'[{"tcaseIds": ["abc123"]}]\'', + '$0 api runs create --project-code PRJ --title "Sprint 1" --type static --query-plans \'[{"tcaseIds": ["4"]}]\'', description: 'Create a static run with specific test cases', }, { usage: - '$0 api runs create --project-code PRJ --body \'{"title": "Sprint 1", "type": "static", "queryPlans": [{"tcaseIds": ["abc123"]}]}\'', + '$0 api runs create --project-code PRJ --body \'{"title": "Sprint 1", "type": "static", "queryPlans": [{"tcaseIds": ["4"]}]}\'', description: 'Create a run using --body', }, ], @@ -68,7 +280,8 @@ const help = { ], }, clone: { - describe: 'Clone an existing test run.', + describe: + "Clone an existing test run: its type and test case selection. Other fields come only from the request, so the source run's milestone, configuration and assignee are not copied.", epilog: apiDocsEpilog('run', 'clone-existing-run'), examples: [ { @@ -83,7 +296,7 @@ const help = { ], }, close: { - describe: 'Close a test run.', + describe: 'Close a test run. Runs of a test plan are closed with test-plans close.', epilog: apiDocsEpilog('run', 'close-run'), examples: [ { @@ -137,6 +350,253 @@ const runIdParam = { schema: z.number().int().positive('must be a positive integer'), } +const runTCaseIdParam = { + name: 'tcase-id', + type: 'string' as const, + describe: help['tcase-id'], + schema: resourceIdSchema, +} + +const runIdsField: ApiFieldSpec = { + name: 'run-ids', + type: 'string', + describe: help['run-ids'], + schema: intListSchema, +} + +// Filters of a run's test cases, shared by their list, folder tree and status counts. +const runTCaseFilterOptions: ApiQueryOptionSpec[] = [ + { name: 'search', type: 'string', describe: help.search }, + { name: 'tags', type: 'string', describe: help.tags }, + { + name: 'tags-filter-op', + type: 'string', + describe: help['tags-filter-op'], + choices: filterOpEnum.options, + }, + { name: 'priorities', type: 'string', describe: help.priorities }, + { name: 'status', type: 'string', describe: help.status }, + { name: 'automation-results', type: 'string', describe: help['automation-results'] }, + { + name: 'automation-results-filter-op', + type: 'string', + describe: help['automation-results-filter-op'], + choices: filterOpEnum.options, + }, +] + +const transformRunTCaseFilters = (query: Record) => ({ + ...kebabToCamelCaseKeys(query), + tags: splitIntList(query.tags), + priorities: splitList(query.priorities), + status: splitList(query.status), + automationResults: splitList(query['automation-results']), +}) + +const updateFieldNames = [ + 'title', + 'description', + 'type', + 'milestone-id', + 'assignment-id', + 'configuration-id', + 'integration-issue-id', + 'query-plans', +] + +const runIdsSpec = ( + action: 'bulk-close' | 'bulk-reopen' | 'delete', + text: { describe: string; epilog: string; examples: ApiEndpointSpec['examples'] }, + call: (api: Api, projectCode: string | number, req: RunIdsRequest) => Promise +): ApiEndpointSpec => ({ + id: `runs.${action}`, + commandPath: ['runs', action], + describe: text.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [runIdsField], + transformFields: (fields) => ({ runIds: splitIntList(fields['run-ids']) }), + check: requireFieldsOrBody('run-ids'), + epilog: text.epilog, + examples: text.examples, + execute: async (api, { pathParams, body }) => { + printJson(await call(api, pathParams['project-code'], body as RunIdsRequest)) + }, +}) + +const list: ApiEndpointSpec = { + id: 'runs.list', + commandPath: ['runs', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { name: 'closed', type: 'boolean', describe: help.closed }, + { name: 'milestone-ids', type: 'string', describe: help['milestone-ids'] }, + { name: 'plan-ids', type: 'string', describe: help['plan-ids'], schema: intListSchema }, + { name: 'configuration-ids', type: 'string', describe: help['configuration-ids'] }, + { + name: 'include', + type: 'string', + describe: help['run-include'], + helpChoices: ['project', 'plan', 'milestone', 'assignment', 'configuration', 'statusCounts'], + }, + ...paginationOptions(['title', 'created_at', 'last_activity_at', 'closed_at']), + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + milestoneIds: + typeof query['milestone-ids'] === 'string' + ? query['milestone-ids'].split(',').map(Number) + : query['milestone-ids'], + planIds: splitIntList(query['plan-ids']), + configurationIds: splitList(query['configuration-ids']), + include: splitList(query.include), + }), + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson(await api.runs.list(pathParams['project-code'], query as ListRunsRequest)) + }, +} + +const listAssigned: ApiEndpointSpec = { + id: 'runs.list-assigned', + commandPath: ['runs', 'list-assigned'], + describe: help.listAssigned.describe, + bodyMode: 'none', + pathParams: [], + queryOptions: paginationOptions( + ['title', 'created_at', 'last_activity_at'], + help['assigned-sort-field'] + ), + epilog: help.listAssigned.epilog, + examples: help.listAssigned.examples, + execute: async (api, { query }) => { + printJson(await api.runs.listAssigned(query as ListAssignedRunsRequest)) + }, +} + +const openStatusCounts: ApiEndpointSpec = { + id: 'runs.open-status-counts', + commandPath: ['runs', 'open-status-counts'], + describe: help.openStatusCounts.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + epilog: help.openStatusCounts.epilog, + examples: help.openStatusCounts.examples, + execute: async (api, { pathParams }) => { + printJson(await api.runs.getOpenStatusCounts(pathParams['project-code'])) + }, +} + +const get: ApiEndpointSpec = { + id: 'runs.get', + commandPath: ['runs', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.runs.get(pathParams['project-code'], pathParams['run-id'])) + }, +} + +const selections: ApiEndpointSpec = { + id: 'runs.selections', + commandPath: ['runs', 'selections'], + describe: help.selections.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + epilog: help.selections.epilog, + examples: help.selections.examples, + execute: async (api, { pathParams }) => { + printJson(await api.runs.getSelections(pathParams['project-code'], pathParams['run-id'])) + }, +} + +const folders: ApiEndpointSpec = { + id: 'runs.folders', + commandPath: ['runs', 'folders'], + describe: help.folders.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + queryOptions: runTCaseFilterOptions, + transformQuery: transformRunTCaseFilters, + epilog: help.folders.epilog, + examples: help.folders.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.runs.getFolderTree( + pathParams['project-code'], + pathParams['run-id'], + query as RunTCaseFilters + ) + ) + }, +} + +const statusCounts: ApiEndpointSpec = { + id: 'runs.status-counts', + commandPath: ['runs', 'status-counts'], + describe: help.statusCounts.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + queryOptions: runTCaseFilterOptions, + transformQuery: transformRunTCaseFilters, + epilog: help.statusCounts.epilog, + examples: help.statusCounts.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.runs.getStatusCounts( + pathParams['project-code'], + pathParams['run-id'], + query as RunTCaseFilters + ) + ) + }, +} + +const preview: ApiEndpointSpec = { + id: 'runs.preview', + commandPath: ['runs', 'preview'], + describe: help.preview.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'run-type', + type: 'string', + describe: help['run-type'], + choices: ['static', 'static_struct', 'live'], + }, + { + name: 'query-plans', + type: 'string', + describe: help['query-plans'], + schema: QueryPlansSchema, + jsonParse: true, + }, + { + name: 'run-id', + type: 'number', + describe: help['preview-run-id'], + schema: PreviewShape.runId.unwrap(), + }, + { name: 'offset', type: 'number', describe: help.offset, schema: offsetParam }, + { name: 'limit', type: 'number', describe: help.limit, schema: limitParam }, + ], + check: requireFieldsOrBody('run-type', 'query-plans'), + epilog: help.preview.epilog, + examples: help.preview.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.runs.previewTCases(pathParams['project-code'], body as PreviewRunTCasesRequest) + ) + }, +} + const create: ApiEndpointSpec = { id: 'runs.create', commandPath: ['runs', 'create'], @@ -175,6 +635,12 @@ const create: ApiEndpointSpec = { describe: help['assignment-id'], schema: CreateRunShape.assignmentId.unwrap(), }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['integration-issue-id'], + schema: CreateRunShape.integrationIssueId.unwrap(), + }, { name: 'query-plans', type: 'string', @@ -201,38 +667,6 @@ const create: ApiEndpointSpec = { }, } -const list: ApiEndpointSpec = { - id: 'runs.list', - commandPath: ['runs', 'list'], - describe: help.list.describe, - bodyMode: 'none', - pathParams: [projectCodeParam], - queryOptions: [ - { name: 'closed', type: 'boolean', describe: help.closed }, - { name: 'milestone-ids', type: 'string', describe: help['milestone-ids'] }, - { - name: 'include', - type: 'string', - describe: help['run-include'], - helpChoices: ['project', 'plan', 'milestone', 'assignment', 'configuration', 'statusCounts'], - }, - ...paginationOptions(['title', 'created_at', 'last_activity_at', 'closed_at']), - ], - transformQuery: (query) => ({ - ...kebabToCamelCaseKeys(query), - milestoneIds: - typeof query['milestone-ids'] === 'string' - ? query['milestone-ids'].split(',').map(Number) - : query['milestone-ids'], - include: splitList(query.include), - }), - epilog: help.list.epilog, - examples: help.list.examples, - execute: async (api, { pathParams, query }) => { - printJson(await api.runs.list(pathParams['project-code'], query as ListRunsRequest)) - }, -} - const clone: ApiEndpointSpec = { id: 'runs.clone', commandPath: ['runs', 'clone'], @@ -264,12 +698,24 @@ const clone: ApiEndpointSpec = { describe: 'Milestone ID for the cloned run.', schema: CloneRunRequestSchema.shape.milestoneId.unwrap(), }, + { + name: 'configuration-id', + type: 'string', + describe: 'Configuration ID for the cloned run.', + schema: CloneRunRequestSchema.shape.configurationId.unwrap(), + }, { name: 'assignment-id', type: 'number', describe: 'Assignment ID for the cloned run.', schema: CloneRunRequestSchema.shape.assignmentId.unwrap(), }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['integration-issue-id'], + schema: CloneRunRequestSchema.shape.integrationIssueId.unwrap(), + }, ], check: (argv) => { @@ -288,6 +734,62 @@ const clone: ApiEndpointSpec = { }, } +const update: ApiEndpointSpec = { + id: 'runs.update', + commandPath: ['runs', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, runIdParam], + fieldOptions: [ + { name: 'title', type: 'string', describe: help.title, schema: UpdateRunShape.title.unwrap() }, + { name: 'description', type: 'string', describe: help['update-description'] }, + { + name: 'type', + type: 'string', + describe: help['update-type'], + choices: ['static', 'static_struct', 'live'], + }, + { + name: 'milestone-id', + type: 'number', + describe: help['update-milestone-id'], + schema: UpdateRunShape.milestoneId.unwrap(), + }, + { + name: 'assignment-id', + type: 'number', + describe: help['update-assignment-id'], + schema: UpdateRunShape.assignmentId.unwrap(), + }, + { name: 'configuration-id', type: 'string', describe: help['update-configuration-id'] }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['update-integration-issue-id'], + schema: UpdateRunShape.integrationIssueId.unwrap(), + }, + { + name: 'query-plans', + type: 'string', + describe: help['update-query-plans'], + schema: UpdateRunShape.queryPlans.unwrap(), + jsonParse: true, + }, + ], + check: requireAnyFieldOrBody(...updateFieldNames), + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.runs.update( + pathParams['project-code'], + pathParams['run-id'], + body as UpdateRunRequest + ) + ) + }, +} + const close: ApiEndpointSpec = { id: 'runs.close', commandPath: ['runs', 'close'], @@ -301,6 +803,56 @@ const close: ApiEndpointSpec = { }, } +const reopen: ApiEndpointSpec = { + id: 'runs.reopen', + commandPath: ['runs', 'reopen'], + describe: help.reopen.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + epilog: help.reopen.epilog, + examples: help.reopen.examples, + execute: async (api, { pathParams }) => { + printJson(await api.runs.reopen(pathParams['project-code'], pathParams['run-id'])) + }, +} + +const bulkClose = runIdsSpec('bulk-close', help.bulkClose, (api, code, req) => + api.runs.closeMany(code, req) +) + +const bulkReopen = runIdsSpec('bulk-reopen', help.bulkReopen, (api, code, req) => + api.runs.reopenMany(code, req) +) + +const move: ApiEndpointSpec = { + id: 'runs.move', + commandPath: ['runs', 'move'], + describe: help.move.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + runIdsField, + { + name: 'milestone-id', + type: 'number', + describe: help['move-milestone-id'], + schema: MoveRunsRequestSchema.shape.milestoneId, + }, + ], + transformFields: (fields) => ({ + runIds: splitIntList(fields['run-ids']), + milestoneId: fields['milestone-id'], + }), + check: requireFieldsOrBody('run-ids', 'milestone-id'), + epilog: help.move.epilog, + examples: help.move.examples, + execute: async (api, { pathParams, body }) => { + printJson(await api.runs.moveToMilestone(pathParams['project-code'], body as MoveRunsRequest)) + }, +} + +const remove = runIdsSpec('delete', help.delete, (api, code, req) => api.runs.deleteMany(code, req)) + const tcasesList: ApiEndpointSpec = { id: 'runs.tcases.list', commandPath: ['runs', 'test-cases', 'list'], @@ -308,9 +860,7 @@ const tcasesList: ApiEndpointSpec = { bodyMode: 'none', pathParams: [projectCodeParam, runIdParam], queryOptions: [ - { name: 'search', type: 'string', describe: help.search }, - { name: 'tags', type: 'string', describe: help.tags }, - { name: 'priorities', type: 'string', describe: help.priorities }, + ...runTCaseFilterOptions, { name: 'include', type: 'string', @@ -320,10 +870,7 @@ const tcasesList: ApiEndpointSpec = { ...paginationOptions(['seq', 'folder_id', 'pos', 'title', 'priority', 'status']), ], transformQuery: (query) => ({ - ...kebabToCamelCaseKeys(query), - tags: typeof query.tags === 'string' ? query.tags.split(',').map(Number) : query.tags, - priorities: - typeof query.priorities === 'string' ? query.priorities.split(',') : query.priorities, + ...transformRunTCaseFilters(query), include: splitList(query.include), }), epilog: help.tcasesList.epilog, @@ -344,11 +891,7 @@ const tcasesGet: ApiEndpointSpec = { commandPath: ['runs', 'test-cases', 'get'], describe: help.tcasesGet.describe, bodyMode: 'none', - pathParams: [ - projectCodeParam, - runIdParam, - { name: 'tcase-id', type: 'string', describe: help['tcase-id'], schema: resourceIdSchema }, - ], + pathParams: [projectCodeParam, runIdParam, runTCaseIdParam], epilog: help.tcasesGet.epilog, examples: help.tcasesGet.examples, execute: async (api, { pathParams }) => { @@ -362,6 +905,60 @@ const tcasesGet: ApiEndpointSpec = { }, } +const tcasesUpdateVersion: ApiEndpointSpec = { + id: 'runs.tcases.update-version', + commandPath: ['runs', 'test-cases', 'update-version'], + describe: help.tcasesUpdateVersion.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, runIdParam, runTCaseIdParam], + fieldOptions: [ + { + name: 'update-to-latest-version', + type: 'boolean', + describe: help['update-to-latest-version'], + }, + { name: 'allow-run-tcase-removal', type: 'boolean', describe: help['allow-run-tcase-removal'] }, + ], + // kebab-to-camel would give allowRunTcaseRemoval. + transformFields: ({ 'allow-run-tcase-removal': allowRunTCaseRemoval, ...fields }) => ({ + ...kebabToCamelCaseKeys(fields), + allowRunTCaseRemoval, + }), + check: requireAnyFieldOrBody('update-to-latest-version', 'allow-run-tcase-removal'), + epilog: help.tcasesUpdateVersion.epilog, + examples: help.tcasesUpdateVersion.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.runs.updateTCaseVersion( + pathParams['project-code'], + pathParams['run-id'], + pathParams['tcase-id'], + body as UpdateRunTCaseVersionRequest + ) + ) + }, +} + +const logsList: ApiEndpointSpec = { + id: 'runs.logs.list', + commandPath: ['runs', 'logs', 'list'], + describe: help.logsList.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, runIdParam], + queryOptions: paginationOptions(['created_at', 'api_key_id'], help['logs-sort-field']), + epilog: help.logsList.epilog, + examples: help.logsList.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.runs.listLogs( + pathParams['project-code'], + pathParams['run-id'], + query as ListRunLogsRequest + ) + ) + }, +} + const logsCreate: ApiEndpointSpec = { id: 'runs.logs.create', commandPath: ['runs', 'logs', 'create'], @@ -395,11 +992,26 @@ const logsCreate: ApiEndpointSpec = { } export const runSpecs: ApiEndpointSpec[] = [ - create, list, + listAssigned, + openStatusCounts, + get, + selections, + folders, + statusCounts, + preview, + create, clone, + update, close, + reopen, + bulkClose, + bulkReopen, + move, + remove, tcasesList, tcasesGet, + tcasesUpdateVersion, + logsList, logsCreate, ] diff --git a/src/commands/api/manifests/test-cases.ts b/src/commands/api/manifests/test-cases.ts index 670f9c3..dbfbe25 100644 --- a/src/commands/api/manifests/test-cases.ts +++ b/src/commands/api/manifests/test-cases.ts @@ -18,25 +18,31 @@ import { type UpdateTCasesRequirementsRequest, type UpdateTCasesTagsRequest, } from '../../../api/tcases' -import { resourceIdSchema } from '../../../api/schemas' +import { filterOpEnum, resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' import { commonHelp, idListSchema, intIdParam, + intListSchema, paginationOptions, projectCodeParam, + requireFieldsOrBody, + splitIntList, splitList, } from './utils' -import type { ApiEndpointSpec, ApiFieldSpec, ApiPathParamSpec } from '../types' +import type { ApiEndpointSpec, ApiFieldSpec, ApiPathParamSpec, ApiQueryOptionSpec } from '../types' // CreateTCaseRequestSchema stays a ZodObject despite its .superRefine() refinement, // so its field definitions are available directly via .shape. const CreateTCaseShape = CreateTCaseRequestSchema.shape +const customFieldFilterHelp = + 'Filter by a custom field with --cf- (see custom-fields list); repeat it for several values.' + const help = { ...commonHelp, - 'tcase-id': 'Test case ID.', + 'tcase-id': 'Test case ID, sequence number or legacy ID.', title: 'Test case title, 1-511 characters.', priority: 'Priority level, low/medium/high.', 'precondition-text': @@ -65,13 +71,31 @@ const help = { priorities: 'Comma-separated priorities to filter by (e.g., "low,high").', 'tcase-ids': 'Comma-separated test case references: IDs, sequence numbers or legacy IDs (e.g., "12,13").', + 'tcase-ids-filter': + 'Comma-separated test cases to filter by: IDs, sequence numbers or legacy IDs (e.g., "12,13"). One that matches no test case matches nothing.', + 'template-tcase-ids': + 'Comma-separated template test cases whose filled test cases to return: IDs, sequence numbers or legacy IDs.', + 'requirement-ids': 'Comma-separated requirement IDs to filter by (see requirements list).', + recursive: 'With --folders, also match the test cases in their subfolders.', + 'tags-filter-op': + 'How several --tags combine: any of them (or, the default), all of them (and) or none of them (not).', + 'precondition-ids': + 'Comma-separated shared precondition IDs: test cases that use one of them (see shared-preconditions list).', + 'step-ids': + 'Comma-separated shared step IDs: test cases that use one of them (see shared-steps list).', + 'author-ids': + 'Comma-separated IDs of the users who created the test cases to filter by (see users list).', + 'tcase-created-after': + 'Test cases created at or after this time (RFC 3339, e.g., "2026-01-01T00:00:00Z").', + 'tcase-created-before': + 'Test cases created at or before this time (RFC 3339, e.g., "2026-02-01T00:00:00Z").', 'tcase-version': 'Test case version number.', 'target-folder-id': 'ID of the folder to copy the test cases into.', 'source-project-id': 'Code or ID of the project the test cases are copied from. Defaults to --project-code.', 'new-parent-id': 'ID of the folder to move the test cases into.', items: - 'JSON array of the test cases to move, each with its final 0-based position in the folder (a negative pos counts from the end).\nExample: \'[{"id": "12", "pos": 0}, {"id": "13", "pos": -1}]\'', + 'JSON array of the test cases to move, each with its "id" (ID, sequence number or legacy ID) and its final 0-based "pos" in the folder (a negative pos counts from the end).\nExample: \'[{"id": "12", "pos": 0}, {"id": "13", "pos": -1}]\'', 'add-tags': 'Comma-separated tag titles to add (missing tags are created).', 'remove-tags': 'Comma-separated tag titles to remove. A title also in --add is kept.', 'add-requirements': @@ -83,13 +107,18 @@ const help = { 'exclude-status': 'Comma-separated result statuses to leave out (e.g., "open,skipped").', internal: 'Set to false to leave out system results, such as the reset to open after an edit.', list: { - describe: 'List test cases in a project.', + describe: `List test cases in a project. ${customFieldFilterHelp}`, epilog: apiDocsEpilog('tcases', 'list-test-cases'), examples: [ { usage: '$0 api test-cases list --project-code PRJ', description: 'List test cases in a project', }, + { + usage: + '$0 api test-cases list --project-code PRJ --cf-automation Automated --cf-automation Partial', + description: 'List test cases whose "automation" custom field is Automated or Partial', + }, ], }, get: { @@ -103,7 +132,7 @@ const help = { ], }, count: { - describe: 'Count test cases matching filters.', + describe: `Count test cases matching filters. ${customFieldFilterHelp}`, epilog: apiDocsEpilog('tcases', 'get-test-case-count'), examples: [ { @@ -266,15 +295,94 @@ const tcaseIdsField: ApiFieldSpec = { schema: idListSchema, } -/** Allows a JSON-body command to run when its required fields are set, or --body/--body-file is. */ -const requireFieldsOrBody = - (...fields: string[]) => - (argv: Record): true | string => - argv.body !== undefined || - argv['body-file'] !== undefined || - fields.every((f) => argv[f] !== undefined) - ? true - : `Either --body, --body-file, or ${fields.map((f) => `--${f}`).join(' and ')} is required` +function transformTCaseFields(fields: Record): Record { + const result = kebabToCamelCaseKeys(fields) + + // Handle precondition union + if (fields['precondition-text']) { + result.precondition = { text: fields['precondition-text'] } + } else if (fields['precondition-id']) { + result.precondition = { sharedPreconditionId: fields['precondition-id'] } + } + delete result.preconditionText + delete result.preconditionId + + // Handle comma-separated tags + if (typeof fields.tags === 'string') { + result.tags = fields.tags + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => !!tag) + } + + if (fields['title-suffix-params'] !== undefined) { + result.filledTCaseTitleSuffixParams = splitList(fields['title-suffix-params']) + } + delete result.titleSuffixParams + + return result +} + +/** Like requireFieldsOrBody('tcase-ids'), but also needs at least one of --add and --remove. */ +const requireTCaseIdsAndChange = (argv: Record): true | string => { + if (argv.body !== undefined || argv['body-file'] !== undefined) return true + if (argv['tcase-ids'] === undefined) + return 'Either --body, --body-file, or --tcase-ids is required' + if (argv.add === undefined && argv.remove === undefined) + return 'Either --add or --remove is required' + return true +} + +const tcaseFilterOptions: ApiQueryOptionSpec[] = [ + { name: 'search', type: 'string', describe: 'Search text to filter test cases.' }, + { + name: 'types', + type: 'string', + describe: 'Comma-separated test case types to filter by (e.g., "standalone,template").', + }, + { name: 'folders', type: 'string', describe: help.folders, schema: intListSchema }, + { name: 'recursive', type: 'boolean', describe: help.recursive }, + { name: 'tags', type: 'string', describe: help.tagsFilter, schema: intListSchema }, + { + name: 'tags-filter-op', + type: 'string', + describe: help['tags-filter-op'], + choices: filterOpEnum.options, + }, + { name: 'priorities', type: 'string', describe: help.priorities }, + { name: 'draft', type: 'boolean', describe: help.draft }, + { name: 'tcase-ids', type: 'string', describe: help['tcase-ids-filter'] }, + { name: 'template-tcase-ids', type: 'string', describe: help['template-tcase-ids'] }, + { name: 'requirement-ids', type: 'string', describe: help['requirement-ids'] }, + { + name: 'precondition-ids', + type: 'string', + describe: help['precondition-ids'], + schema: intListSchema, + }, + { name: 'step-ids', type: 'string', describe: help['step-ids'], schema: intListSchema }, + { name: 'author-ids', type: 'string', describe: help['author-ids'], schema: intListSchema }, + { name: 'created-after', type: 'string', describe: help['tcase-created-after'] }, + { name: 'created-before', type: 'string', describe: help['tcase-created-before'] }, +] + +// kebab-to-camel would give templateTcaseIds. +const transformTCaseFilters = ({ + 'template-tcase-ids': templateTCaseIds, + ...query +}: Record) => ({ + ...kebabToCamelCaseKeys(query), + types: splitList(query.types), + folders: splitIntList(query.folders), + tags: splitIntList(query.tags), + priorities: splitList(query.priorities), + tcaseIds: splitList(query['tcase-ids']), + templateTCaseIds: splitList(templateTCaseIds), + requirementIds: splitList(query['requirement-ids']), + preconditionIds: splitIntList(query['precondition-ids']), + stepIds: splitIntList(query['step-ids']), + authorIds: splitIntList(query['author-ids']), +}) const list: ApiEndpointSpec = { id: 'test-cases.list', @@ -295,16 +403,7 @@ const list: ApiEndpointSpec = { 'updated_at', 'legacy_id', ]), - { name: 'folders', type: 'string', describe: help.folders }, - { name: 'tags', type: 'string', describe: help.tagsFilter }, - { name: 'priorities', type: 'string', describe: help.priorities }, - { name: 'search', type: 'string', describe: 'Search text to filter test cases.' }, - { - name: 'types', - type: 'string', - describe: 'Comma-separated test case types to filter by (e.g., "standalone,template").', - }, - { name: 'draft', type: 'boolean', describe: help.draft }, + ...tcaseFilterOptions, { name: 'include', type: 'string', @@ -324,15 +423,10 @@ const list: ApiEndpointSpec = { }, ], transformQuery: (query) => ({ - ...kebabToCamelCaseKeys(query), - folders: - typeof query.folders === 'string' ? query.folders.split(',').map(Number) : query.folders, - tags: typeof query.tags === 'string' ? query.tags.split(',').map(Number) : query.tags, - priorities: - typeof query.priorities === 'string' ? query.priorities.split(',') : query.priorities, - types: typeof query.types === 'string' ? query.types.split(',') : query.types, - include: typeof query.include === 'string' ? query.include.split(',') : query.include, + ...transformTCaseFilters(query), + include: splitList(query.include), }), + customFieldFilters: true, epilog: help.list.epilog, examples: help.list.examples, execute: async (api, { pathParams, query }) => { @@ -340,6 +434,22 @@ const list: ApiEndpointSpec = { }, } +const count: ApiEndpointSpec = { + id: 'test-cases.count', + commandPath: ['test-cases', 'count'], + describe: help.count.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: tcaseFilterOptions, + transformQuery: transformTCaseFilters, + customFieldFilters: true, + epilog: help.count.epilog, + examples: help.count.examples, + execute: async (api, { pathParams, query }) => { + printJson(await api.testCases.count(pathParams['project-code'], query as CountTCasesRequest)) + }, +} + const get: ApiEndpointSpec = { id: 'test-cases.get', commandPath: ['test-cases', 'get'], @@ -356,66 +466,25 @@ const get: ApiEndpointSpec = { }, } -const count: ApiEndpointSpec = { - id: 'test-cases.count', - commandPath: ['test-cases', 'count'], - describe: help.count.describe, +const tcaseGetVersion: ApiEndpointSpec = { + id: 'test-cases.get-version', + commandPath: ['test-cases', 'get-version'], + describe: help.getVersion.describe, bodyMode: 'none', - pathParams: [projectCodeParam], - queryOptions: [ - { name: 'folders', type: 'string', describe: help.folders }, - { - name: 'recursive', - type: 'boolean', - describe: 'If true, include test cases in subfolders when filtering by folders.', - }, - { name: 'tags', type: 'string', describe: help.tagsFilter }, - { name: 'priorities', type: 'string', describe: help.priorities }, - { name: 'draft', type: 'boolean', describe: help.draft }, - ], - transformQuery: (query) => ({ - ...kebabToCamelCaseKeys(query), - folders: - typeof query.folders === 'string' ? query.folders.split(',').map(Number) : query.folders, - tags: typeof query.tags === 'string' ? query.tags.split(',').map(Number) : query.tags, - priorities: - typeof query.priorities === 'string' ? query.priorities.split(',') : query.priorities, - }), - epilog: help.count.epilog, - examples: help.count.examples, - execute: async (api, { pathParams, query }) => { - printJson(await api.testCases.count(pathParams['project-code'], query as CountTCasesRequest)) + pathParams: [projectCodeParam, tcaseIdParam, tcaseVersionParam], + epilog: help.getVersion.epilog, + examples: help.getVersion.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.testCases.getVersion( + pathParams['project-code'], + pathParams['tcase-id'], + pathParams['tcase-version'] as number + ) + ) }, } -function transformTCaseFields(fields: Record): Record { - const result = kebabToCamelCaseKeys(fields) - - // Handle precondition union - if (fields['precondition-text']) { - result.precondition = { text: fields['precondition-text'] } - } else if (fields['precondition-id']) { - result.precondition = { sharedPreconditionId: fields['precondition-id'] } - } - delete result.preconditionText - delete result.preconditionId - - // Handle comma-separated tags - if (typeof fields.tags === 'string') { - result.tags = fields.tags - .split(',') - .map((tag) => tag.trim()) - .filter((tag) => !!tag) - } - - if (fields['title-suffix-params'] !== undefined) { - result.filledTCaseTitleSuffixParams = splitList(fields['title-suffix-params']) - } - delete result.titleSuffixParams - - return result -} - const tcaseCreate: ApiEndpointSpec = { id: 'test-cases.create', commandPath: ['test-cases', 'create'], @@ -521,6 +590,39 @@ const tcaseCreate: ApiEndpointSpec = { }, } +const tcaseCopy: ApiEndpointSpec = { + id: 'test-cases.copy', + commandPath: ['test-cases', 'copy'], + describe: help.copy.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + tcaseIdsField, + { + name: 'target-folder-id', + type: 'number', + describe: help['target-folder-id'], + schema: z.number().int().positive('must be a positive integer'), + }, + { + name: 'source-project-id', + type: 'string', + describe: help['source-project-id'], + schema: resourceIdSchema, + }, + ], + transformFields: (fields) => ({ + ...kebabToCamelCaseKeys(fields), + tcaseIds: splitList(fields['tcase-ids']), + }), + check: requireFieldsOrBody('tcase-ids', 'target-folder-id'), + epilog: help.copy.epilog, + examples: help.copy.examples, + execute: async (api, { pathParams, body }) => { + printJson(await api.testCases.copy(pathParams['project-code'], body as CopyTCasesRequest)) + }, +} + const tcaseUpdate: ApiEndpointSpec = { id: 'test-cases.update', commandPath: ['test-cases', 'update'], @@ -577,7 +679,7 @@ const tcaseUpdate: ApiEndpointSpec = { name: 'parameter-values', type: 'string', describe: - 'JSON array of parameter value sets. Include tcaseId to modify existing filled cases.', + 'JSON array of parameter value sets. Include a filled test case\'s "tcaseId" (ID, sequence number or legacy ID) to change it.', schema: z.array(parameterValueWithIdSchema), jsonParse: true, }, @@ -641,52 +743,22 @@ const tcaseUpdate: ApiEndpointSpec = { }, } -const tcaseDelete: ApiEndpointSpec = { - id: 'test-cases.delete', - commandPath: ['test-cases', 'delete'], - describe: help.delete.describe, - bodyMode: 'json', - pathParams: [projectCodeParam], - fieldOptions: [tcaseIdsField], - transformFields: (fields) => ({ tcaseIds: splitList(fields['tcase-ids']) }), - check: requireFieldsOrBody('tcase-ids'), - epilog: help.delete.epilog, - examples: help.delete.examples, - execute: async (api, { pathParams, body }) => { - printJson(await api.testCases.delete(pathParams['project-code'], body as DeleteTCasesRequest)) - }, -} - -const tcaseCopy: ApiEndpointSpec = { - id: 'test-cases.copy', - commandPath: ['test-cases', 'copy'], - describe: help.copy.describe, - bodyMode: 'json', - pathParams: [projectCodeParam], - fieldOptions: [ - tcaseIdsField, - { - name: 'target-folder-id', - type: 'number', - describe: help['target-folder-id'], - schema: z.number().int().positive('must be a positive integer'), - }, - { - name: 'source-project-id', - type: 'string', - describe: help['source-project-id'], - schema: resourceIdSchema, - }, - ], - transformFields: (fields) => ({ - ...kebabToCamelCaseKeys(fields), - tcaseIds: splitList(fields['tcase-ids']), - }), - check: requireFieldsOrBody('tcase-ids', 'target-folder-id'), - epilog: help.copy.epilog, - examples: help.copy.examples, - execute: async (api, { pathParams, body }) => { - printJson(await api.testCases.copy(pathParams['project-code'], body as CopyTCasesRequest)) +const tcaseRollback: ApiEndpointSpec = { + id: 'test-cases.rollback', + commandPath: ['test-cases', 'rollback'], + describe: help.rollback.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, tcaseIdParam, tcaseVersionParam], + epilog: help.rollback.epilog, + examples: help.rollback.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.testCases.rollback( + pathParams['project-code'], + pathParams['tcase-id'], + pathParams['tcase-version'] as number + ) + ) }, } @@ -751,16 +823,6 @@ const tcaseUpdatePriority: ApiEndpointSpec = { }, } -/** Like requireFieldsOrBody('tcase-ids'), but also needs at least one of --add and --remove. */ -const requireTCaseIdsAndChange = (argv: Record): true | string => { - if (argv.body !== undefined || argv['body-file'] !== undefined) return true - if (argv['tcase-ids'] === undefined) - return 'Either --body, --body-file, or --tcase-ids is required' - if (argv.add === undefined && argv.remove === undefined) - return 'Either --add or --remove is required' - return true -} - const tcaseUpdateTags: ApiEndpointSpec = { id: 'test-cases.update-tags', commandPath: ['test-cases', 'update-tags'], @@ -828,41 +890,19 @@ const tcaseUpdateRequirements: ApiEndpointSpec = { }, } -const tcaseGetVersion: ApiEndpointSpec = { - id: 'test-cases.get-version', - commandPath: ['test-cases', 'get-version'], - describe: help.getVersion.describe, - bodyMode: 'none', - pathParams: [projectCodeParam, tcaseIdParam, tcaseVersionParam], - epilog: help.getVersion.epilog, - examples: help.getVersion.examples, - execute: async (api, { pathParams }) => { - printJson( - await api.testCases.getVersion( - pathParams['project-code'], - pathParams['tcase-id'], - pathParams['tcase-version'] as number - ) - ) - }, -} - -const tcaseRollback: ApiEndpointSpec = { - id: 'test-cases.rollback', - commandPath: ['test-cases', 'rollback'], - describe: help.rollback.describe, - bodyMode: 'none', - pathParams: [projectCodeParam, tcaseIdParam, tcaseVersionParam], - epilog: help.rollback.epilog, - examples: help.rollback.examples, - execute: async (api, { pathParams }) => { - printJson( - await api.testCases.rollback( - pathParams['project-code'], - pathParams['tcase-id'], - pathParams['tcase-version'] as number - ) - ) +const tcaseDelete: ApiEndpointSpec = { + id: 'test-cases.delete', + commandPath: ['test-cases', 'delete'], + describe: help.delete.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [tcaseIdsField], + transformFields: (fields) => ({ tcaseIds: splitList(fields['tcase-ids']) }), + check: requireFieldsOrBody('tcase-ids'), + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams, body }) => { + printJson(await api.testCases.delete(pathParams['project-code'], body as DeleteTCasesRequest)) }, } @@ -904,17 +944,17 @@ const tcaseResultsList: ApiEndpointSpec = { export const testCaseSpecs: ApiEndpointSpec[] = [ list, - get, count, + get, + tcaseGetVersion, tcaseCreate, - tcaseUpdate, - tcaseDelete, tcaseCopy, + tcaseUpdate, + tcaseRollback, tcaseMove, tcaseUpdatePriority, tcaseUpdateTags, tcaseUpdateRequirements, - tcaseGetVersion, - tcaseRollback, + tcaseDelete, tcaseResultsList, ] diff --git a/src/commands/api/manifests/test-plans.ts b/src/commands/api/manifests/test-plans.ts index c4dc59f..6e9d54c 100644 --- a/src/commands/api/manifests/test-plans.ts +++ b/src/commands/api/manifests/test-plans.ts @@ -1,25 +1,64 @@ import { z } from 'zod' -import { CreateTestPlanRequestSchema } from '../../../api/test-plans' -import { RunSchema } from '../../../api/runs' -import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { + CreateTestPlanRequestSchema, + MoveTestPlansRequestSchema, + PlanRunSchema, + UpdateTestPlanRequestSchema, + UpdateTestPlanRunRequestSchema, + type ListTestPlansRequest, + type MoveTestPlansRequest, + type UpdateTestPlanRequest, + type UpdateTestPlanRunRequest, +} from '../../../api/test-plans' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { + commonHelp, + intIdParam, + intListSchema, + paginationOptions, + projectCodeParam, + requireAnyFieldOrBody, + requireFieldsOrBody, + splitIntList, +} from './utils' import type { ApiEndpointSpec } from '../types' const CreateTestPlanShape = CreateTestPlanRequestSchema.shape +const UpdateTestPlanShape = UpdateTestPlanRequestSchema.shape +const UpdateTestPlanRunShape = UpdateTestPlanRunRequestSchema.shape const help = { ...commonHelp, title: 'Test plan title, 1-255 characters.', description: 'Optional description for the test plan.', 'milestone-id': 'ID of the milestone to associate with this test plan.', - runs: `JSON array of run objects.\nEach run needs "title", "type", and "queryPlans".\nExample: '[{"title": "Run 1", "type": "static", "queryPlans": [{"tcaseIds": ["abc"]}]}]'`, + 'integration-issue-id': + "ID or key of an issue in the project's Jira or Linear integration to link the test plan to.", + runs: `JSON array of run objects.\nEach run needs a "title" and exactly one query plan listing its "tcaseIds" (test case IDs, sequence numbers or legacy IDs), and takes an "assignmentId" and a "configurationId".\nExample: '[{"title": "Chrome", "queryPlans": [{"tcaseIds": ["12", "13"]}]}]'`, + 'update-runs': `JSON array that replaces the whole list of runs. Keep a run by passing its "id" (see test-plans get). Runs left out are deleted for good, with their results, and a kept run's assignee and configuration are cleared when left out. To change one run, use test-plans update-run.\nExample: '[{"id": 7, "title": "Chrome", "queryPlans": [{"tcaseIds": ["12"]}]}]'`, + 'update-milestone-id': + 'Milestone to move the test plan and its runs to; 0 takes them out of their milestone.', + 'update-integration-issue-id': + 'Issue to link the test plan to in the project\'s Jira or Linear integration; "" removes the link.', + 'plan-id': 'Test plan ID.', + 'plan-ids': 'Comma-separated test plan IDs (e.g., "1,2"), at most 100.', + 'run-id': 'ID of a run of the test plan.', + 'run-title': 'New run title, 1-255 characters.', + 'assignment-id': 'User to assign the run to; 0 unassigns it.', + 'configuration-id': 'Configuration of the run; "" removes it.', + 'query-plans': `JSON array with exactly one query plan, listing the "tcaseIds" (test case IDs, sequence numbers or legacy IDs) that replace the run's.\nExample: '[{"tcaseIds": ["12", "13"]}]'`, + 'move-milestone-id': 'Milestone to move the test plans to; 0 takes them out of their milestone.', + closed: 'Filter by state: true for closed test plans, false for open ones.', + 'milestone-ids': 'Comma-separated milestone IDs to filter by (e.g., "1,2").', + 'list-sort-field': + 'Field to sort by; created_at by default. last_activity_at is the latest result or run log in any of its runs, and closed_at puts open plans last.', create: { describe: 'Create a new test plan.', epilog: apiDocsEpilog('plan', 'create-new-test-plan'), examples: [ { usage: - '$0 api test-plans create --project-code PRJ --title "Release Plan" --runs \'[{"title": "Smoke Tests", "type": "static", "queryPlans": [{"tcaseIds": ["abc"]}]}]\'', + '$0 api test-plans create --project-code PRJ --title "Release Plan" --runs \'[{"title": "Smoke Tests", "queryPlans": [{"tcaseIds": ["4"]}]}]\'', description: 'Create a test plan with individual fields', }, { @@ -28,13 +67,144 @@ const help = { }, { usage: - '$0 api test-plans create --project-code PRJ --body \'{"title": "Release Plan", "runs": [{"title": "Smoke Tests", "type": "static", "queryPlans": [{"tcaseIds": ["abc"]}]}]}\'', + '$0 api test-plans create --project-code PRJ --body \'{"title": "Release Plan", "runs": [{"title": "Smoke Tests", "queryPlans": [{"tcaseIds": ["4"]}]}]}\'', description: 'Create a test plan using --body', }, ], }, + list: { + describe: 'List the test plans of a project, without their runs.', + epilog: apiDocsEpilog('plan', 'list-test-plans'), + examples: [ + { + usage: '$0 api test-plans list --project-code PRJ --closed false', + description: 'List open test plans', + }, + ], + }, + get: { + describe: 'Get a test plan with its milestone and runs.', + epilog: apiDocsEpilog('plan', 'get-test-plan'), + examples: [ + { + usage: '$0 api test-plans get --project-code PRJ --plan-id 1', + description: 'Get a test plan', + }, + ], + }, + update: { + describe: + 'Update a test plan. Fields left out keep their values. A closed test plan must be reopened first.', + epilog: apiDocsEpilog('plan', 'update-test-plan'), + examples: [ + { + usage: '$0 api test-plans update --project-code PRJ --plan-id 1 --title "Release 2"', + description: 'Rename a test plan', + }, + ], + }, + updateRun: { + describe: + 'Update one run of a test plan. Fields left out keep their values. A closed test plan must be reopened first.', + epilog: apiDocsEpilog('plan', 'update-test-plan-run'), + examples: [ + { + usage: + '$0 api test-plans update-run --project-code PRJ --plan-id 1 --run-id 7 --assignment-id 3', + description: 'Assign a run of a test plan', + }, + ], + }, + close: { + describe: 'Close a test plan and all of its runs.', + epilog: apiDocsEpilog('plan', 'close-test-plan'), + examples: [ + { + usage: '$0 api test-plans close --project-code PRJ --plan-id 1', + description: 'Close a test plan', + }, + ], + }, + reopen: { + describe: + 'Reopen a closed test plan and its runs. The runs catch up with the changes made to their test cases while they were closed.', + epilog: apiDocsEpilog('plan', 'reopen-test-plan'), + examples: [ + { + usage: '$0 api test-plans reopen --project-code PRJ --plan-id 1', + description: 'Reopen a test plan', + }, + ], + }, + move: { + describe: + 'Move test plans, with their runs, into a milestone or out of their milestone. Closed plans stay closed.', + epilog: apiDocsEpilog('plan', 'move-test-plans-to-milestone'), + examples: [ + { + usage: '$0 api test-plans move --project-code PRJ --plan-ids 1,2 --milestone-id 5', + description: 'Move two test plans into milestone 5', + }, + ], + }, + delete: { + describe: + 'Delete a test plan with its runs. They go to the trash as one entry, which trash restore brings back together.', + epilog: apiDocsEpilog('plan', 'delete-test-plan'), + examples: [ + { + usage: '$0 api test-plans delete --project-code PRJ --plan-id 1', + description: 'Delete a test plan', + }, + ], + }, } as const +const planIdParam = intIdParam('plan-id', help['plan-id']) + +const list: ApiEndpointSpec = { + id: 'test-plans.list', + commandPath: ['test-plans', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { name: 'closed', type: 'boolean', describe: help.closed }, + { + name: 'milestone-ids', + type: 'string', + describe: help['milestone-ids'], + schema: intListSchema, + }, + ...paginationOptions( + ['title', 'created_at', 'last_activity_at', 'closed_at'], + help['list-sort-field'] + ), + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + milestoneIds: splitIntList(query['milestone-ids']), + }), + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson(await api.testPlans.list(pathParams['project-code'], query as ListTestPlansRequest)) + }, +} + +const get: ApiEndpointSpec = { + id: 'test-plans.get', + commandPath: ['test-plans', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, planIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.testPlans.get(pathParams['project-code'], pathParams['plan-id'])) + }, +} + const create: ApiEndpointSpec = { id: 'test-plans.create', commandPath: ['test-plans', 'create'], @@ -55,11 +225,17 @@ const create: ApiEndpointSpec = { describe: help['milestone-id'], schema: CreateTestPlanShape.milestoneId.unwrap(), }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['integration-issue-id'], + schema: CreateTestPlanShape.integrationIssueId.unwrap(), + }, { name: 'runs', type: 'string', describe: help.runs, - schema: z.array(RunSchema).min(1, 'Must contain at least one run'), + schema: z.array(PlanRunSchema).min(1, 'Must contain at least one run'), jsonParse: true, }, ], @@ -80,4 +256,179 @@ const create: ApiEndpointSpec = { }, } -export const testPlanSpecs: ApiEndpointSpec[] = [create] +const update: ApiEndpointSpec = { + id: 'test-plans.update', + commandPath: ['test-plans', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, planIdParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: UpdateTestPlanShape.title.unwrap(), + }, + { name: 'description', type: 'string', describe: help.description }, + { + name: 'milestone-id', + type: 'number', + describe: help['update-milestone-id'], + schema: UpdateTestPlanShape.milestoneId.unwrap(), + }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['update-integration-issue-id'], + schema: UpdateTestPlanShape.integrationIssueId.unwrap(), + }, + { + name: 'runs', + type: 'string', + describe: help['update-runs'], + schema: UpdateTestPlanShape.runs.unwrap(), + jsonParse: true, + }, + ], + check: requireAnyFieldOrBody( + 'title', + 'description', + 'milestone-id', + 'integration-issue-id', + 'runs' + ), + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testPlans.update( + pathParams['project-code'], + pathParams['plan-id'], + body as UpdateTestPlanRequest + ) + ) + }, +} + +const updateRun: ApiEndpointSpec = { + id: 'test-plans.update-run', + commandPath: ['test-plans', 'update-run'], + describe: help.updateRun.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, planIdParam, intIdParam('run-id', help['run-id'])], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help['run-title'], + schema: UpdateTestPlanRunShape.title.unwrap(), + }, + { + name: 'assignment-id', + type: 'number', + describe: help['assignment-id'], + schema: UpdateTestPlanRunShape.assignmentId.unwrap(), + }, + { name: 'configuration-id', type: 'string', describe: help['configuration-id'] }, + { + name: 'query-plans', + type: 'string', + describe: help['query-plans'], + schema: UpdateTestPlanRunShape.queryPlans.unwrap(), + jsonParse: true, + }, + ], + check: requireAnyFieldOrBody('title', 'assignment-id', 'configuration-id', 'query-plans'), + epilog: help.updateRun.epilog, + examples: help.updateRun.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testPlans.updateRun( + pathParams['project-code'], + pathParams['plan-id'], + pathParams['run-id'], + body as UpdateTestPlanRunRequest + ) + ) + }, +} + +const close: ApiEndpointSpec = { + id: 'test-plans.close', + commandPath: ['test-plans', 'close'], + describe: help.close.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, planIdParam], + epilog: help.close.epilog, + examples: help.close.examples, + execute: async (api, { pathParams }) => { + printJson(await api.testPlans.close(pathParams['project-code'], pathParams['plan-id'])) + }, +} + +const reopen: ApiEndpointSpec = { + id: 'test-plans.reopen', + commandPath: ['test-plans', 'reopen'], + describe: help.reopen.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, planIdParam], + epilog: help.reopen.epilog, + examples: help.reopen.examples, + execute: async (api, { pathParams }) => { + printJson(await api.testPlans.reopen(pathParams['project-code'], pathParams['plan-id'])) + }, +} + +const move: ApiEndpointSpec = { + id: 'test-plans.move', + commandPath: ['test-plans', 'move'], + describe: help.move.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { name: 'plan-ids', type: 'string', describe: help['plan-ids'], schema: intListSchema }, + { + name: 'milestone-id', + type: 'number', + describe: help['move-milestone-id'], + schema: MoveTestPlansRequestSchema.shape.milestoneId, + }, + ], + transformFields: (fields) => ({ + planIds: splitIntList(fields['plan-ids']), + milestoneId: fields['milestone-id'], + }), + check: requireFieldsOrBody('plan-ids', 'milestone-id'), + epilog: help.move.epilog, + examples: help.move.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testPlans.moveToMilestone(pathParams['project-code'], body as MoveTestPlansRequest) + ) + }, +} + +const remove: ApiEndpointSpec = { + id: 'test-plans.delete', + commandPath: ['test-plans', 'delete'], + describe: help.delete.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, planIdParam], + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams }) => { + printJson(await api.testPlans.delete(pathParams['project-code'], pathParams['plan-id'])) + }, +} + +export const testPlanSpecs: ApiEndpointSpec[] = [ + list, + get, + create, + update, + updateRun, + close, + reopen, + move, + remove, +] diff --git a/src/commands/api/manifests/utils.ts b/src/commands/api/manifests/utils.ts index 9138e7d..53baf01 100644 --- a/src/commands/api/manifests/utils.ts +++ b/src/commands/api/manifests/utils.ts @@ -17,13 +17,16 @@ export const commonHelp = { * Pagination and sorting options of a list command. `sortFields` are the fields its endpoint * sorts by. */ -export const paginationOptions = (sortFields: readonly string[]): ApiQueryOptionSpec[] => [ +export const paginationOptions = ( + sortFields: readonly string[], + sortFieldDescribe: string = commonHelp['sort-field'] +): ApiQueryOptionSpec[] => [ { name: 'offset', type: 'number', describe: commonHelp.offset, schema: offsetParam }, { name: 'limit', type: 'number', describe: commonHelp.limit, schema: limitParam }, { name: 'sort-field', type: 'string', - describe: commonHelp['sort-field'], + describe: sortFieldDescribe, schema: sortFieldParam, helpChoices: sortFields, }, @@ -37,13 +40,16 @@ export const paginationOptions = (sortFields: readonly string[]): ApiQueryOption ] /** - * Reusable project-code path parameter with validation. + * Reusable project-code path parameter with validation. The API also takes the project's ID, + * whose underscore a code can't have. */ export const projectCodeParam: ApiPathParamSpec = { name: 'project-code', type: 'string', - describe: 'Project code identifying the QA Sphere project.', - schema: z.string().regex(/^[a-zA-Z0-9]+$/, 'must contain only latin letters and digits'), + describe: 'Code or ID of the QA Sphere project.', + schema: z + .string() + .regex(/^[a-zA-Z0-9_]+$/, 'must contain only latin letters, digits and underscores'), } /** @@ -71,6 +77,26 @@ export const idListSchema = z 'must be comma-separated IDs (letters, digits, dashes and underscores)' ) +/** Requires --body, --body-file, or every one of the fields. */ +export const requireFieldsOrBody = + (...fields: string[]) => + (argv: Record): true | string => + argv.body !== undefined || + argv['body-file'] !== undefined || + fields.every((f) => argv[f] !== undefined) + ? true + : `Either --body, --body-file, or ${fields.map((f) => `--${f}`).join(' and ')} is required` + +/** Requires --body, --body-file, or at least one of the fields, as update commands do. */ +export const requireAnyFieldOrBody = + (...fields: string[]) => + (argv: Record): true | string => + argv.body !== undefined || + argv['body-file'] !== undefined || + fields.some((f) => argv[f] !== undefined) + ? true + : `At least one field to update is required (--body, --body-file, ${fields.map((f) => `--${f}`).join(', ')})` + export const splitList = (value: unknown): unknown => typeof value === 'string' ? value diff --git a/src/commands/api/types.ts b/src/commands/api/types.ts index dfb9f79..f5c0be7 100644 --- a/src/commands/api/types.ts +++ b/src/commands/api/types.ts @@ -49,6 +49,8 @@ interface ApiEndpointBase { pathParams: ApiPathParamSpec[] queryOptions?: ApiQueryOptionSpec[] transformQuery?: (query: Record) => Record + /** Takes --cf- options, sent in the query as `customFields` by system name. */ + customFieldFilters?: boolean check?: (argv: Record) => true | string execute: ExecuteFn } diff --git a/src/commands/api/utils.ts b/src/commands/api/utils.ts index ff1ee3e..ff8b5e0 100644 --- a/src/commands/api/utils.ts +++ b/src/commands/api/utils.ts @@ -92,6 +92,29 @@ export function kebabToCamelCaseKeys(obj: Record): Record [kebabToCamel(k), v])) } +const customFieldOptionPrefix = 'cf-' + +/** Whether an argument is a --cf- custom field filter. */ +export function isCustomFieldOption(key: string): boolean { + return key.startsWith(customFieldOptionPrefix) +} + +/** + * Collects --cf- options by system name. Repeating an option gives several values, + * which the API matches with OR. + */ +export function collectCustomFieldFilters( + args: Record +): Record | undefined { + const filters: Record = {} + for (const [key, value] of Object.entries(args)) { + if (key.startsWith(customFieldOptionPrefix)) { + filters[key.slice(customFieldOptionPrefix.length)] = [value].flat().map(String) + } + } + return Object.keys(filters).length > 0 ? filters : undefined +} + /** * Parses a JSON string field value. * Returns the parsed value, or throws with a descriptive error. diff --git a/src/tests/api/integrations/get.spec.ts b/src/tests/api/integrations/get.spec.ts new file mode 100644 index 0000000..8e519a7 --- /dev/null +++ b/src/tests/api/integrations/get.spec.ts @@ -0,0 +1,59 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { + id: '1CSWAh3ys_YniqfXepnwT8F', + name: 'Acme Jira', + type: 'jira', + config: {}, + relationConfig: {}, + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/:integrationId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets an integration', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.integrationId).toBe('1CSWAh3ys_YniqfXepnwT8F') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) + testRejectsInvalidIdentifier(runCommand, 'integration-id', 'resource', ['--project-code', 'PRJ']) +}) diff --git a/src/tests/api/integrations/github-issues-create.spec.ts b/src/tests/api/integrations/github-issues-create.spec.ts new file mode 100644 index 0000000..ba0d67e --- /dev/null +++ b/src/tests/api/integrations/github-issues-create.spec.ts @@ -0,0 +1,96 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'github', 'issues', 'create', ...args) + +describe('mocked', () => { + const mockResponse = { id: 13, url: 'https://github.com/acme/app/issues/13' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/integration/github/:integrationId/issue`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('creates a GitHub issue', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'Login fails', + '--description', + 'Steps' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ summary: 'Login fails', description: 'Steps' }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { summary: 'Login fails' } + const requiredArgs = ['--project-code', 'PRJ', '--integration-id', '1CSWAh3ys_YniqfXepnwT8F'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--description', 'Steps'], + expectedRequest: { summary: 'Login fails', description: 'Steps' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { + summary: + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + /summary must be at most 255 characters/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'X', + ]) + + test('requires a summary', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--integration-id', '1CSWAh3ys_YniqfXepnwT8F'), + /Either --body, --body-file, or --summary is required/ + ) + }) +}) diff --git a/src/tests/api/integrations/github-issues-get.spec.ts b/src/tests/api/integrations/github-issues-get.spec.ts new file mode 100644 index 0000000..d41f55e --- /dev/null +++ b/src/tests/api/integrations/github-issues-get.spec.ts @@ -0,0 +1,80 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'github', 'issues', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { + issue: { + id: 1012, + number: 12, + title: 'Login fails', + body: '', + url: 'https://github.com/acme/app/issues/12', + }, + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/github/:integrationId/issue/:issueNumber`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a GitHub issue by number', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-number', + '12' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.issueNumber).toBe('12') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-number', + '12', + ]) + + test('rejects a non-positive issue-number', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-number', + '0' + ), + /--issue-number.*must be a positive integer/ + ) + }) +}) diff --git a/src/tests/api/integrations/jira-issue-types-fields.spec.ts b/src/tests/api/integrations/jira-issue-types-fields.spec.ts new file mode 100644 index 0000000..4558fbc --- /dev/null +++ b/src/tests/api/integrations/jira-issue-types-fields.spec.ts @@ -0,0 +1,75 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'jira', 'issue-types', 'fields', ...args) + +describe('mocked', () => { + const mockResponse = { + fields: [{ fieldId: 'priority', key: 'priority', name: 'Priority', required: true }], + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/jira/:integrationId/issue-type/:issueTypeId/field-metadata`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists the fields of a Jira issue type', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-type-id', + '10001' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.issueTypeId).toBe('10001') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-type-id', + '10001', + ]) + + // Jira's issue type IDs are numeric. + test('rejects a non-numeric issue-type-id', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-type-id', + 'bug' + ), + /--issue-type-id/ + ) + }) +}) diff --git a/src/tests/api/integrations/jira-issue-types-list.spec.ts b/src/tests/api/integrations/jira-issue-types-list.spec.ts new file mode 100644 index 0000000..449c8e3 --- /dev/null +++ b/src/tests/api/integrations/jira-issue-types-list.spec.ts @@ -0,0 +1,54 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'jira', 'issue-types', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + issueTypes: [{ id: '10001', name: 'Bug', description: '', subtask: false, hierarchyLevel: 0 }], + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/jira/:integrationId/issue-type`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('lists Jira issue types', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.integrationId).toBe('1CSWAh3ys_YniqfXepnwT8F') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) +}) diff --git a/src/tests/api/integrations/jira-issues-create.spec.ts b/src/tests/api/integrations/jira-issues-create.spec.ts new file mode 100644 index 0000000..60e2f2f --- /dev/null +++ b/src/tests/api/integrations/jira-issues-create.spec.ts @@ -0,0 +1,121 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'jira', 'issues', 'create', ...args) + +describe('mocked', () => { + const mockResponse = { + id: '10394', + key: 'QA-12', + url: 'https://acme.atlassian.net/browse/QA-12', + attachmentUploadErrors: null, + createdAs: 'integration', + } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/integration/jira/:integrationId/issue`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('creates a Jira issue', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'Login fails', + '--issue-type-id', + '10001', + '--description', + 'Steps', + '--parent-id', + '10300', + '--other-fields', + '{"priority": {"name": "High"}}' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + summary: 'Login fails', + issueTypeId: '10001', + description: 'Steps', + parentId: '10300', + otherFields: { priority: { name: 'High' } }, + }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { summary: 'Login fails', issueTypeId: '10001' } + const requiredArgs = ['--project-code', 'PRJ', '--integration-id', '1CSWAh3ys_YniqfXepnwT8F'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--summary', 'Logout fails'], + expectedRequest: { summary: 'Logout fails', issueTypeId: '10001' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { summary: '', issueTypeId: '10001' }, + /summary must not be empty/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'X', + '--issue-type-id', + '1', + ]) + + test('requires a summary and an issue type', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'X' + ), + /Either --body, --body-file, or --summary and --issue-type-id is required/ + ) + }) +}) diff --git a/src/tests/api/integrations/jira-issues-get.spec.ts b/src/tests/api/integrations/jira-issues-get.spec.ts new file mode 100644 index 0000000..e00e321 --- /dev/null +++ b/src/tests/api/integrations/jira-issues-get.spec.ts @@ -0,0 +1,71 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'jira', 'issues', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { + issue: { + id: '10394', + key: 'QA-12', + summary: 'Login fails', + description: '', + url: 'https://acme.atlassian.net/browse/QA-12', + }, + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/jira/:integrationId/issue/:issueId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a Jira issue by key', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-id', + 'QA-12' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.integrationId).toBe('1CSWAh3ys_YniqfXepnwT8F') + expect(lastParams.issueId).toBe('QA-12') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-id', + 'QA-12', + ]) + testRejectsInvalidIdentifier(runCommand, 'issue-id', 'resource', [ + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) +}) diff --git a/src/tests/api/integrations/jira-issues-search.spec.ts b/src/tests/api/integrations/jira-issues-search.spec.ts new file mode 100644 index 0000000..b9f94d7 --- /dev/null +++ b/src/tests/api/integrations/jira-issues-search.spec.ts @@ -0,0 +1,113 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'jira', 'issues', 'search', ...args) + +describe('mocked', () => { + const mockResponse = { + data: [ + { + id: '10394', + key: 'QA-12', + summary: 'Login fails', + description: '', + url: 'https://acme.atlassian.net/browse/QA-12', + }, + ], + nextPageToken: 'next', + isLast: false, + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/jira/:integrationId/issue`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('searches Jira issues', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes the search params', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--search', + 'login', + '--types', + 'Bug,Story', + '--fields', + 'summary,description', + '--limit', + '50', + '--next-page-token', + 'next', + '--sort-field', + 'key', + '--sort-order', + 'desc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('search')).toBe('login') + expect(lastSearchParams!.getAll('types')).toEqual(['Bug', 'Story']) + expect(lastSearchParams!.getAll('fields')).toEqual(['summary', 'description']) + expect(lastSearchParams!.get('limit')).toBe('50') + expect(lastSearchParams!.get('nextPageToken')).toBe('next') + expect(lastSearchParams!.get('sortField')).toBe('key') + expect(lastSearchParams!.get('sortOrder')).toBe('desc') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) + + test('rejects a limit above 5000', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--limit', + '5001' + ), + /--limit.*must be at most 5000/ + ) + }) +}) diff --git a/src/tests/api/integrations/linear-issues-create.spec.ts b/src/tests/api/integrations/linear-issues-create.spec.ts new file mode 100644 index 0000000..7ad6fb5 --- /dev/null +++ b/src/tests/api/integrations/linear-issues-create.spec.ts @@ -0,0 +1,100 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'linear', 'issues', 'create', ...args) + +describe('mocked', () => { + const mockResponse = { + id: 'l1', + identifier: 'ENG-34', + url: 'https://linear.app/acme/issue/ENG-34', + } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/integration/linear/:integrationId/issue`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('creates a Linear issue', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'Login fails', + '--description', + 'Steps' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ summary: 'Login fails', description: 'Steps' }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { summary: 'Login fails' } + const requiredArgs = ['--project-code', 'PRJ', '--integration-id', '1CSWAh3ys_YniqfXepnwT8F'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--description', 'Steps'], + expectedRequest: { summary: 'Login fails', description: 'Steps' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { + summary: + 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + }, + /summary must be at most 255 characters/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--summary', + 'X', + ]) + + test('requires a summary', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--integration-id', '1CSWAh3ys_YniqfXepnwT8F'), + /Either --body, --body-file, or --summary is required/ + ) + }) +}) diff --git a/src/tests/api/integrations/linear-issues-get.spec.ts b/src/tests/api/integrations/linear-issues-get.spec.ts new file mode 100644 index 0000000..20f9b0b --- /dev/null +++ b/src/tests/api/integrations/linear-issues-get.spec.ts @@ -0,0 +1,69 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'linear', 'issues', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { + issue: { + id: 'l1', + identifier: 'ENG-34', + title: 'Login fails', + url: 'https://linear.app/acme/issue/ENG-34', + }, + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/linear/:integrationId/issue/:issueId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a Linear issue by identifier', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-id', + 'ENG-34' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.issueId).toBe('ENG-34') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--issue-id', + 'ENG-34', + ]) + testRejectsInvalidIdentifier(runCommand, 'issue-id', 'resource', [ + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) +}) diff --git a/src/tests/api/integrations/linear-issues-search.spec.ts b/src/tests/api/integrations/linear-issues-search.spec.ts new file mode 100644 index 0000000..ce6f379 --- /dev/null +++ b/src/tests/api/integrations/linear-issues-search.spec.ts @@ -0,0 +1,100 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'linear', 'issues', 'search', ...args) + +describe('mocked', () => { + const mockResponse = { + data: [ + { + id: 'l1', + identifier: 'ENG-34', + title: 'Login fails', + url: 'https://linear.app/acme/issue/ENG-34', + }, + ], + nextPageToken: null, + isLast: true, + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/integration/linear/:integrationId/issue`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('searches Linear issues', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes the search params', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--search', + 'login', + '--limit', + '20', + '--next-page-token', + 'abc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('search')).toBe('login') + expect(lastSearchParams!.get('limit')).toBe('20') + expect(lastSearchParams!.get('nextPageToken')).toBe('abc') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + ]) + + test('rejects a limit above 250', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--integration-id', + '1CSWAh3ys_YniqfXepnwT8F', + '--limit', + '251' + ), + /--limit.*must be at most 250/ + ) + }) +}) diff --git a/src/tests/api/integrations/list.spec.ts b/src/tests/api/integrations/list.spec.ts new file mode 100644 index 0000000..0e8e563 --- /dev/null +++ b/src/tests/api/integrations/list.spec.ts @@ -0,0 +1,55 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'integrations', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + integrations: [ + { + id: '1CSWAh3ys_YniqfXepnwT8F', + name: 'Acme Jira', + type: 'jira', + config: { url: 'https://acme.atlassian.net' }, + relationConfig: { projectId: '10000' }, + }, + ], + } + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/integration`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test("lists a project's integrations", async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', []) +}) + +test("lists a project's integrations on live server", { tags: ['live'] }, async ({ project }) => { + const result = await runCommand<{ integrations: unknown[] }>('--project-code', project.code) + expect(result.integrations).toEqual([]) +}) diff --git a/src/tests/api/milestones/archive.spec.ts b/src/tests/api/milestones/archive.spec.ts new file mode 100644 index 0000000..9ba7746 --- /dev/null +++ b/src/tests/api/milestones/archive.spec.ts @@ -0,0 +1,73 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createMilestone, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'archive', ...args) + +describe('mocked', () => { + const mockResponse = { + id: 1, + title: 'v1.0', + createdAt: '2026-10-21T00:00:00Z', + updatedAt: '2026-10-21T00:00:00Z', + archivedAt: '2026-10-21T00:00:00Z', + archivedBy: 1, + closedPlans: 0, + closedRuns: 1, + } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/milestone/:milestoneId/archive`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('archives a milestone', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--milestone-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.milestoneId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--milestone-id', '1']) + + test('rejects a non-positive milestone-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '0'), + /--milestone-id.*must be a positive integer/ + ) + }) +}) + +test('archives a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const milestone = await createMilestone(project.code) + const result = await runCommand<{ archivedAt?: string | null; message?: string }>( + '--project-code', + project.code, + '--milestone-id', + String(milestone.id) + ) + expect(result.archivedAt).not.toBeNull() +}) diff --git a/src/tests/api/milestones/delete.spec.ts b/src/tests/api/milestones/delete.spec.ts new file mode 100644 index 0000000..1f49b54 --- /dev/null +++ b/src/tests/api/milestones/delete.spec.ts @@ -0,0 +1,64 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createMilestone, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'delete', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/milestone/:milestoneId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a milestone', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--milestone-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.milestoneId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--milestone-id', '1']) + + test('rejects a non-positive milestone-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '0'), + /--milestone-id.*must be a positive integer/ + ) + }) +}) + +test('deletes a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const milestone = await createMilestone(project.code) + const result = await runCommand<{ archivedAt?: string | null; message?: string }>( + '--project-code', + project.code, + '--milestone-id', + String(milestone.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/milestones/get.spec.ts b/src/tests/api/milestones/get.spec.ts new file mode 100644 index 0000000..ec3c5e8 --- /dev/null +++ b/src/tests/api/milestones/get.spec.ts @@ -0,0 +1,71 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createMilestone, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { + id: 1, + title: 'v1.0', + createdAt: '2026-10-21T00:00:00Z', + updatedAt: '2026-10-21T00:00:00Z', + archivedAt: null, + archivedBy: null, + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/milestone/:milestoneId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a milestone', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--milestone-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.milestoneId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--milestone-id', '1']) + + test('rejects a non-positive milestone-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '0'), + /--milestone-id.*must be a positive integer/ + ) + }) +}) + +test('gets a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const milestone = await createMilestone(project.code) + const result = await runCommand<{ id: number }>( + '--project-code', + project.code, + '--milestone-id', + String(milestone.id) + ) + expect(result.id).toBe(milestone.id) +}) diff --git a/src/tests/api/milestones/unarchive.spec.ts b/src/tests/api/milestones/unarchive.spec.ts new file mode 100644 index 0000000..942a699 --- /dev/null +++ b/src/tests/api/milestones/unarchive.spec.ts @@ -0,0 +1,80 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createMilestone, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'unarchive', ...args) + +describe('mocked', () => { + const mockResponse = { + id: 1, + title: 'v1.0', + createdAt: '2026-10-21T00:00:00Z', + updatedAt: '2026-10-21T00:00:00Z', + archivedAt: null, + archivedBy: null, + } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/milestone/:milestoneId/unarchive`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('unarchives a milestone', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--milestone-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.milestoneId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--milestone-id', '1']) + + test('rejects a non-positive milestone-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '0'), + /--milestone-id.*must be a positive integer/ + ) + }) +}) + +test('unarchives a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const milestone = await createMilestone(project.code) + await runCli( + 'api', + 'milestones', + 'archive', + '--project-code', + project.code, + '--milestone-id', + String(milestone.id) + ) + const result = await runCommand<{ archivedAt?: string | null; message?: string }>( + '--project-code', + project.code, + '--milestone-id', + String(milestone.id) + ) + expect(result.archivedAt).toBeNull() +}) diff --git a/src/tests/api/milestones/update.spec.ts b/src/tests/api/milestones/update.spec.ts new file mode 100644 index 0000000..18b214c --- /dev/null +++ b/src/tests/api/milestones/update.spec.ts @@ -0,0 +1,116 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createMilestone, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'milestones', 'update', ...args) + +describe('mocked', () => { + const mockResponse = { + id: 1, + title: 'v1.1', + createdAt: '2026-10-21T00:00:00Z', + updatedAt: '2026-10-21T00:00:00Z', + archivedAt: null, + archivedBy: null, + } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/milestone/:milestoneId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('renames a milestone', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--milestone-id', + '1', + '--title', + 'v1.1' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.milestoneId).toBe('1') + expect(lastRequest).toEqual({ title: 'v1.1' }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'v1.1' } + const requiredArgs = ['--project-code', 'PRJ', '--milestone-id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--title', 'v1.2'], + expectedRequest: { title: 'v1.2' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--milestone-id', + '1', + '--title', + 'X', + ]) + + test('rejects a non-positive milestone-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', 'X', '--milestone-id', '0'), + /--milestone-id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '1'), + /At least one field to update is required/ + ) + }) +}) + +test('renames a milestone on live server', { tags: ['live'] }, async ({ project }) => { + const milestone = await createMilestone(project.code) + const result = await runCommand<{ title: string }>( + '--project-code', + project.code, + '--milestone-id', + String(milestone.id), + '--title', + `CLI renamed ${milestone.id}` + ) + expect(result.title).toBe(`CLI renamed ${milestone.id}`) +}) diff --git a/src/tests/api/results/batch-create.spec.ts b/src/tests/api/results/batch-create.spec.ts index d9a9fd0..a2233b4 100644 --- a/src/tests/api/results/batch-create.spec.ts +++ b/src/tests/api/results/batch-create.spec.ts @@ -46,7 +46,7 @@ describe('mocked', () => { '--run-id', '1', '--items', - '[{"tcaseId": "tc1", "status": "passed", "links": [{"text": "Log", "url": "https://ci.example.com/1"}]}]' + '[{"tcaseId": "tc1", "status": "passed", "links": [{"integrationId": "i1", "url": "https://acme.atlassian.net/browse/QA-1", "text": "Login fails", "meta": {"id": "10001"}}]}]' ) expect(lastParams.projectCode).toBe('PRJ') expect(lastParams.runId).toBe('1') @@ -55,7 +55,14 @@ describe('mocked', () => { { tcaseId: 'tc1', status: 'passed', - links: [{ text: 'Log', url: 'https://ci.example.com/1' }], + links: [ + { + integrationId: 'i1', + url: 'https://acme.atlassian.net/browse/QA-1', + text: 'Login fails', + meta: { id: '10001' }, + }, + ], }, ], }) @@ -91,6 +98,18 @@ describe('mocked', () => { h.testBodyFile(validBody, validBody, requiredArgs) h.testInvalidJson(requiredArgs) h.testInvalidBody({ items: [] }, /Must contain at least one result item/, requiredArgs) + const item = { tcaseId: 'tc1', status: 'passed' } + h.testInvalidBody( + { items: Array(1001).fill(item) }, + /Must contain at most 1000 result items/, + requiredArgs + ) + const link = { integrationId: 'i1', url: 'https://ci.example.com/1', text: 'CI Log' } + h.testInvalidBody( + { items: Array(21).fill({ ...item, links: Array(5).fill(link) }) }, + /Must contain at most 100 links across the result items/, + requiredArgs + ) } ) }) diff --git a/src/tests/api/results/create.spec.ts b/src/tests/api/results/create.spec.ts index 6868621..00f05d1 100644 --- a/src/tests/api/results/create.spec.ts +++ b/src/tests/api/results/create.spec.ts @@ -11,6 +11,7 @@ import { createFolder, createTCase, createRun, + expectValidationError, testRejectsInvalidIdentifier, testBodyInput, } from '../test-helper' @@ -57,7 +58,7 @@ describe('mocked', () => { expect(result).toEqual({ id: 1 }) }) - test('creates a result with links using text field', async () => { + test('creates a result with issue links', async () => { await runCommand( '--project-code', 'PRJ', @@ -68,14 +69,21 @@ describe('mocked', () => { '--status', 'passed', '--links', - '[{"text": "CI Log", "url": "https://ci.example.com/123"}]' + '[{"integrationId": "i1", "url": "https://acme.atlassian.net/browse/QA-1", "text": "Login fails", "meta": {"id": "10001"}}]' ) expect(lastParams.projectCode).toBe('PRJ') expect(lastParams.runId).toBe('1') expect(lastParams.tcaseId).toBe('tc1') expect(lastRequest).toEqual({ status: 'passed', - links: [{ text: 'CI Log', url: 'https://ci.example.com/123' }], + links: [ + { + integrationId: 'i1', + url: 'https://acme.atlassian.net/browse/QA-1', + text: 'Login fails', + meta: { id: '10001' }, + }, + ], }) }) @@ -117,6 +125,12 @@ describe('mocked', () => { }) h.testInvalidJson(requiredArgs) h.testInvalidBody({ status: 'invalid-status' }, /Invalid option/, requiredArgs) + const link = { integrationId: 'i1', url: 'https://ci.example.com/1', text: 'CI Log' } + h.testInvalidBody( + { status: 'failed', links: Array(6).fill(link) }, + /Must contain at most 5 links/, + requiredArgs + ) } ) }) @@ -138,6 +152,25 @@ describe('validation errors', () => { '--status', 'passed', ]) + + test('rejects a link without an integration', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--tcase-id', + 'tc1', + '--status', + 'failed', + '--links', + '[{"text": "CI Log", "url": "https://ci.example.com/123"}]' + ), + /--links.*integrationId/ + ) + }) }) test('creates a result on live server', { tags: ['live'] }, async ({ project }) => { diff --git a/src/tests/api/results/get.spec.ts b/src/tests/api/results/get.spec.ts new file mode 100644 index 0000000..e50b57b --- /dev/null +++ b/src/tests/api/results/get.spec.ts @@ -0,0 +1,96 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'results', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { id: 10, tcaseId: 'tc1', status: 'failed', comment: '', links: [] } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/result/:resultId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a result', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--result-id', + '10' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastParams.resultId).toBe('10') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--result-id', + '10', + ]) + + test('rejects a non-positive result-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--result-id', '0'), + /--result-id.*must be a positive integer/ + ) + }) +}) + +test('gets a result on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const created = await runCli<{ id: number }>( + 'api', + 'results', + 'create', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--tcase-id', + tcase.id, + '--status', + 'failed' + ) + const result = await runCommand<{ id: number; status: string }>( + '--project-code', + project.code, + '--run-id', + String(run.id), + '--result-id', + String(created.id) + ) + expect(result.id).toBe(created.id) + expect(result.status).toBe('failed') +}) diff --git a/src/tests/api/results/update.spec.ts b/src/tests/api/results/update.spec.ts new file mode 100644 index 0000000..53b67fb --- /dev/null +++ b/src/tests/api/results/update.spec.ts @@ -0,0 +1,161 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'results', 'update', ...args) + +describe('mocked', () => { + const mockResponse = { id: 10, tcaseId: 'tc1', status: 'failed', comment: 'Retried', links: [] } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/result/:resultId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test("updates a result's comment and links", async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--result-id', + '10', + '--comment', + 'Retried', + '--create-links', + '[{"integrationId": "i1", "url": "https://acme.atlassian.net/browse/QA-1", "text": "Login fails", "meta": {"id": "10001"}}]', + '--delete-link-ids', + '3,4' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.resultId).toBe('10') + expect(lastRequest).toEqual({ + comment: 'Retried', + createLinks: [ + { + integrationId: 'i1', + url: 'https://acme.atlassian.net/browse/QA-1', + text: 'Login fails', + meta: { id: '10001' }, + }, + ], + deleteLinkIds: [3, 4], + }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { comment: 'Retried' } + const requiredArgs = ['--project-code', 'PRJ', '--run-id', '1', '--result-id', '10'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--time-taken', '1500'], + expectedRequest: { comment: 'Retried', timeTaken: 1500 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { createLinks: [{ integrationId: 'i1', url: 'ftp://a.com', text: 'Bug' }] }, + /must be an http or https URL/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--result-id', + '10', + '--comment', + 'X', + ]) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--result-id', '10'), + /At least one field to update is required/ + ) + }) + + test('rejects a link without an integration', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--result-id', + '10', + '--create-links', + '[{"url": "https://a.com/1", "text": "Bug"}]' + ), + /--create-links.*integrationId/ + ) + }) +}) + +test("updates a result's comment on live server", { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const created = await runCli<{ id: number }>( + 'api', + 'results', + 'create', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--tcase-id', + tcase.id, + '--status', + 'failed' + ) + const result = await runCommand<{ comment: string }>( + '--project-code', + project.code, + '--run-id', + String(run.id), + '--result-id', + String(created.id), + '--comment', + 'CLI retried' + ) + expect(result.comment).toContain('CLI retried') +}) diff --git a/src/tests/api/runs/bulk-close.spec.ts b/src/tests/api/runs/bulk-close.spec.ts new file mode 100644 index 0000000..7c77c5f --- /dev/null +++ b/src/tests/api/runs/bulk-close.spec.ts @@ -0,0 +1,115 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'bulk-close', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/close`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('closes runs', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-ids', '1, 2') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runIds: [1, 2] }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { runIds: [1] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--run-ids', '2'], + expectedRequest: { runIds: [2] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ runIds: [] }, /Must contain at least one run ID/, requiredArgs) + h.testInvalidBody( + { + runIds: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, + 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, + 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, + 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, + ], + }, + /Must contain at most 100 run IDs/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-ids', '1']) + + test('rejects run IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-ids', '1,0'), + /--run-ids.*must be comma-separated positive integers/ + ) + }) + + test('requires run IDs or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --run-ids is required/ + ) + }) +}) + +test('closes runs on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + await runCommand('--project-code', project.code, '--run-ids', String(run.id)) + const closed = await runCli<{ closedAt: string | null }>( + 'api', + 'runs', + 'get', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(closed.closedAt).not.toBeNull() +}) diff --git a/src/tests/api/runs/bulk-reopen.spec.ts b/src/tests/api/runs/bulk-reopen.spec.ts new file mode 100644 index 0000000..349e985 --- /dev/null +++ b/src/tests/api/runs/bulk-reopen.spec.ts @@ -0,0 +1,116 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'bulk-reopen', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/reopen`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('reopens runs', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-ids', '1, 2') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runIds: [1, 2] }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { runIds: [1] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--run-ids', '2'], + expectedRequest: { runIds: [2] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ runIds: [] }, /Must contain at least one run ID/, requiredArgs) + h.testInvalidBody( + { + runIds: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, + 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, + 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, + 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, + ], + }, + /Must contain at most 100 run IDs/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-ids', '1']) + + test('rejects run IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-ids', '1,0'), + /--run-ids.*must be comma-separated positive integers/ + ) + }) + + test('requires run IDs or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --run-ids is required/ + ) + }) +}) + +test('reopens runs on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + await runCli('api', 'runs', 'close', '--project-code', project.code, '--run-id', String(run.id)) + await runCommand('--project-code', project.code, '--run-ids', String(run.id)) + const reopened = await runCli<{ closedAt: string | null }>( + 'api', + 'runs', + 'get', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(reopened.closedAt).toBeNull() +}) diff --git a/src/tests/api/runs/clone.spec.ts b/src/tests/api/runs/clone.spec.ts index 60c50bf..36aa703 100644 --- a/src/tests/api/runs/clone.spec.ts +++ b/src/tests/api/runs/clone.spec.ts @@ -44,10 +44,19 @@ describe('mocked', () => { '--run-id', '42', '--title', - 'Cloned Run' + 'Cloned Run', + '--configuration-id', + 'cfg-1', + '--integration-issue-id', + 'ENG-34' ) expect(lastParams.projectCode).toBe(project.code) - expect(lastRequest).toEqual({ runId: 42, title: 'Cloned Run' }) + expect(lastRequest).toEqual({ + runId: 42, + title: 'Cloned Run', + configurationId: 'cfg-1', + integrationIssueId: 'ENG-34', + }) expect(result).toEqual({ id: 99 }) }) diff --git a/src/tests/api/runs/create.spec.ts b/src/tests/api/runs/create.spec.ts index d860080..610ae36 100644 --- a/src/tests/api/runs/create.spec.ts +++ b/src/tests/api/runs/create.spec.ts @@ -56,7 +56,7 @@ describe('mocked', () => { expect(result).toEqual({ id: 42 }) }) - test('creates a static_struct run with folderIds and priorities', async ({ project }) => { + test('creates a static_struct run with several filter query plans', async ({ project }) => { await runCommand( '--project-code', project.code, @@ -65,14 +65,14 @@ describe('mocked', () => { '--type', 'static_struct', '--query-plans', - '[{"folderIds": [1, 2], "priorities": ["high"]}]' + '[{"folderIds": [1, 2], "priorities": ["high"]}, {}]' ) expect(lastParams.projectCode).toBe(project.code) expect(lastCreateRunRequest).toEqual({ title: 'Structured Run', type: 'static_struct', - queryPlans: [{ folderIds: [1, 2], priorities: ['high'] }], + queryPlans: [{ folderIds: [1, 2], priorities: ['high'] }, {}], }) }) @@ -96,7 +96,7 @@ describe('mocked', () => { }) }) - test('includes optional milestone-id, configuration-id, and assignment-id', async ({ + test('includes optional milestone-id, configuration-id, assignment-id and integration-issue-id', async ({ project, }) => { await runCommand( @@ -112,6 +112,8 @@ describe('mocked', () => { 'cfg-1', '--assignment-id', '10', + '--integration-issue-id', + 'QA-12', '--query-plans', '[{"tcaseIds": ["abc"]}]' ) @@ -123,6 +125,7 @@ describe('mocked', () => { milestoneId: 5, configurationId: 'cfg-1', assignmentId: 10, + integrationIssueId: 'QA-12', queryPlans: [{ tcaseIds: ['abc'] }], }) }) @@ -211,25 +214,6 @@ describe('validation errors', () => { ) }) - test('rejects description exceeding 512 characters', async () => { - await expectValidationError( - () => - runCommand( - '--project-code', - 'PRJ', - '--title', - 'Valid', - '--type', - 'static', - '--description', - 'x'.repeat(513), - '--query-plans', - '[{"tcaseIds": ["abc"]}]' - ), - /--description.*must be at most 512 characters/ - ) - }) - test('rejects invalid JSON in --query-plans', async () => { await expectValidationError( () => @@ -264,7 +248,7 @@ describe('validation errors', () => { ) }) - test('rejects empty query plan object', async () => { + test('rejects tcaseIds combined with a filter, even an empty one', async () => { await expectValidationError( () => runCommand( @@ -275,9 +259,9 @@ describe('validation errors', () => { '--type', 'static', '--query-plans', - '[{}]' + '[{"tcaseIds": ["abc"], "folderIds": []}]' ), - /must specify at least one filter/ + /tcaseIds can't be combined with folderIds, tagIds or priorities/ ) }) @@ -332,24 +316,10 @@ describe('validation errors', () => { ) }) - test('rejects multiple query plans for static runs', async () => { - await expectValidationError( - () => - runCommand( - '--project-code', - 'PRJ', - '--title', - 'Test', - '--type', - 'static', - '--query-plans', - '[{"tcaseIds": ["abc"]}, {"folderIds": [1]}]' - ), - /supports exactly one query plan/ - ) - }) - - test('rejects multiple query plans for static_struct runs', async () => { + test.each([ + { label: 'beside a filter query plan', plans: '[{"tcaseIds": ["abc"]}, {"folderIds": [1]}]' }, + { label: 'in two query plans', plans: '[{"tcaseIds": ["abc"]}, {"tcaseIds": ["def"]}]' }, + ])('rejects tcaseIds $label', async ({ plans }) => { await expectValidationError( () => runCommand( @@ -360,9 +330,9 @@ describe('validation errors', () => { '--type', 'static_struct', '--query-plans', - '[{"tcaseIds": ["abc"]}, {"folderIds": [1]}]' + plans ), - /supports exactly one query plan/ + /tcaseIds are only allowed in a single query plan/ ) }) diff --git a/src/tests/api/runs/delete.spec.ts b/src/tests/api/runs/delete.spec.ts new file mode 100644 index 0000000..1e89dd3 --- /dev/null +++ b/src/tests/api/runs/delete.spec.ts @@ -0,0 +1,110 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'delete', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/delete`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('deletes runs', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-ids', '1, 2') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runIds: [1, 2] }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { runIds: [1] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--run-ids', '2'], + expectedRequest: { runIds: [2] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ runIds: [] }, /Must contain at least one run ID/, requiredArgs) + h.testInvalidBody( + { + runIds: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, + 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, + 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, + 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, + ], + }, + /Must contain at most 100 run IDs/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-ids', '1']) + + test('rejects run IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-ids', '1,0'), + /--run-ids.*must be comma-separated positive integers/ + ) + }) + + test('requires run IDs or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --run-ids is required/ + ) + }) +}) + +test('deletes runs on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--run-ids', + String(run.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/runs/folders.spec.ts b/src/tests/api/runs/folders.spec.ts new file mode 100644 index 0000000..6a424f8 --- /dev/null +++ b/src/tests/api/runs/folders.spec.ts @@ -0,0 +1,111 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'folders', ...args) + +describe('mocked', () => { + const mockResponse = { folders: [{ id: 1, parentId: 0, title: 'Login', comment: '', pos: 0 }] } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/folder/tree`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test("gets a run's folder tree", async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) + + test('passes the test case filters', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--search', + 'login', + '--tags', + '1,2', + '--tags-filter-op', + 'and', + '--priorities', + 'high', + '--status', + 'failed,blocked', + '--automation-results', + 'automated_failing,has_manual', + '--automation-results-filter-op', + 'or' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('search')).toBe('login') + expect(lastSearchParams!.getAll('tags')).toEqual(['1', '2']) + expect(lastSearchParams!.get('tagsFilterOp')).toBe('and') + expect(lastSearchParams!.getAll('priorities')).toEqual(['high']) + expect(lastSearchParams!.getAll('status')).toEqual(['failed', 'blocked']) + expect(lastSearchParams!.getAll('automationResults')).toEqual([ + 'automated_failing', + 'has_manual', + ]) + expect(lastSearchParams!.get('automationResultsFilterOp')).toBe('or') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) + + test('rejects an unknown status', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--status', 'flaky'), + /--status/ + ) + }) +}) + +test("gets a run's folder tree on live server", { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ folders: { id: number }[] }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.folders.map((f) => f.id)).toContain(folder.ids[0][0]) +}) diff --git a/src/tests/api/runs/get.spec.ts b/src/tests/api/runs/get.spec.ts new file mode 100644 index 0000000..bc009b3 --- /dev/null +++ b/src/tests/api/runs/get.spec.ts @@ -0,0 +1,65 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { id: 42, title: 'Sprint 1', type: 'static', closedAt: null } + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/run/:runId`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a run', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) +}) + +test('gets a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ id: number; closedAt: string | null }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.id).toBe(run.id) + expect(result.closedAt).toBeNull() +}) diff --git a/src/tests/api/runs/list-assigned.spec.ts b/src/tests/api/runs/list-assigned.spec.ts new file mode 100644 index 0000000..84375d1 --- /dev/null +++ b/src/tests/api/runs/list-assigned.spec.ts @@ -0,0 +1,60 @@ +import { HttpResponse, http } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { PaginatedResponse } from '../../../api/schemas' +import { test, baseURL, token, useMockServer, runCli, expectValidationError } from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'list-assigned', ...args) + +describe('mocked', () => { + const mockResponse = { total: 1, offset: 0, limit: 100, data: [{ id: 42, title: 'Sprint 1' }] } + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/run/assigned/paginated`, ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastSearchParams = null + }) + + test('lists the runs assigned to you', async () => { + const result = await runCommand() + expect(result).toEqual(mockResponse) + }) + + test('passes pagination and sort params', async () => { + await runCommand( + '--offset', + '10', + '--limit', + '20', + '--sort-field', + 'last_activity_at', + '--sort-order', + 'asc' + ) + expect(lastSearchParams!.get('offset')).toBe('10') + expect(lastSearchParams!.get('limit')).toBe('20') + expect(lastSearchParams!.get('sortField')).toBe('last_activity_at') + expect(lastSearchParams!.get('sortOrder')).toBe('asc') + }) +}) + +describe('validation errors', () => { + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('lists the runs assigned to you on live server', { tags: ['live'] }, async () => { + const result = await runCommand>('--limit', '1') + expect(Array.isArray(result.data)).toBe(true) +}) diff --git a/src/tests/api/runs/logs-list.spec.ts b/src/tests/api/runs/logs-list.spec.ts new file mode 100644 index 0000000..3b93fad --- /dev/null +++ b/src/tests/api/runs/logs-list.spec.ts @@ -0,0 +1,117 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'logs', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + total: 1, + offset: 0, + limit: 100, + data: [{ id: 'l1', runId: 42, comment: 'Deploy finished' }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/log/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test("lists a run's logs", async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) + + test('passes pagination and sort params', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--offset', + '5', + '--limit', + '10', + '--sort-field', + 'api_key_id', + '--sort-order', + 'asc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('offset')).toBe('5') + expect(lastSearchParams!.get('limit')).toBe('10') + expect(lastSearchParams!.get('sortField')).toBe('api_key_id') + expect(lastSearchParams!.get('sortOrder')).toBe('asc') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test("lists a run's logs on live server", { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const log = await runCli<{ id: string }>( + 'api', + 'runs', + 'logs', + 'create', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--comment', + 'CLI log' + ) + const result = await runCommand<{ data: { id: string }[] }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.data.map((l) => l.id)).toContain(log.id) +}) diff --git a/src/tests/api/runs/move.spec.ts b/src/tests/api/runs/move.spec.ts new file mode 100644 index 0000000..4df7ada --- /dev/null +++ b/src/tests/api/runs/move.spec.ts @@ -0,0 +1,125 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + createMilestone, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'move', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'Runs moved' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/run/milestone`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('moves runs into a milestone', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-ids', + '1,2', + '--milestone-id', + '5' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runIds: [1, 2], milestoneId: 5 }) + expect(result).toEqual(mockResponse) + }) + + test('takes runs out of their milestone with 0', async ({ project }) => { + await runCommand('--project-code', project.code, '--run-ids', '1', '--milestone-id', '0') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ runIds: [1], milestoneId: 0 }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { runIds: [1], milestoneId: 5 } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--milestone-id', '6'], + expectedRequest: { runIds: [1], milestoneId: 6 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { runIds: [1], milestoneId: -1 }, + /milestoneId must be a non-negative integer/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-ids', + '1', + '--milestone-id', + '5', + ]) + + test('requires run IDs and a milestone', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-ids', '1'), + /Either --body, --body-file, or --run-ids and --milestone-id is required/ + ) + }) +}) + +test('moves a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const milestone = await createMilestone(project.code) + await runCommand( + '--project-code', + project.code, + '--run-ids', + String(run.id), + '--milestone-id', + String(milestone.id) + ) + const moved = await runCli<{ milestoneId: number | null }>( + 'api', + 'runs', + 'get', + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(moved.milestoneId).toBe(milestone.id) +}) diff --git a/src/tests/api/runs/open-status-counts.spec.ts b/src/tests/api/runs/open-status-counts.spec.ts new file mode 100644 index 0000000..bde0054 --- /dev/null +++ b/src/tests/api/runs/open-status-counts.spec.ts @@ -0,0 +1,58 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'open-status-counts', ...args) + +describe('mocked', () => { + const mockResponse = { items: [{ runId: 42, counts: { open: 1 } }] } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/open-status`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets the status counts of open runs', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', []) +}) + +test( + 'gets the status counts of open runs on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ items: { runId: number }[] }>('--project-code', project.code) + expect(result.items.map((i) => i.runId)).toContain(run.id) + } +) diff --git a/src/tests/api/runs/preview.spec.ts b/src/tests/api/runs/preview.spec.ts new file mode 100644 index 0000000..56c142d --- /dev/null +++ b/src/tests/api/runs/preview.spec.ts @@ -0,0 +1,133 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'preview', ...args) + +describe('mocked', () => { + const mockResponse = { total: 1, offset: 0, limit: 100, data: [{ id: 'tc1', title: 'Login' }] } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/preview-tcases`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('previews a selection', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-type', + 'live', + '--query-plans', + '[{"tagIds": [1]}]', + '--run-id', + '7', + '--limit', + '20' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + runType: 'live', + queryPlans: [{ tagIds: [1] }], + runId: 7, + limit: 20, + }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { runType: 'static', queryPlans: [{ tcaseIds: ['12'] }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--offset', '10'], + expectedRequest: { runType: 'static', queryPlans: [{ tcaseIds: ['12'] }], offset: 10 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { runType: 'static', queryPlans: [] }, + /Must contain at least one query plan/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-type', + 'live', + '--query-plans', + '[{"tagIds": [1]}]', + ]) + + test('requires a run type and query plans', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-type', 'live'), + /Either --body, --body-file, or --run-type and --query-plans is required/ + ) + }) + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-type', + 'live', + '--query-plans', + '[{"tagIds": [1]}]', + '--limit', + '1001' + ), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('previews a selection on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + await createTCase(project.code, folderId) + const result = await runCommand<{ total: number }>( + '--project-code', + project.code, + '--run-type', + 'live', + '--query-plans', + JSON.stringify([{ folderIds: [folderId] }]) + ) + expect(result.total).toBe(1) +}) diff --git a/src/tests/api/runs/reopen.spec.ts b/src/tests/api/runs/reopen.spec.ts new file mode 100644 index 0000000..f97672f --- /dev/null +++ b/src/tests/api/runs/reopen.spec.ts @@ -0,0 +1,68 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'reopen', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'Run reopened' } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/reopen`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('reopens a run', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) +}) + +test('reopens a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + await runCli('api', 'runs', 'close', '--project-code', project.code, '--run-id', String(run.id)) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/runs/selections.spec.ts b/src/tests/api/runs/selections.spec.ts new file mode 100644 index 0000000..4ad1cac --- /dev/null +++ b/src/tests/api/runs/selections.spec.ts @@ -0,0 +1,71 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'selections', ...args) + +describe('mocked', () => { + const mockResponse = { + selections: [{ id: 3, tcaseCount: 1, tcases: [], folders: [], tags: [], priorities: [] }], + } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/selections`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test("gets a run's selections", async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) +}) + +test("gets a run's selections on live server", { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ selections: { tcaseCount: number }[] }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.selections).toHaveLength(1) + expect(result.selections[0].tcaseCount).toBe(1) +}) diff --git a/src/tests/api/runs/status-counts.spec.ts b/src/tests/api/runs/status-counts.spec.ts new file mode 100644 index 0000000..a31560b --- /dev/null +++ b/src/tests/api/runs/status-counts.spec.ts @@ -0,0 +1,112 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'status-counts', ...args) + +describe('mocked', () => { + const mockResponse = { statusCounts: { open: 1, passed: 0 } } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/status-counts`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('counts the test cases of a run by status', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--run-id', '42') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(result).toEqual(mockResponse) + }) + + test('passes the test case filters', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--search', + 'login', + '--tags', + '1,2', + '--tags-filter-op', + 'and', + '--priorities', + 'high', + '--status', + 'failed,blocked', + '--automation-results', + 'automated_failing,has_manual', + '--automation-results-filter-op', + 'or' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('search')).toBe('login') + expect(lastSearchParams!.getAll('tags')).toEqual(['1', '2']) + expect(lastSearchParams!.get('tagsFilterOp')).toBe('and') + expect(lastSearchParams!.getAll('priorities')).toEqual(['high']) + expect(lastSearchParams!.getAll('status')).toEqual(['failed', 'blocked']) + expect(lastSearchParams!.getAll('automationResults')).toEqual([ + 'automated_failing', + 'has_manual', + ]) + expect(lastSearchParams!.get('automationResultsFilterOp')).toBe('or') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--run-id', '1']) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) + + test('rejects an unknown automation result filter', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--automation-results', 'flaky'), + /--automation-results/ + ) + }) +}) + +test('counts the test cases of a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ statusCounts: Record }>( + '--project-code', + project.code, + '--run-id', + String(run.id) + ) + expect(result.statusCounts.open).toBe(1) +}) diff --git a/src/tests/api/runs/tcases-list.spec.ts b/src/tests/api/runs/tcases-list.spec.ts index 4326724..2d9f509 100644 --- a/src/tests/api/runs/tcases-list.spec.ts +++ b/src/tests/api/runs/tcases-list.spec.ts @@ -70,6 +70,30 @@ describe('mocked', () => { expect(lastUrl!.searchParams.get('sortField')).toEqual('status') expect(result).toEqual(mockResponse) }) + + test('passes status and automation result filters', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--status', + 'failed,blocked', + '--automation-results', + 'automated_failing', + '--automation-results-filter-op', + 'not', + '--tags', + '1,2', + '--tags-filter-op', + 'and' + ) + expect(lastUrl!.searchParams.getAll('status')).toEqual(['failed', 'blocked']) + expect(lastUrl!.searchParams.getAll('automationResults')).toEqual(['automated_failing']) + expect(lastUrl!.searchParams.get('automationResultsFilterOp')).toEqual('not') + expect(lastUrl!.searchParams.getAll('tags')).toEqual(['1', '2']) + expect(lastUrl!.searchParams.get('tagsFilterOp')).toEqual('and') + }) }) describe('validation errors', () => { diff --git a/src/tests/api/runs/tcases-update-version.spec.ts b/src/tests/api/runs/tcases-update-version.spec.ts new file mode 100644 index 0000000..93f53c5 --- /dev/null +++ b/src/tests/api/runs/tcases-update-version.spec.ts @@ -0,0 +1,137 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'runs', 'test-cases', 'update-version', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'Updated', removed: false } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase/:tcaseId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('moves a run test case to the latest version', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--tcase-id', + '12', + '--update-to-latest-version', + '--allow-run-tcase-removal' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastParams.tcaseId).toBe('12') + expect(lastRequest).toEqual({ updateToLatestVersion: true, allowRunTCaseRemoval: true }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { updateToLatestVersion: true } + const requiredArgs = ['--project-code', 'PRJ', '--run-id', '1', '--tcase-id', '12'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--allow-run-tcase-removal'], + expectedRequest: { updateToLatestVersion: true, allowRunTCaseRemoval: true }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { updateToLatestVersion: 'yes' }, + /update-to-latest-version|updateToLatestVersion/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--tcase-id', + '12', + '--update-to-latest-version', + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--run-id', + '1', + '--update-to-latest-version', + ]) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1', '--tcase-id', '12'), + /At least one field to update is required/ + ) + }) +}) + +test( + 'moves a run test case to the latest version on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + await runCli( + 'api', + 'test-cases', + 'update', + '--project-code', + project.code, + '--tcase-id', + tcase.id, + '--title', + 'CLI updated test case' + ) + const result = await runCommand<{ removed: boolean }>( + '--project-code', + project.code, + '--run-id', + String(run.id), + '--tcase-id', + tcase.id, + '--update-to-latest-version' + ) + expect(result.removed).toBe(false) + } +) diff --git a/src/tests/api/runs/update.spec.ts b/src/tests/api/runs/update.spec.ts new file mode 100644 index 0000000..085ca2a --- /dev/null +++ b/src/tests/api/runs/update.spec.ts @@ -0,0 +1,157 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createRun, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'runs', 'update', ...args) + +describe('mocked', () => { + const mockResponse = { id: 42, title: 'Sprint 2', type: 'static', closedAt: null } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/run/:runId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('updates a run, taking it out of its milestone', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--title', + 'Sprint 2', + '--milestone-id', + '0', + '--configuration-id', + '', + '--integration-issue-id', + 'QA-12' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.runId).toBe('42') + expect(lastRequest).toEqual({ + title: 'Sprint 2', + milestoneId: 0, + configurationId: '', + integrationIssueId: 'QA-12', + }) + expect(result).toEqual(mockResponse) + }) + + test('replaces the selection, keeping a query plan by id', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--type', + 'live', + '--query-plans', + '[{"id": 3, "tagIds": [1]}, {"folderIds": [2]}]' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + type: 'live', + queryPlans: [{ id: 3, tagIds: [1] }, { folderIds: [2] }], + }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Sprint 2' } + const requiredArgs = ['--project-code', 'PRJ', '--run-id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--milestone-id', '5'], + expectedRequest: { title: 'Sprint 2', milestoneId: 5 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--run-id', + '1', + '--title', + 'X', + ]) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', 'X', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--run-id', '1'), + /At least one field to update is required/ + ) + }) + + test('rejects tcaseIds beside another query plan', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--run-id', + '1', + '--query-plans', + '[{"id": 3, "tcaseIds": ["abc"]}, {"folderIds": [2]}]' + ), + /--query-plans.*tcaseIds are only allowed in a single query plan/ + ) + }) +}) + +test('updates a run on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [tcase.id]) + const result = await runCommand<{ id: number; title: string }>( + '--project-code', + project.code, + '--run-id', + String(run.id), + '--title', + 'CLI updated run' + ) + expect(result.id).toBe(run.id) + expect(result.title).toBe('CLI updated run') +}) diff --git a/src/tests/api/test-cases/count.spec.ts b/src/tests/api/test-cases/count.spec.ts index 04d8c7e..c5cb51d 100644 --- a/src/tests/api/test-cases/count.spec.ts +++ b/src/tests/api/test-cases/count.spec.ts @@ -38,12 +38,27 @@ describe('mocked', () => { expect(result).toEqual({ count: 42 }) }) - test('passes recursive param as query parameter', async ({ project }) => { - await runCommand('--project-code', project.code, '--folders', '1,2', '--recursive') + test('passes filter params as query parameters', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--folders', + '1,2', + '--recursive', + '--tcase-ids', + '4,TC-9', + '--author-ids', + '2,5', + '--cf-automation', + 'Automated' + ) expect(lastParams.projectCode).toBe(project.code) expect(lastUrl).not.toBeNull() expect(lastUrl!.searchParams.get('recursive')).toEqual('true') expect(lastUrl!.searchParams.getAll('folders')).toEqual(['1', '2']) + expect(lastUrl!.searchParams.getAll('tcaseIds')).toEqual(['4', 'TC-9']) + expect(lastUrl!.searchParams.getAll('authorIds')).toEqual(['2', '5']) + expect(lastUrl!.searchParams.getAll('cf_automation')).toEqual(['Automated']) }) }) diff --git a/src/tests/api/test-cases/list.spec.ts b/src/tests/api/test-cases/list.spec.ts index ba53409..07ebaaf 100644 --- a/src/tests/api/test-cases/list.spec.ts +++ b/src/tests/api/test-cases/list.spec.ts @@ -71,24 +71,74 @@ describe('mocked', () => { '--priorities', 'high', '--search', - 'login' + 'login', + '--tcase-ids', + '4,TC-9', + '--template-tcase-ids', + '3', + '--requirement-ids', + '1CSWAh3ys_YniqfXepnwT8F', + '--recursive', + '--tags', + '1,2', + '--tags-filter-op', + 'and', + '--precondition-ids', + '3', + '--step-ids', + '7,8', + '--author-ids', + '2', + '--created-after', + '2026-01-01T00:00:00Z', + '--created-before', + '2026-02-01T00:00:00Z', + '--cf-automation', + 'Automated', + '--cf-automation', + 'Partial', + '--cf-code', + '007' ) expect(lastParams.projectCode).toBe(project.code) expect(lastSearchParams).not.toBeNull() expect(lastSearchParams!.get('priorities')).toBe('high') expect(lastSearchParams!.get('search')).toBe('login') expect(lastSearchParams!.getAll('folders')).toEqual(['1', '2']) + expect(lastSearchParams!.getAll('tcaseIds')).toEqual(['4', 'TC-9']) + expect(lastSearchParams!.getAll('templateTCaseIds')).toEqual(['3']) + expect(lastSearchParams!.getAll('requirementIds')).toEqual(['1CSWAh3ys_YniqfXepnwT8F']) + expect(lastSearchParams!.get('recursive')).toBe('true') + expect(lastSearchParams!.getAll('tags')).toEqual(['1', '2']) + expect(lastSearchParams!.get('tagsFilterOp')).toBe('and') + expect(lastSearchParams!.getAll('preconditionIds')).toEqual(['3']) + expect(lastSearchParams!.getAll('stepIds')).toEqual(['7', '8']) + expect(lastSearchParams!.getAll('authorIds')).toEqual(['2']) + expect(lastSearchParams!.get('createdAfter')).toBe('2026-01-01T00:00:00Z') + expect(lastSearchParams!.get('createdBefore')).toBe('2026-02-01T00:00:00Z') + expect(lastSearchParams!.getAll('cf_automation')).toEqual(['Automated', 'Partial']) + // Custom field values stay text + expect(lastSearchParams!.get('cf_code')).toBe('007') }) }) describe('validation errors', () => { testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') - test('rejects the removed --page option', async () => { + // The command checks unknown arguments itself, to let --cf-* options through. + test('rejects unknown arguments, such as the removed --page option', async () => { await expectValidationError( () => runCommand('--project-code', 'PRJ', '--page', '1'), /Unknown argument: page/ ) + await expectValidationError( + () => runCommand('--project-code', 'PRJ', 'extra'), + /Unknown argument: extra/ + ) + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--cfAutomation', 'Automated'), + /Unknown argument: cfAutomation/ + ) }) test('rejects --limit above 1000', async () => { diff --git a/src/tests/api/test-helper.ts b/src/tests/api/test-helper.ts index ee1534c..405d4ec 100644 --- a/src/tests/api/test-helper.ts +++ b/src/tests/api/test-helper.ts @@ -155,10 +155,41 @@ export async function createRun(projectCode: string, tcaseIds: string[]): Promis ) } +export async function createMilestone(projectCode: string): Promise<{ id: number }> { + const uid = randomBytes(4).toString('hex') + return runCli<{ id: number }>( + 'api', + 'milestones', + 'create', + '--project-code', + projectCode, + '--title', + `CLI Milestone ${uid}` + ) +} + +export async function createTestPlan( + projectCode: string, + tcaseIds: string[] +): Promise<{ id: number }> { + const uid = randomBytes(4).toString('hex') + return runCli<{ id: number }>( + 'api', + 'test-plans', + 'create', + '--project-code', + projectCode, + '--title', + `CLI Plan ${uid}`, + '--runs', + JSON.stringify([{ title: `CLI Plan Run ${uid}`, queryPlans: [{ tcaseIds }] }]) + ) +} + type ParamType = 'code' | 'int' | 'resource' const paramTypePatterns: Record = { - code: /must contain only latin letters and digits/, + code: /must contain only latin letters, digits and underscores/, int: /must be a positive integer/, resource: /must contain only alphanumeric characters, dashes, and underscores/, } diff --git a/src/tests/api/test-plans/close.spec.ts b/src/tests/api/test-plans/close.spec.ts new file mode 100644 index 0000000..be37e66 --- /dev/null +++ b/src/tests/api/test-plans/close.spec.ts @@ -0,0 +1,68 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'close', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId/close`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('closes a test plan', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--plan-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--plan-id', '1']) + + test('rejects a non-positive plan-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '0'), + /--plan-id.*must be a positive integer/ + ) + }) +}) + +test('closes a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/test-plans/create.spec.ts b/src/tests/api/test-plans/create.spec.ts index 4f2ea5f..0e9908e 100644 --- a/src/tests/api/test-plans/create.spec.ts +++ b/src/tests/api/test-plans/create.spec.ts @@ -40,7 +40,6 @@ describe('mocked', () => { runs: [ { title: 'Run 1', - type: 'static', queryPlans: [{ tcaseIds: ['abc'] }], }, ], @@ -52,7 +51,6 @@ describe('mocked', () => { runs: [ { title: 'Run 1', - type: 'static', queryPlans: [{ tcaseIds: ['abc'] }], }, ], @@ -61,9 +59,7 @@ describe('mocked', () => { }) test('creates a test plan with individual fields', async ({ project }) => { - const runs = JSON.stringify([ - { title: 'Run 1', type: 'static', queryPlans: [{ tcaseIds: ['abc'] }] }, - ]) + const runs = JSON.stringify([{ title: 'Run 1', queryPlans: [{ tcaseIds: ['abc'] }] }]) const result = await runCommand( '--project-code', project.code, @@ -74,14 +70,17 @@ describe('mocked', () => { '--description', 'A test plan', '--milestone-id', - '5' + '5', + '--integration-issue-id', + 'QA-12' ) expect(lastParams.projectCode).toBe(project.code) expect(lastRequest).toEqual({ title: 'My Plan', description: 'A test plan', milestoneId: 5, - runs: [{ title: 'Run 1', type: 'static', queryPlans: [{ tcaseIds: ['abc'] }] }], + integrationIssueId: 'QA-12', + runs: [{ title: 'Run 1', queryPlans: [{ tcaseIds: ['abc'] }] }], }) expect(result).toEqual({ id: 1 }) }) @@ -92,7 +91,7 @@ describe('mocked', () => { (h) => { const validBody = { title: 'Plan', - runs: [{ title: 'Run 1', type: 'static', queryPlans: [{ tcaseIds: ['abc'] }] }], + runs: [{ title: 'Run 1', queryPlans: [{ tcaseIds: ['abc'] }] }], } const requiredArgs = ['--project-code', 'PRJ'] h.testInlineBody(validBody, validBody, requiredArgs) @@ -109,6 +108,12 @@ describe('mocked', () => { /must not be empty|Must contain at least one run/, requiredArgs ) + // A plan's run lists its test cases; it takes no filters. + h.testInvalidBody( + { title: 'Plan', runs: [{ title: 'Run 1', queryPlans: [{ folderIds: [1] }] }] }, + /tcaseIds/, + requiredArgs + ) } ) }) @@ -118,7 +123,7 @@ describe('validation errors', () => { '--body', JSON.stringify({ title: 'Plan', - runs: [{ title: 'Run', type: 'static', queryPlans: [{ tcaseIds: ['abc'] }] }], + runs: [{ title: 'Run', queryPlans: [{ tcaseIds: ['abc'] }] }], }), ]) }) @@ -139,7 +144,6 @@ test('creates a test plan on live server', { tags: ['live'] }, async ({ project runs: [ { title: 'Run 1', - type: 'static', queryPlans: [{ tcaseIds: [tcase.id] }], }, ], diff --git a/src/tests/api/test-plans/delete.spec.ts b/src/tests/api/test-plans/delete.spec.ts new file mode 100644 index 0000000..27e343a --- /dev/null +++ b/src/tests/api/test-plans/delete.spec.ts @@ -0,0 +1,68 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'delete', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a test plan', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--plan-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--plan-id', '1']) + + test('rejects a non-positive plan-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '0'), + /--plan-id.*must be a positive integer/ + ) + }) +}) + +test('deletes a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/test-plans/get.spec.ts b/src/tests/api/test-plans/get.spec.ts new file mode 100644 index 0000000..cd3c3c5 --- /dev/null +++ b/src/tests/api/test-plans/get.spec.ts @@ -0,0 +1,69 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'get', ...args) + +describe('mocked', () => { + const mockResponse = { id: 1, title: 'Release', milestoneId: null, closedAt: null, runs: [] } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a test plan', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--plan-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--plan-id', '1']) + + test('rejects a non-positive plan-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '0'), + /--plan-id.*must be a positive integer/ + ) + }) +}) + +test('gets a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const result = await runCommand<{ id: number; runs: unknown[] }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + expect(result.id).toBe(plan.id) + expect(result.runs).toHaveLength(1) +}) diff --git a/src/tests/api/test-plans/list.spec.ts b/src/tests/api/test-plans/list.spec.ts new file mode 100644 index 0000000..16cc4ce --- /dev/null +++ b/src/tests/api/test-plans/list.spec.ts @@ -0,0 +1,100 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + total: 1, + offset: 0, + limit: 100, + data: [{ id: 1, title: 'Release', milestoneId: null, closedAt: null }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/plan/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('lists test plans', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes filters, pagination and sort params', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--closed', + 'false', + '--milestone-ids', + '1,2', + '--limit', + '20', + '--sort-field', + 'closed_at', + '--sort-order', + 'asc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams!.get('closed')).toBe('false') + expect(lastSearchParams!.getAll('milestoneIds')).toEqual(['1', '2']) + expect(lastSearchParams!.get('limit')).toBe('20') + expect(lastSearchParams!.get('sortField')).toBe('closed_at') + expect(lastSearchParams!.get('sortOrder')).toBe('asc') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', []) + + test('rejects milestone IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-ids', '1,x'), + /--milestone-ids.*must be comma-separated positive integers/ + ) + }) + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('lists test plans on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const result = await runCommand<{ data: { id: number }[] }>('--project-code', project.code) + expect(result.data.map((p) => p.id)).toContain(plan.id) +}) diff --git a/src/tests/api/test-plans/move.spec.ts b/src/tests/api/test-plans/move.spec.ts new file mode 100644 index 0000000..6b405f0 --- /dev/null +++ b/src/tests/api/test-plans/move.spec.ts @@ -0,0 +1,127 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + createMilestone, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'move', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'Plans moved' } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/plan/milestone`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('moves test plans into a milestone', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--plan-ids', + '1,2', + '--milestone-id', + '5' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ planIds: [1, 2], milestoneId: 5 }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { planIds: [1], milestoneId: 5 } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--plan-ids', '2'], + expectedRequest: { planIds: [2], milestoneId: 5 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { planIds: [], milestoneId: 5 }, + /Must contain at least one plan ID/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--plan-ids', + '1', + '--milestone-id', + '5', + ]) + + test('rejects plan IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-ids', '0', '--milestone-id', '5'), + /--plan-ids.*must be comma-separated positive integers/ + ) + }) + + test('requires plan IDs and a milestone', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--milestone-id', '5'), + /Either --body, --body-file, or --plan-ids and --milestone-id is required/ + ) + }) +}) + +test('moves a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const milestone = await createMilestone(project.code) + await runCommand( + '--project-code', + project.code, + '--plan-ids', + String(plan.id), + '--milestone-id', + String(milestone.id) + ) + const moved = await runCli<{ milestoneId: number | null }>( + 'api', + 'test-plans', + 'get', + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + expect(moved.milestoneId).toBe(milestone.id) +}) diff --git a/src/tests/api/test-plans/reopen.spec.ts b/src/tests/api/test-plans/reopen.spec.ts new file mode 100644 index 0000000..6d43641 --- /dev/null +++ b/src/tests/api/test-plans/reopen.spec.ts @@ -0,0 +1,77 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'reopen', ...args) + +describe('mocked', () => { + const mockResponse = { message: 'ok' } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId/reopen`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('reopens a test plan', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--plan-id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--plan-id', '1']) + + test('rejects a non-positive plan-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '0'), + /--plan-id.*must be a positive integer/ + ) + }) +}) + +test('reopens a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + await runCli( + 'api', + 'test-plans', + 'close', + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + expect(result.message).toBeTruthy() +}) diff --git a/src/tests/api/test-plans/update-run.spec.ts b/src/tests/api/test-plans/update-run.spec.ts new file mode 100644 index 0000000..1c2b271 --- /dev/null +++ b/src/tests/api/test-plans/update-run.spec.ts @@ -0,0 +1,137 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'update-run', ...args) + +describe('mocked', () => { + const mockResponse = { id: 7, title: 'Chrome', assignmentId: 3 } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId/run/:runId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('updates a run of a test plan', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--plan-id', + '1', + '--run-id', + '7', + '--assignment-id', + '3', + '--configuration-id', + '', + '--query-plans', + '[{"tcaseIds": ["12", "13"]}]' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(lastParams.runId).toBe('7') + expect(lastRequest).toEqual({ + assignmentId: 3, + configurationId: '', + queryPlans: [{ tcaseIds: ['12', '13'] }], + }) + expect(result).toEqual(mockResponse) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Chrome' } + const requiredArgs = ['--project-code', 'PRJ', '--plan-id', '1', '--run-id', '7'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--assignment-id', '0'], + expectedRequest: { title: 'Chrome', assignmentId: 0 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ queryPlans: [] }, /exactly one query plan/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--plan-id', + '1', + '--run-id', + '7', + '--title', + 'X', + ]) + + test('rejects a non-positive run-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '1', '--title', 'X', '--run-id', '0'), + /--run-id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '1', '--run-id', '7'), + /At least one field to update is required/ + ) + }) +}) + +test('updates a run of a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const got = await runCli<{ runs: { id: number }[] }>( + 'api', + 'test-plans', + 'get', + '--project-code', + project.code, + '--plan-id', + String(plan.id) + ) + const result = await runCommand<{ title: string }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id), + '--run-id', + String(got.runs[0].id), + '--title', + 'CLI updated plan run' + ) + expect(result.title).toBe('CLI updated plan run') +}) diff --git a/src/tests/api/test-plans/update.spec.ts b/src/tests/api/test-plans/update.spec.ts new file mode 100644 index 0000000..123922a --- /dev/null +++ b/src/tests/api/test-plans/update.spec.ts @@ -0,0 +1,147 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + createFolder, + createTCase, + createTestPlan, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-plans', 'update', ...args) + +describe('mocked', () => { + const mockResponse = { id: 1, title: 'Release 2', milestoneId: null, closedAt: null, runs: [] } + let lastParams: PathParams = {} + let lastRequest: unknown = null + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/plan/:planId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + lastRequest = null + }) + + test('updates a test plan', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--plan-id', + '1', + '--title', + 'Release 2', + '--milestone-id', + '0', + '--integration-issue-id', + '' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.planId).toBe('1') + expect(lastRequest).toEqual({ title: 'Release 2', milestoneId: 0, integrationIssueId: '' }) + expect(result).toEqual(mockResponse) + }) + + test('replaces the runs, keeping one by id', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--plan-id', + '1', + '--runs', + '[{"id": 7, "title": "Chrome", "queryPlans": [{"tcaseIds": ["12"]}]}]' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + runs: [{ id: 7, title: 'Chrome', queryPlans: [{ tcaseIds: ['12'] }] }], + }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Release 2' } + const requiredArgs = ['--project-code', 'PRJ', '--plan-id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--description', 'Second release'], + expectedRequest: { title: 'Release 2', description: 'Second release' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--plan-id', + '1', + '--title', + 'X', + ]) + + test('rejects a non-positive plan-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', 'X', '--plan-id', '0'), + /--plan-id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--plan-id', '1'), + /At least one field to update is required/ + ) + }) + + test('rejects a run with two query plans', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--plan-id', + '1', + '--runs', + '[{"title": "Chrome", "queryPlans": [{"tcaseIds": ["1"]}, {"tcaseIds": ["2"]}]}]' + ), + /--runs.*exactly one query plan/ + ) + }) +}) + +test('updates a test plan on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const tcase = await createTCase(project.code, folder.ids[0][0]) + const plan = await createTestPlan(project.code, [tcase.id]) + const result = await runCommand<{ title: string }>( + '--project-code', + project.code, + '--plan-id', + String(plan.id), + '--title', + 'CLI updated plan' + ) + expect(result.title).toBe('CLI updated plan') +})