From 8b13a633c89d9f927a8289771e884a07200b51db Mon Sep 17 00:00:00 2001 From: Himanshu Rai Date: Tue, 20 Oct 2026 15:57:15 +0530 Subject: [PATCH] Add new API commands (1) Add CLI commands for the new public API endpoints for folders, test cases, trash, tags, requirements, shared steps and preconditions, projects, AI rules and configurations, and move every command off the deprecated endpoints. New commands: - ai-rules list - configurations list, get, create, update, delete - folders get, tree, path, create, update, move, copy, delete - projects update, users list - requirements get, create, update, delete - shared-preconditions create, update, delete - shared-steps create, update, delete - tags create, update, rename, delete - test-cases get-version, rollback, move, copy, delete, update-priority, update-tags, update-requirements, results list - trash list, restore test-cases create and update gain --requirements, --links and --title-suffix-params, and create gains --pos, so every field can be set without --body. Paginated endpoints: - List commands call the /paginated endpoints and print the page the API returns, {total, offset, limit, data}. --offset and --limit (0-1000, default 100) select the page, and --page is gone. - runs list gains --include, as the paginated endpoint adds related data only on request. tags list gains --include, and runs test-cases list takes several comma-separated values. - Result uploads read run test cases and test cases by marker page by page, and create test cases through /tcase/batch, 1,000 at a time. folders bulk-create calls /folder/upsert. - Bulk commands take at most 1,000 items, like the API. --include and --sort-field list their values in the help but leave checking them to the API, so new values need no CLI update. The --create-tcases mapping file paired markers with the wrong titles when a title in the middle was reused. createNewTCases no longer looks up reusable test cases a second time, which caused it; the caller has already left them out. Conventions: - One command per action. Bulk actions take comma-separated IDs and call the multi-item endpoint (--tcase-ids, --folder-ids, --tag-ids). - New resources take ---id; shared steps and preconditions keep --id, like their existing get. - --tcase-version instead of --version, which is yargs' global flag. - Helpers for integer ID params and comma-separated lists are shared in manifests/utils.ts. The command trees in the README and the agent skill list each command's required options, with a note that every command sending a body also accepts --body or --body-file. Both also describe pagination. --- CLAUDE.md | 6 +- README.md | 128 ++-- skills/qas-cli/SKILL.md | 120 ++-- src/api/ai-rules.ts | 45 ++ src/api/configurations.ts | 74 +++ src/api/folders.ts | 185 +++++- src/api/index.ts | 6 + src/api/milestones.ts | 13 +- src/api/projects.ts | 96 ++- src/api/requirements.ts | 82 ++- src/api/runs.ts | 39 +- src/api/schemas.ts | 39 +- src/api/shared-preconditions.ts | 71 ++- src/api/shared-steps.ts | 92 ++- src/api/tags.ts | 102 +++- src/api/tcases.ts | 280 +++++++-- src/api/trash.ts | 66 +++ src/api/utils.ts | 16 + src/commands/api/builder.ts | 5 +- src/commands/api/manifests/ai-rules.ts | 57 ++ src/commands/api/manifests/configurations.ts | 185 ++++++ src/commands/api/manifests/folders.ts | 439 ++++++++++++-- src/commands/api/manifests/index.ts | 6 + src/commands/api/manifests/milestones.ts | 18 +- src/commands/api/manifests/projects.ts | 171 +++++- src/commands/api/manifests/requirements.ts | 209 ++++++- src/commands/api/manifests/runs.ts | 39 +- .../api/manifests/shared-preconditions.ts | 157 ++++- src/commands/api/manifests/shared-steps.ts | 175 ++++-- src/commands/api/manifests/tags.ts | 191 +++++- src/commands/api/manifests/test-cases.ts | 561 +++++++++++++++++- src/commands/api/manifests/trash.ts | 93 +++ src/commands/api/manifests/utils.ts | 70 ++- src/commands/api/types.ts | 6 + src/commands/api/utils.ts | 3 +- src/tests/api/ai-rules/list.spec.ts | 76 +++ src/tests/api/configurations/create.spec.ts | 94 +++ src/tests/api/configurations/delete.spec.ts | 67 +++ src/tests/api/configurations/get.spec.ts | 68 +++ src/tests/api/configurations/list.spec.ts | 102 ++++ src/tests/api/configurations/update.spec.ts | 117 ++++ src/tests/api/folders/bulk-create.spec.ts | 12 +- src/tests/api/folders/copy.spec.ts | 110 ++++ src/tests/api/folders/create.spec.ts | 114 ++++ src/tests/api/folders/delete.spec.ts | 133 +++++ src/tests/api/folders/get.spec.ts | 65 ++ src/tests/api/folders/list.spec.ts | 29 +- src/tests/api/folders/move.spec.ts | 145 +++++ src/tests/api/folders/path.spec.ts | 78 +++ src/tests/api/folders/tree.spec.ts | 87 +++ src/tests/api/folders/update.spec.ts | 117 ++++ src/tests/api/milestones/list.spec.ts | 30 +- src/tests/api/projects/list.spec.ts | 49 +- src/tests/api/projects/update.spec.ts | 156 +++++ src/tests/api/projects/users-list.spec.ts | 64 ++ src/tests/api/requirements/create.spec.ts | 120 ++++ src/tests/api/requirements/delete.spec.ts | 71 +++ src/tests/api/requirements/get.spec.ts | 73 +++ src/tests/api/requirements/list.spec.ts | 26 +- src/tests/api/requirements/update.spec.ts | 134 +++++ src/tests/api/runs/list.spec.ts | 55 +- src/tests/api/runs/tcases-list.spec.ts | 41 +- .../api/shared-preconditions/create.spec.ts | 110 ++++ .../api/shared-preconditions/delete.spec.ts | 74 +++ .../api/shared-preconditions/list.spec.ts | 24 +- .../api/shared-preconditions/update.spec.ts | 116 ++++ src/tests/api/shared-steps/create.spec.ts | 120 ++++ src/tests/api/shared-steps/delete.spec.ts | 74 +++ src/tests/api/shared-steps/list.spec.ts | 26 +- src/tests/api/shared-steps/update.spec.ts | 137 +++++ src/tests/api/tags/create.spec.ts | 86 +++ src/tests/api/tags/delete.spec.ts | 113 ++++ src/tests/api/tags/list.spec.ts | 23 +- src/tests/api/tags/rename.spec.ts | 114 ++++ src/tests/api/tags/update.spec.ts | 111 ++++ src/tests/api/test-cases/copy.spec.ts | 127 ++++ src/tests/api/test-cases/create.spec.ts | 82 ++- src/tests/api/test-cases/delete.spec.ts | 98 +++ src/tests/api/test-cases/get-version.spec.ts | 111 ++++ src/tests/api/test-cases/list.spec.ts | 37 +- src/tests/api/test-cases/move.spec.ts | 139 +++++ src/tests/api/test-cases/results-list.spec.ts | 124 ++++ src/tests/api/test-cases/rollback.spec.ts | 107 ++++ .../api/test-cases/update-priority.spec.ts | 121 ++++ .../test-cases/update-requirements.spec.ts | 150 +++++ src/tests/api/test-cases/update-tags.spec.ts | 145 +++++ src/tests/api/test-cases/update.spec.ts | 28 +- src/tests/api/trash/list.spec.ts | 132 +++++ src/tests/api/trash/restore.spec.ts | 112 ++++ src/tests/api/utils.spec.ts | 30 +- src/tests/bearer-upload.spec.ts | 40 +- src/tests/result-upload.spec.ts | 82 ++- .../ResultUploadCommandHandler.ts | 110 ++-- src/utils/result-upload/ResultUploader.ts | 5 +- 94 files changed, 8353 insertions(+), 633 deletions(-) create mode 100644 src/api/ai-rules.ts create mode 100644 src/api/configurations.ts create mode 100644 src/api/trash.ts create mode 100644 src/commands/api/manifests/ai-rules.ts create mode 100644 src/commands/api/manifests/configurations.ts create mode 100644 src/commands/api/manifests/trash.ts create mode 100644 src/tests/api/ai-rules/list.spec.ts create mode 100644 src/tests/api/configurations/create.spec.ts create mode 100644 src/tests/api/configurations/delete.spec.ts create mode 100644 src/tests/api/configurations/get.spec.ts create mode 100644 src/tests/api/configurations/list.spec.ts create mode 100644 src/tests/api/configurations/update.spec.ts create mode 100644 src/tests/api/folders/copy.spec.ts create mode 100644 src/tests/api/folders/create.spec.ts create mode 100644 src/tests/api/folders/delete.spec.ts create mode 100644 src/tests/api/folders/get.spec.ts create mode 100644 src/tests/api/folders/move.spec.ts create mode 100644 src/tests/api/folders/path.spec.ts create mode 100644 src/tests/api/folders/tree.spec.ts create mode 100644 src/tests/api/folders/update.spec.ts create mode 100644 src/tests/api/projects/update.spec.ts create mode 100644 src/tests/api/projects/users-list.spec.ts create mode 100644 src/tests/api/requirements/create.spec.ts create mode 100644 src/tests/api/requirements/delete.spec.ts create mode 100644 src/tests/api/requirements/get.spec.ts create mode 100644 src/tests/api/requirements/update.spec.ts create mode 100644 src/tests/api/shared-preconditions/create.spec.ts create mode 100644 src/tests/api/shared-preconditions/delete.spec.ts create mode 100644 src/tests/api/shared-preconditions/update.spec.ts create mode 100644 src/tests/api/shared-steps/create.spec.ts create mode 100644 src/tests/api/shared-steps/delete.spec.ts create mode 100644 src/tests/api/shared-steps/update.spec.ts create mode 100644 src/tests/api/tags/create.spec.ts create mode 100644 src/tests/api/tags/delete.spec.ts create mode 100644 src/tests/api/tags/rename.spec.ts create mode 100644 src/tests/api/tags/update.spec.ts create mode 100644 src/tests/api/test-cases/copy.spec.ts create mode 100644 src/tests/api/test-cases/delete.spec.ts create mode 100644 src/tests/api/test-cases/get-version.spec.ts create mode 100644 src/tests/api/test-cases/move.spec.ts create mode 100644 src/tests/api/test-cases/results-list.spec.ts create mode 100644 src/tests/api/test-cases/rollback.spec.ts create mode 100644 src/tests/api/test-cases/update-priority.spec.ts create mode 100644 src/tests/api/test-cases/update-requirements.spec.ts create mode 100644 src/tests/api/test-cases/update-tags.spec.ts create mode 100644 src/tests/api/trash/list.spec.ts create mode 100644 src/tests/api/trash/restore.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 40617bd..ff71219 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`) +- `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 - `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()` @@ -91,9 +91,9 @@ Important note: Online documentation is available at https://docs.qasphere.com. Composable fetch wrappers using higher-order functions: -- `utils.ts` — `withBaseUrl`, `withAuth`, `withJson`, `withUserAgent`, `withHttpRetry` decorators that wrap `fetch` via middleware pattern; `jsonResponse()` for parsing responses; `appendSearchParams()` for building query strings +- `utils.ts` — `withBaseUrl`, `withAuth`, `withJson`, `withUserAgent`, `withHttpRetry` decorators that wrap `fetch` via middleware pattern; `jsonResponse()` for parsing responses; `appendSearchParams()` for building query strings; `fetchAllPages()` for reading every page of a paginated list - `index.ts` — `createApi(baseUrl, token)` assembles the API client from all sub-modules using `withFetchMiddlewares`. All requests authenticate with `Authorization: Bearer ` (works for both API keys and OAuth access tokens) -- `schemas.ts` — Shared types (`ResourceId`, `ResultStatus`, `PaginatedResponse`, `PaginatedRequest`, `MessageResponse`), `RequestValidationError` class, `validateRequest()` helper, and common Zod field definitions (`sortFieldParam`, `sortOrderParam`, `offsetParam`, `limitParam` etc.). +- `schemas.ts` — Shared types (`ResourceId`, `ResultStatus`, `PaginatedResponse`, `MessageResponse`), `RequestValidationError` class, `validateRequest()` helper, the API limits (`MAX_PAGE_SIZE`, `MAX_BULK_ITEMS`) and common Zod field definitions (`paginationParams`, `sortFieldParam`, `sortOrderParam`, `offsetParam`, `limitParam` etc.). List endpoints use the `/paginated` routes; the older unpaginated routes are deprecated. - One sub-module per resource (e.g., `projects.ts`, `runs.ts`, `tcases.ts`, `folders.ts`), each exporting a `createApi(fetcher)` factory function. Each module defines Zod schemas for its request types (PascalCase, e.g., `CreateRunRequestSchema`), derives TypeScript types via `z.infer`, and validates inputs with `validateRequest()` inside API functions ### Configuration (src/utils/) diff --git a/README.md b/README.md index 1c40b94..4e73159 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ qasphere api runs close --project-code PRJ --run-id "$RUN_ID" **2. Run progress report** — list open runs with their pass/fail/open counts: ```bash -qasphere api runs list --project-code PRJ --closed false \ - | jq '.[] | {title, passed: .statusCounts.passed, failed: .statusCounts.failed, open: .statusCounts.open, total: .statusCounts.all}' +qasphere api runs list --project-code PRJ --closed false --include statusCounts \ + | jq '.data[] | {title, passed: .statusCounts.passed, failed: .statusCounts.failed, open: .statusCounts.open, total: .statusCounts.all}' ``` **3. Pair the API with the result uploader** — pre-create a run with custom metadata in CI, then upload automation results into it: @@ -77,7 +77,7 @@ 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: -- _"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`, computes `passed / all` per run from `statusCounts`, renders the chart. +- _"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. - _"Generate a markdown summary of run 42 with the pass/fail breakdown and a list of failing test case titles."_ — agent calls `runs test-cases list --project-code PRJ --run-id 42` and formats the output. @@ -181,64 +181,118 @@ The `api` command provides direct access to the QA Sphere public API from the co qasphere api [options] ``` +Each line lists the options a command requires. Commands that send a request body also take it whole with `--body ''` or `--body-file `, and their field options override its keys. Run any command with `-h` to see all of its options. + ``` qasphere api +├── ai-rules +│ └── 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 ├── 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 -│ └── 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 ├── milestones -│ ├── list --project-code # List milestones -│ └── create --project-code --title # Create milestone +│ ├── list --project-code # List milestones +│ └── create --project-code --title # Create milestone ├── projects -│ ├── list # List all projects -│ ├── get --project-code # Get project by code -│ └── create --code --title # Create project +│ ├── list # List all projects +│ ├── get --project-code # Get project by code +│ ├── create --code --title # Create project +│ ├── update --project-code # Update project (owner/admin) +│ └── users +│ └── list --project-code # List users with access to project ├── requirements -│ └── list --project-code # List 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 ├── results -│ ├── create --project-code --run-id --tcase-id --status # Create result -│ └── batch-create --project-code --run-id --items # Batch create results +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ └── batch-create --project-code --run-id --items # Batch create results ├── 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 +│ ├── 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 │ ├── 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 │ └── logs -│ └── create --project-code --run-id --comment # Create run log +│ └── 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 +│ ├── 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 +│ ├── 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 +│ ├── 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 -│ ├── count --project-code # Count test cases -│ ├── create --project-code --body # Create test case -│ └── update --project-code --tcase-id --body # Update test case +│ ├── 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) +│ └── results +│ └── list --project-code --tcase-id # List a test case's result history ├── test-plans -│ └── create --project-code --body # Create test plan +│ └── create --project-code --title --runs # Create test plan +├── trash +│ ├── 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. + +### 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. + ## Commands: `junit-upload`, `playwright-json-upload`, `allure-upload` The `junit-upload`, `playwright-json-upload`, and `allure-upload` commands upload test results to QA Sphere. diff --git a/skills/qas-cli/SKILL.md b/skills/qas-cli/SKILL.md index f2621b8..d26edde 100644 --- a/skills/qas-cli/SKILL.md +++ b/skills/qas-cli/SKILL.md @@ -123,67 +123,117 @@ qasphere api [options] All commands output JSON to stdout, errors to stderr. Exit code 0 on success, 1 on failure. +Each line lists the options a command requires. Commands that send a request body also take it whole with `--body ''` or `--body-file `, and their field options override its keys (see JSON Body Commands below). Run any command with `-h` to see all of its options. + ``` qasphere api +├── ai-rules +│ └── 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 ├── 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 -│ └── 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 ├── milestones -│ ├── list --project-code # List milestones -│ └── create --project-code --title # Create milestone +│ ├── list --project-code # List milestones +│ └── create --project-code --title # Create milestone ├── projects -│ ├── list # List all projects -│ ├── get --project-code # Get project by code -│ └── create --code --title # Create project +│ ├── list # List all projects +│ ├── get --project-code # Get project by code +│ ├── create --code --title # Create project +│ ├── update --project-code # Update project (owner/admin) +│ └── users +│ └── list --project-code # List users with access to project ├── requirements -│ └── list --project-code # List 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 ├── results -│ ├── create --project-code --run-id --tcase-id --status # Create result -│ └── batch-create --project-code --run-id --items # Batch create results +│ ├── create --project-code --run-id --tcase-id --status # Create result +│ └── batch-create --project-code --run-id --items # Batch create results ├── 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 +│ ├── 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 │ ├── 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 │ └── 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. +│ └── 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 +│ ├── 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 +│ ├── 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 +│ ├── 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 -│ ├── count --project-code # Count test cases -│ ├── create --project-code --body # Create test case -│ └── update --project-code --tcase-id --body # Update test case +│ ├── 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) +│ └── results +│ └── list --project-code --tcase-id # List a test case's result history ├── test-plans -│ └── create --project-code --body # Create test plan +│ └── create --project-code --title --runs # Create test plan +├── trash +│ ├── 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. + ### Pagination -List commands support pagination via `--offset` and `--limit`. Some commands also support `--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. ### JSON Body Commands diff --git a/src/api/ai-rules.ts b/src/api/ai-rules.ts new file mode 100644 index 0000000..9ecfa76 --- /dev/null +++ b/src/api/ai-rules.ts @@ -0,0 +1,45 @@ +import { z } from 'zod' +import { ResourceId, validateRequest } from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export const aiRuleTypes = [ + 'system', + 'all', + 'tcase', + 'tcase-single', + 'tcase-bulk', + 'issue', + 'manual', +] as const + +export interface AIRule { + id: string + name: string + scope: 'project' | 'global' + type: (typeof aiRuleTypes)[number] + enabled: boolean + prompt: string + createdBy: number + createdAt: string + updatedAt: string + deletedAt: string | null +} + +export const ListAIRulesRequestSchema = z.object({ + types: z.array(z.enum(aiRuleTypes)).optional(), + enabled: z.boolean().optional(), +}) + +export type ListAIRulesRequest = z.infer + +export const createAIRuleApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListAIRulesRequest) => { + const validated = params ? validateRequest(params, ListAIRulesRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/ai-rule`, validated) + ).then((r) => jsonResponse<{ aiRules: AIRule[] }>(r)) + }, + } +} diff --git a/src/api/configurations.ts b/src/api/configurations.ts new file mode 100644 index 0000000..ff7920d --- /dev/null +++ b/src/api/configurations.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export interface Configuration { + id: string + title: string + runCount?: number + createdAt: string + updatedAt: string +} + +export const ListConfigurationsRequestSchema = z.object({ + ...paginationParams, + include: z.array(z.string()).optional(), +}) + +export type ListConfigurationsRequest = z.infer + +export const configurationTitleSchema = z + .string() + .min(1, 'title must not be empty') + .max(64, 'title must be at most 64 characters') + +export const ConfigurationRequestSchema = z.object({ title: configurationTitleSchema }) + +export type ConfigurationRequest = z.infer + +export const createConfigurationApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListConfigurationsRequest) => { + const validated = params ? validateRequest(params, ListConfigurationsRequestSchema) : {} + return fetcher( + appendSearchParams( + `/api/public/v0/project/${projectCode}/configuration/paginated`, + validated + ) + ).then((r) => jsonResponse>(r)) + }, + + get: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/configuration/${id}`).then((r) => + jsonResponse(r) + ), + + create: async (projectCode: ResourceId, req: ConfigurationRequest) => { + const validated = validateRequest(req, ConfigurationRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/configuration`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async (projectCode: ResourceId, id: ResourceId, req: ConfigurationRequest) => { + const validated = validateRequest(req, ConfigurationRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/configuration/${id}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + delete: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/configuration/${id}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), + } +} diff --git a/src/api/folders.ts b/src/api/folders.ts index 6c6e318..ead7e60 100644 --- a/src/api/folders.ts +++ b/src/api/folders.ts @@ -1,14 +1,12 @@ import { z } from 'zod' import { + MAX_BULK_ITEMS, PaginatedResponse, ResourceId, - limitParam, - offsetParam, - pageParam, - sortFieldParam, - sortOrderParam, + paginationParams, validateRequest, } from './schemas' +import type { TrashEntry } from './trash' import { appendSearchParams, jsonResponse, withJson } from './utils' export interface Folder { @@ -20,25 +18,42 @@ export interface Folder { projectId: string } -export const GetFoldersRequestSchema = z.object({ - offset: offsetParam, - page: pageParam, - limit: limitParam, - search: z.string().optional(), - sortField: sortFieldParam, - sortOrder: sortOrderParam, +export interface FolderTreeNode extends Folder { + tcaseCount?: number + children?: FolderTreeNode[] +} + +export interface FolderListResponse { + folders: T[] +} + +export interface FolderIdsResponse { + folders: Array<{ id: number }> +} + +export const ListFoldersRequestSchema = z.object({ + ...paginationParams, + search: z.string().max(255, 'search must be at most 255 characters').optional(), }) -export type GetFoldersRequest = z.infer +export type ListFoldersRequest = z.infer export const BulkCreateFoldersRequestSchema = z .array( z.object({ - path: z.array(z.string()).min(1, 'path must have at least one element'), + path: z + .array( + z + .string() + .min(1, 'folder names must not be empty') + .max(255, 'folder names must be at most 255 characters') + ) + .min(1, 'path must have at least one element'), comment: z.string().optional(), }) ) .min(1, 'Must contain at least one folder') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} folders`) export type BulkCreateFoldersRequest = z.infer @@ -46,23 +61,157 @@ export interface BulkCreateFoldersResponse { ids: number[][] } +export const GetFolderTreeRequestSchema = z.object({ + include: z.string().optional(), +}) + +export type GetFolderTreeRequest = z.infer + +const folderTitle = z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + +const folderIdList = z + .array(z.number().int().positive('folder IDs must be positive integers')) + .min(1, 'Must contain at least one folder') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} folders`) + +export const CreateFolderRequestSchema = z.object({ + title: folderTitle, + parentId: z.number().int().nonnegative('parentId must be a non-negative integer').optional(), + pos: z.number().int().nonnegative('pos must be a non-negative integer').optional(), + comment: z.string().optional(), +}) + +export type CreateFolderRequest = z.infer + +export const UpdateFolderRequestSchema = z + .object({ + title: folderTitle.optional(), + comment: z.string().optional(), + }) + .refine((req) => req.title !== undefined || req.comment !== undefined, { + message: 'title or comment is required', + }) + +export type UpdateFolderRequest = z.infer + +export const FolderPositionsSchema = z + .array( + z.object({ + id: z.number().int().positive('id must be a positive integer'), + pos: z.number().int('pos must be an integer'), + }) + ) + .min(1, 'Must contain at least one item') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} items`) + +export const MoveFoldersRequestSchema = z.object({ + newParentId: z.number().int().nonnegative('newParentId must be a non-negative integer'), + items: FolderPositionsSchema, +}) + +export type MoveFoldersRequest = z.infer + +export const CopyFoldersRequestSchema = z.object({ + folderIds: folderIdList, + targetFolderId: z + .number() + .int() + .nonnegative('targetFolderId must be a non-negative integer') + .optional(), + sourceProjectId: z.string().min(1, 'sourceProjectId must not be empty').optional(), +}) + +export type CopyFoldersRequest = z.infer + +export const DeleteFoldersRequestSchema = z + .object({ + folderIds: folderIdList, + tcaseIds: z.array(z.string().min(1, 'test case IDs must not be empty')).optional(), + }) + .refine((req) => req.folderIds.length + (req.tcaseIds?.length ?? 0) <= MAX_BULK_ITEMS, { + message: `folderIds and tcaseIds must contain at most ${MAX_BULK_ITEMS} IDs together`, + path: ['folderIds'], + }) + +export type DeleteFoldersRequest = z.infer + export const createFolderApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { - getPaginated: async (projectCode: ResourceId, request: GetFoldersRequest) => { - const validated = validateRequest(request, GetFoldersRequestSchema) + list: async (projectCode: ResourceId, params?: ListFoldersRequest) => { + const validated = params ? validateRequest(params, ListFoldersRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/folders`, validated) + appendSearchParams(`/api/public/v0/project/${projectCode}/folder/paginated`, validated) ).then((r) => jsonResponse>(r)) }, bulkCreate: async (projectCode: ResourceId, req: BulkCreateFoldersRequest) => { const validated = validateRequest(req, BulkCreateFoldersRequestSchema) - return fetcher(`/api/public/v0/project/${projectCode}/tcase/folder/bulk`, { + return fetcher(`/api/public/v0/project/${projectCode}/folder/upsert`, { method: 'POST', body: JSON.stringify({ folders: validated }), }).then((r) => jsonResponse(r)) }, + + get: (projectCode: ResourceId, folderId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/folder/${folderId}`).then((r) => + jsonResponse(r) + ), + + getTree: async (projectCode: ResourceId, request: GetFolderTreeRequest) => { + const validated = validateRequest(request, GetFolderTreeRequestSchema) + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/folder/tree`, validated) + ).then((r) => jsonResponse>(r)) + }, + + getPath: (projectCode: ResourceId, folderId: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/folder/${folderId}/path`).then((r) => + jsonResponse>(r) + ), + + create: async (projectCode: ResourceId, req: CreateFolderRequest) => { + const validated = validateRequest(req, CreateFolderRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/folder`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async (projectCode: ResourceId, folderId: ResourceId, req: UpdateFolderRequest) => { + const validated = validateRequest(req, UpdateFolderRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/folder/${folderId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + move: async (projectCode: ResourceId, req: MoveFoldersRequest) => { + const validated = validateRequest(req, MoveFoldersRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/folder/location`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + copy: async (projectCode: ResourceId, req: CopyFoldersRequest) => { + const validated = validateRequest(req, CopyFoldersRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/folder/copy`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + delete: async (projectCode: ResourceId, req: DeleteFoldersRequest) => { + const validated = validateRequest(req, DeleteFoldersRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/folder/delete`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, } } diff --git a/src/api/index.ts b/src/api/index.ts index eef96fd..ebf5877 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,6 @@ +import { createAIRuleApi } from './ai-rules' import { createAuditLogApi } from './audit-logs' +import { createConfigurationApi } from './configurations' import { createCustomFieldApi } from './custom-fields' import { createFileApi } from './file' import { createFolderApi } from './folders' @@ -13,6 +15,7 @@ import { createSharedStepApi } from './shared-steps' import { createTagApi } from './tags' import { createTCaseApi } from './tcases' import { createTestPlanApi } from './test-plans' +import { createTrashApi } from './trash' import { createUserApi } from './users' import { withFetchMiddlewares, @@ -26,7 +29,9 @@ import { CLI_VERSION } from '../utils/version' const getApi = (fetcher: typeof fetch) => { return { + aiRules: createAIRuleApi(fetcher), auditLogs: createAuditLogApi(fetcher), + configurations: createConfigurationApi(fetcher), customFields: createCustomFieldApi(fetcher), files: createFileApi(fetcher), folders: createFolderApi(fetcher), @@ -41,6 +46,7 @@ const getApi = (fetcher: typeof fetch) => { tags: createTagApi(fetcher), testCases: createTCaseApi(fetcher), testPlans: createTestPlanApi(fetcher), + trash: createTrashApi(fetcher), users: createUserApi(fetcher), } } diff --git a/src/api/milestones.ts b/src/api/milestones.ts index ba63457..46a1017 100644 --- a/src/api/milestones.ts +++ b/src/api/milestones.ts @@ -1,14 +1,17 @@ import { z } from 'zod' -import { ResourceId, validateRequest } from './schemas' +import { PaginatedResponse, ResourceId, paginationParams, validateRequest } from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' export interface Milestone { id: number title: string - archived: boolean + createdAt: string + updatedAt: string + archivedAt: string | null } export const ListMilestonesRequestSchema = z.object({ + ...paginationParams, archived: z.boolean().optional(), }) @@ -29,10 +32,8 @@ export const createMilestoneApi = (fetcher: typeof fetch) => { list: async (projectCode: ResourceId, params?: ListMilestonesRequest) => { const validated = params ? validateRequest(params, ListMilestonesRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/milestone`, validated) - ) - .then((r) => jsonResponse<{ milestones: Milestone[] }>(r)) - .then((r) => r.milestones) + appendSearchParams(`/api/public/v0/project/${projectCode}/milestone/paginated`, validated) + ).then((r) => jsonResponse>(r)) }, create: async (projectCode: ResourceId, req: CreateMilestoneRequest) => { diff --git a/src/api/projects.ts b/src/api/projects.ts index 155a0ab..c99b0e5 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -1,12 +1,33 @@ import { z } from 'zod' -import { ResourceId, validateRequest } from './schemas' -import { jsonResponse, withJson } from './utils' +import { + PaginatedResponse, + ResourceId, + linkSchema, + paginationParams, + validateRequest, +} from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' export interface ProjectLink { text: string url: string } +export const userRoles = ['owner', 'admin', 'user', 'test-runner', 'viewer'] as const + +export interface ProjectUser { + id: number + email: string + name: string + avatar: string | null + role: (typeof userRoles)[number] +} + +export interface ProjectTeamMember { + role: string + user: ProjectUser +} + export interface Project { id: string code: string @@ -18,14 +39,11 @@ export interface Project { createdAt: string updatedAt: string archivedAt: string | null + /** Only returned for a single project (get and update). */ + teamMembers?: ProjectTeamMember[] } -export const projectLinksSchema = z.array( - z.object({ - text: z.string().max(255, 'link text must be at most 255 characters'), - url: z.string().url().max(255, 'link url must be at most 255 characters'), - }) -) +export const projectLinksSchema = z.array(linkSchema) export const CreateProjectRequestSchema = z.object({ code: z @@ -44,6 +62,38 @@ export const CreateProjectRequestSchema = z.object({ export type CreateProjectRequest = z.infer +export const projectTeamMembersSchema = z.array( + z.object({ + userId: z.number().int().positive('userId must be a positive integer'), + role: z + .string() + .min(1, 'role must not be empty') + .max(255, 'role must be at most 255 characters'), + pos: z.number().int().nonnegative().optional(), + }) +) + +export const UpdateProjectRequestSchema = z.object({ + title: CreateProjectRequestSchema.shape.title.optional(), + archived: z.boolean().optional(), + links: projectLinksSchema.optional(), + overviewTitle: CreateProjectRequestSchema.shape.overviewTitle, + overviewDescription: CreateProjectRequestSchema.shape.overviewDescription, + teamMembers: projectTeamMembersSchema.optional(), +}) + +export type UpdateProjectRequest = z.infer + +export const ListProjectsRequestSchema = z.object(paginationParams) + +export type ListProjectsRequest = z.infer + +export const ListProjectUsersRequestSchema = z.object({ + roles: z.array(z.enum(userRoles)).optional(), +}) + +export type ListProjectUsersRequest = z.infer + export const createProjectApi = (fetcher: typeof fetch) => { const jsonFetcher = withJson(fetcher) return { @@ -52,10 +102,12 @@ export const createProjectApi = (fetcher: typeof fetch) => { return res.ok }, - list: () => - jsonFetcher(`/api/public/v0/project`) - .then((r) => jsonResponse<{ projects: Project[] | null }>(r)) - .then((r) => r.projects ?? []), + list: async (params?: ListProjectsRequest) => { + const validated = params ? validateRequest(params, ListProjectsRequestSchema) : {} + return jsonFetcher(appendSearchParams(`/api/public/v0/project/paginated`, validated)).then( + (r) => jsonResponse>(r) + ) + }, get: (codeOrId: ResourceId) => jsonFetcher(`/api/public/v0/project/${codeOrId}`).then((r) => jsonResponse(r)), @@ -67,5 +119,25 @@ export const createProjectApi = (fetcher: typeof fetch) => { body: JSON.stringify(validated), }).then((r) => jsonResponse<{ id: string }>(r)) }, + + update: async (codeOrId: ResourceId, req: UpdateProjectRequest) => { + const validated = validateRequest(req, UpdateProjectRequestSchema) + // The team card orders by pos, so an omitted pos follows the list order + const teamMembers = validated.teamMembers?.map((member, i) => ({ + ...member, + pos: member.pos ?? i, + })) + return jsonFetcher(`/api/public/v0/project/${codeOrId}`, { + method: 'PATCH', + body: JSON.stringify({ ...validated, teamMembers }), + }).then((r) => jsonResponse(r)) + }, + + listUsers: async (codeOrId: ResourceId, params?: ListProjectUsersRequest) => { + const validated = params ? validateRequest(params, ListProjectUsersRequestSchema) : {} + return jsonFetcher( + appendSearchParams(`/api/public/v0/project/${codeOrId}/users`, validated) + ).then((r) => jsonResponse<{ users: ProjectUser[] }>(r)) + }, } } diff --git a/src/api/requirements.ts b/src/api/requirements.ts index 1a8db91..37996f7 100644 --- a/src/api/requirements.ts +++ b/src/api/requirements.ts @@ -1,32 +1,98 @@ import { z } from 'zod' -import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + httpUrlSchema, + paginationParams, + validateRequest, +} from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' +export interface RequirementIntegrationLink { + type: string + integrationId: string + issueId: string + issueTitle: string + issueUrl: string + remoteLinkId: string +} + export interface Requirement { - id: number + id: string text: string url: string + integrationLink?: RequirementIntegrationLink | null tcaseCount?: number } export const ListRequirementsRequestSchema = z.object({ - sortField: sortFieldParam, - sortOrder: sortOrderParam, + ...paginationParams, include: z.string().optional(), }) export type ListRequirementsRequest = z.infer +const requirementText = z + .string() + .min(1, 'text must not be empty') + .max(255, 'text must be at most 255 characters') + +// "" is allowed on update, where it clears the URL +const requirementUrl = z.union([z.literal(''), httpUrlSchema]) + +const integrationIssueId = z.string().max(255, 'integrationIssueId must be at most 255 characters') + +export const CreateRequirementRequestSchema = z.object({ + text: requirementText, + url: requirementUrl.optional(), + integrationIssueId: integrationIssueId.optional(), +}) + +export type CreateRequirementRequest = z.infer + +export const UpdateRequirementRequestSchema = z.object({ + text: requirementText.optional(), + url: requirementUrl.optional(), + integrationIssueId: integrationIssueId.optional(), +}) + +export type UpdateRequirementRequest = z.infer + export const createRequirementApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { list: async (projectCode: ResourceId, params?: ListRequirementsRequest) => { const validated = params ? validateRequest(params, ListRequirementsRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/requirement`, validated) - ) - .then((r) => jsonResponse<{ requirements: Requirement[] }>(r)) - .then((r) => r.requirements) + appendSearchParams(`/api/public/v0/project/${projectCode}/requirement/paginated`, validated) + ).then((r) => jsonResponse>(r)) + }, + + get: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/requirement/${id}`).then((r) => + jsonResponse(r) + ), + + create: async (projectCode: ResourceId, req: CreateRequirementRequest) => { + const validated = validateRequest(req, CreateRequirementRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/requirement`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async (projectCode: ResourceId, id: ResourceId, req: UpdateRequirementRequest) => { + const validated = validateRequest(req, UpdateRequirementRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/requirement/${id}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) }, + + delete: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/requirement/${id}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), } } diff --git a/src/api/runs.ts b/src/api/runs.ts index d75ff14..81ce434 100644 --- a/src/api/runs.ts +++ b/src/api/runs.ts @@ -1,11 +1,10 @@ import { z } from 'zod' import { MessageResponse, + PaginatedResponse, ResourceId, - limitParam, + paginationParams, priorityEnum, - sortFieldParam, - sortOrderParam, validateRequest, } from './schemas' import type { Folder } from './folders' @@ -20,7 +19,7 @@ export interface RunTCase { title: string priority: string status: string - folder: Folder + folder?: Folder } export const QueryPlanSchema = z @@ -89,9 +88,10 @@ export interface CreateRunResponse { } export const ListRunsRequestSchema = z.object({ + ...paginationParams, closed: z.boolean().optional(), milestoneIds: z.array(z.number().int().positive()).optional(), - limit: limitParam, + include: z.array(z.string()).optional(), }) export type ListRunsRequest = z.infer @@ -101,7 +101,8 @@ export interface Run { title: string description?: string type: string - closed: boolean + closedAt: string | null + statusCounts?: Record } export const CreateRunLogRequestSchema = z.object({ @@ -128,12 +129,11 @@ export interface CloneRunResponse { } export const ListRunTCasesRequestSchema = z.object({ + ...paginationParams, search: z.string().optional(), tags: z.array(z.number().int().positive()).optional(), priorities: z.array(priorityEnum).optional(), - include: z.string().optional(), - sortField: sortFieldParam, - sortOrder: sortOrderParam, + include: z.array(z.string()).optional(), }) export type ListRunTCasesRequest = z.infer @@ -151,12 +151,8 @@ export const RunSchema = z export const createRunApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) - return { - getTCases: (projectCode: ResourceId, runId: ResourceId) => - fetcher(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`) - .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) - .then((r) => r.tcases), + return { create: async (projectCode: ResourceId, req: CreateRunRequest) => { const validated = validateRequest(req, CreateRunRequestSchema) return fetcher(`/api/public/v0/project/${projectCode}/run`, { @@ -167,9 +163,9 @@ export const createRunApi = (fetcher: typeof fetch) => { list: async (projectCode: ResourceId, params?: ListRunsRequest) => { const validated = params ? validateRequest(params, ListRunsRequestSchema) : {} - return fetcher(appendSearchParams(`/api/public/v0/project/${projectCode}/run`, validated)) - .then((r) => jsonResponse<{ runs: Run[] }>(r)) - .then((r) => r.runs) + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/run/paginated`, validated) + ).then((r) => jsonResponse>(r)) }, clone: async (projectCode: ResourceId, req: CloneRunRequest) => { @@ -200,10 +196,11 @@ export const createRunApi = (fetcher: typeof fetch) => { ) => { const validated = params ? validateRequest(params, ListRunTCasesRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/run/${runId}/tcase`, validated) - ) - .then((r) => jsonResponse<{ tcases: RunTCase[] }>(r)) - .then((r) => r.tcases) + appendSearchParams( + `/api/public/v0/project/${projectCode}/run/${runId}/tcase/paginated`, + validated + ) + ).then((r) => jsonResponse>(r)) }, getTCase: (projectCode: ResourceId, runId: ResourceId, tcaseId: ResourceId) => diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 5c1cfea..245f785 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -16,18 +16,10 @@ export type ResultStatus = export interface PaginatedResponse { data: T[] total: number - /** Offset that was applied to the query. Present when pagination was applied. */ - offset?: number - /** Limit that was applied to the query. Present when pagination was applied. */ - limit?: number - /** @deprecated Echo of the legacy `page` request param. Present only when the request used `page`. */ - page?: number + offset: number + limit: number } -export interface PaginatedRequest { - offset?: number - limit?: number -} export interface MessageResponse { message: string } @@ -57,10 +49,31 @@ export const sortFieldParam = z.string().optional() export const sortOrderParam = z.enum(['asc', 'desc']).optional() export type SortOrder = z.infer export const offsetParam = z.number().int().nonnegative().optional() -/** @deprecated Use `offsetParam`. The API ignores `page` when `offset` is set. */ -export const pageParam = z.number().int().positive().optional() +// The paginated endpoints return at most 1,000 rows a page +export const MAX_PAGE_SIZE = 1000 +// Bulk endpoints take at most 1,000 items a request +export const MAX_BULK_ITEMS = 1000 // Limit 0 is valid and returns only the total count (no rows) -export const limitParam = z.number().int().nonnegative().optional() +export const limitParam = z + .number() + .int() + .nonnegative() + .max(MAX_PAGE_SIZE, `must be at most ${MAX_PAGE_SIZE}`) + .optional() +export const paginationParams = { + offset: offsetParam, + limit: limitParam, + sortField: sortFieldParam, + sortOrder: sortOrderParam, +} +// The API takes http and https URLs only. +export const httpUrlSchema = z + .url({ protocol: /^https?$/, error: 'must be an http or https URL' }) + .max(255, 'must be at most 255 characters') +export const linkSchema = z.object({ + text: z.string().min(1, 'text must not be empty').max(255, 'text must be at most 255 characters'), + url: httpUrlSchema, +}) export const priorityEnum = z.enum(['low', 'medium', 'high']) export const tcaseTypeEnum = z.enum(['standalone', 'template', 'filled']) export const resourceIdSchema = z diff --git a/src/api/shared-preconditions.ts b/src/api/shared-preconditions.ts index ab78720..7e1d53c 100644 --- a/src/api/shared-preconditions.ts +++ b/src/api/shared-preconditions.ts @@ -1,34 +1,95 @@ import { z } from 'zod' -import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' export interface SharedPrecondition { id: number title: string + projectId?: string + version?: number + type?: string + text?: string + isLatest?: boolean + createdAt?: string + updatedAt?: string tcaseCount?: number } export const ListSharedPreconditionsRequestSchema = z.object({ - sortField: sortFieldParam, - sortOrder: sortOrderParam, + ...paginationParams, include: z.string().optional(), }) export type ListSharedPreconditionsRequest = z.infer +const preconditionTitleSchema = z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + +const preconditionTextSchema = z.string().min(1, 'text must not be empty') + +export const CreateSharedPreconditionRequestSchema = z.object({ + title: preconditionTitleSchema, + text: preconditionTextSchema, +}) + +export type CreateSharedPreconditionRequest = z.infer + +export const UpdateSharedPreconditionRequestSchema = z.object({ + title: preconditionTitleSchema.optional(), + text: preconditionTextSchema.optional(), +}) + +export type UpdateSharedPreconditionRequest = z.infer + export const createSharedPreconditionApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { list: async (projectCode: ResourceId, params?: ListSharedPreconditionsRequest) => { const validated = params ? validateRequest(params, ListSharedPreconditionsRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/shared-precondition`, validated) - ).then((r) => jsonResponse(r)) + appendSearchParams( + `/api/public/v0/project/${projectCode}/shared-precondition/paginated`, + validated + ) + ).then((r) => jsonResponse>(r)) }, get: (projectCode: ResourceId, id: ResourceId) => fetcher(`/api/public/v0/project/${projectCode}/shared-precondition/${id}`).then((r) => jsonResponse(r) ), + + create: async (projectCode: ResourceId, req: CreateSharedPreconditionRequest) => { + const validated = validateRequest(req, CreateSharedPreconditionRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/shared-precondition`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async ( + projectCode: ResourceId, + id: ResourceId, + req: UpdateSharedPreconditionRequest + ) => { + const validated = validateRequest(req, UpdateSharedPreconditionRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/shared-precondition/${id}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + delete: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/shared-precondition/${id}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), } } diff --git a/src/api/shared-steps.ts b/src/api/shared-steps.ts index 05ee6a3..53f7705 100644 --- a/src/api/shared-steps.ts +++ b/src/api/shared-steps.ts @@ -1,36 +1,114 @@ import { z } from 'zod' -import { ResourceId, sortFieldParam, sortOrderParam, validateRequest } from './schemas' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' import { appendSearchParams, jsonResponse, withJson } from './utils' +export interface SharedSubStep { + id: number + version: number + type: string + description?: string + expected?: string + data?: unknown[] + isLatest: boolean +} + export interface SharedStep { id: number title: string + version?: number + type?: string + isLatest?: boolean + subSteps?: SharedSubStep[] tcaseCount?: number } export const ListSharedStepsRequestSchema = z.object({ - sortField: sortFieldParam, - sortOrder: sortOrderParam, + ...paginationParams, include: z.string().optional(), }) export type ListSharedStepsRequest = z.infer +const sharedStepTitleSchema = z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + +const subStepFields = { + description: z.string().optional(), + expected: z.string().optional(), + data: z.array(z.unknown()).max(20, 'data must have at most 20 items').optional(), +} + +const hasContent = (subSteps: { description?: string; expected?: string; data?: unknown[] }[]) => + subSteps.some((s) => !!s.description?.trim() || !!s.expected?.trim() || (s.data?.length ?? 0) > 0) + +const noContentMessage = 'at least one sub-step must have a description, expected result or data' + +export const CreateSubStepsSchema = z + .array(z.object(subStepFields)) + .min(1, 'subSteps must have at least one sub-step') + .refine(hasContent, noContentMessage) + +// The API drops sub-steps without content, those kept by id included. +export const UpdateSubStepsSchema = z + .array(z.object({ id: z.number().int().positive().optional(), ...subStepFields })) + .refine(hasContent, noContentMessage) + +export const CreateSharedStepRequestSchema = z.object({ + title: sharedStepTitleSchema, + subSteps: CreateSubStepsSchema, +}) + +export type CreateSharedStepRequest = z.infer + +export const UpdateSharedStepRequestSchema = z.object({ + title: sharedStepTitleSchema.optional(), + subSteps: UpdateSubStepsSchema.optional(), +}) + +export type UpdateSharedStepRequest = z.infer + export const createSharedStepApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { list: async (projectCode: ResourceId, params?: ListSharedStepsRequest) => { const validated = params ? validateRequest(params, ListSharedStepsRequestSchema) : {} return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/shared-step`, validated) - ) - .then((r) => jsonResponse<{ sharedSteps: SharedStep[] }>(r)) - .then((r) => r.sharedSteps) + appendSearchParams(`/api/public/v0/project/${projectCode}/shared-step/paginated`, validated) + ).then((r) => jsonResponse>(r)) }, get: (projectCode: ResourceId, id: ResourceId) => fetcher(`/api/public/v0/project/${projectCode}/shared-step/${id}`).then((r) => jsonResponse(r) ), + + create: async (projectCode: ResourceId, req: CreateSharedStepRequest) => { + const validated = validateRequest(req, CreateSharedStepRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/shared-step`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async (projectCode: ResourceId, id: ResourceId, req: UpdateSharedStepRequest) => { + const validated = validateRequest(req, UpdateSharedStepRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/shared-step/${id}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + delete: (projectCode: ResourceId, id: ResourceId) => + fetcher(`/api/public/v0/project/${projectCode}/shared-step/${id}`, { + method: 'DELETE', + }).then((r) => jsonResponse(r)), } } diff --git a/src/api/tags.ts b/src/api/tags.ts index 8b2408a..8ce932e 100644 --- a/src/api/tags.ts +++ b/src/api/tags.ts @@ -1,17 +1,107 @@ -import { ResourceId } from './schemas' -import { jsonResponse, withJson } from './utils' +import { z } from 'zod' +import { + MAX_BULK_ITEMS, + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' export interface Tag { id: number title: string + tcaseCount?: number } +export const ListTagsRequestSchema = z.object({ + ...paginationParams, + include: z.string().optional(), +}) + +export type ListTagsRequest = z.infer + +const tagTitle = z + .string() + .min(1, 'title must not be empty') + .max(255, 'title must be at most 255 characters') + +export const CreateTagRequestSchema = z.object({ + title: tagTitle, +}) + +export type CreateTagRequest = z.infer + +export const UpdateTagRequestSchema = z.object({ + title: tagTitle, +}) + +export type UpdateTagRequest = z.infer + +export const RenameTagsRequestSchema = z.object({ + tags: z + .array( + z.object({ + id: z.number().int().positive('id must be a positive integer'), + title: tagTitle, + }) + ) + .min(1, 'Must contain at least one tag') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} tags`), +}) + +export type RenameTagsRequest = z.infer + +export const DeleteTagsRequestSchema = z.object({ + tagIds: z + .array(z.number().int().positive('tag IDs must be positive integers')) + .min(1, 'Must contain at least one tag ID') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} tag IDs`), +}) + +export type DeleteTagsRequest = z.infer + export const createTagApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { - list: (projectCode: ResourceId) => - fetcher(`/api/public/v0/project/${projectCode}/tag`) - .then((r) => jsonResponse<{ tags: Tag[] }>(r)) - .then((r) => r.tags), + list: async (projectCode: ResourceId, params?: ListTagsRequest) => { + const validated = params ? validateRequest(params, ListTagsRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/tag/paginated`, validated) + ).then((r) => jsonResponse>(r)) + }, + + create: async (projectCode: ResourceId, req: CreateTagRequest) => { + const validated = validateRequest(req, CreateTagRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tag`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + update: async (projectCode: ResourceId, tagId: ResourceId, req: UpdateTagRequest) => { + const validated = validateRequest(req, UpdateTagRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tag/${tagId}`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + rename: async (projectCode: ResourceId, req: RenameTagsRequest) => { + const validated = validateRequest(req, RenameTagsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tag`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse<{ tags: Tag[] }>(r)) + }, + + delete: async (projectCode: ResourceId, req: DeleteTagsRequest) => { + const validated = validateRequest(req, DeleteTagsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tag/delete`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, } } diff --git a/src/api/tcases.ts b/src/api/tcases.ts index 8f55183..d976e98 100644 --- a/src/api/tcases.ts +++ b/src/api/tcases.ts @@ -1,13 +1,12 @@ import { z } from 'zod' import { + MAX_BULK_ITEMS, PaginatedResponse, ResourceId, - limitParam, - offsetParam, - pageParam, + httpUrlSchema, + linkSchema, + paginationParams, priorityEnum, - sortFieldParam, - sortOrderParam, tcaseTypeEnum, validateRequest, } from './schemas' @@ -15,6 +14,7 @@ import { appendSearchParams, jsonResponse, withJson } from './utils' import type { Folder } from './folders' import type { Project, ProjectLink } from './projects' import type { Tag } from './tags' +import type { TrashEntry } from './trash' export interface TCaseFile { id: string @@ -106,8 +106,28 @@ export interface TCase { } export const CreateTCasesBatchRequestSchema = z.object({ - folderPath: z.array(z.string()), - tcases: z.array(z.object({ title: z.string(), tags: z.array(z.string()) })), + folderPath: z + .array(z.string().min(1, 'folder names must not be empty')) + .min(1, 'folderPath must have at least one element'), + tcases: z + .array( + z.object({ + title: z + .string() + .min(1, 'title must not be empty') + .max(511, 'title must be at most 511 characters'), + tags: z + .array( + z + .string() + .min(1, 'tag must not be empty') + .max(255, 'tag must be at most 255 characters') + ) + .optional(), + }) + ) + .min(1, 'Must contain at least one test case') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} test cases`), }) export type CreateTCasesRequest = z.infer @@ -116,36 +136,21 @@ export interface CreateTCasesResponse { tcases: { id: string; seq: number }[] } -export const GetTCasesRequestSchema = z.object({ - offset: offsetParam, - page: pageParam, - limit: limitParam, - folders: z.array(z.number().int().positive()).optional(), -}) - -export type GetTCasesRequest = z.infer - export const GetTCasesBySeqRequestSchema = z.object({ - seqIds: z.array(z.string()), - offset: offsetParam, - page: pageParam, - limit: limitParam, + ...paginationParams, + seqIds: z.array(z.string()).min(1, 'Must contain at least one test case marker'), }) export type GetTCasesBySeqRequest = z.infer export const ListTCasesRequestSchema = z.object({ - offset: offsetParam, - page: pageParam, - limit: limitParam, + ...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(), types: z.array(tcaseTypeEnum).optional(), draft: z.boolean().optional(), - sortField: sortFieldParam, - sortOrder: sortOrderParam, include: z.array(z.string()).optional(), }) @@ -170,6 +175,8 @@ const stepSchema = z.object({ description: z.string().optional(), expected: z.string().optional(), sharedStepId: z.number().int().positive().optional(), + // The API checks each item's shape; a step sent without data loses its test data. + data: z.array(z.unknown()).max(20, 'data must have at most 20 items').optional(), }) export const StepsArraySchema = z.array(stepSchema) @@ -190,6 +197,21 @@ export const parameterValueWithIdSchema = z.object({ values: z.record(z.string(), z.string()), }) +const requirementRefSchema = z.object({ + text: z.string().min(1, 'text must not be empty').max(255, 'text must be at most 255 characters'), + url: z.union([z.literal(''), httpUrlSchema]).optional(), +}) + +// As `files upload` returns them, with the file's name, MIME type and size added. +const fileSchema = z.object({ + id: z.string().min(1, 'id must not be empty'), + fileName: z.string().min(1, 'fileName must not be empty'), + mimeType: z.string().min(1, 'mimeType must not be empty'), + size: z.number().int().nonnegative('size must be a non-negative integer'), + url: z.string().optional(), + stableUrl: z.string().optional(), +}) + export const CreateTCaseRequestSchema = z .object({ title: z @@ -205,8 +227,9 @@ export const CreateTCaseRequestSchema = z steps: z.array(stepSchema).optional(), precondition: preconditionSchema.optional(), pos: z.number().int().nonnegative().optional(), - requirements: z.array(z.object({ text: z.string(), url: z.string().max(255) })).optional(), - links: z.array(z.object({ text: z.string(), url: z.string() })).optional(), + requirements: z.array(requirementRefSchema).optional(), + links: z.array(linkSchema).optional(), + files: z.array(fileSchema).optional(), customFields: customFieldsSchema.optional(), parameterValues: z.array(parameterValueSchema).optional(), filledTCaseTitleSuffixParams: z.array(z.string()).optional(), @@ -242,8 +265,9 @@ export const UpdateTCaseRequestSchema = z.object({ isDraft: z.boolean().optional(), steps: z.array(stepSchema).optional(), precondition: preconditionSchema.optional(), - requirements: z.array(z.object({ text: z.string(), url: z.string().max(255) })).optional(), - links: z.array(z.object({ text: z.string(), url: z.string() })).optional(), + requirements: z.array(requirementRefSchema).optional(), + links: z.array(linkSchema).optional(), + files: z.array(fileSchema).optional(), customFields: customFieldsSchema.optional(), parameterValues: z.array(parameterValueWithIdSchema).optional(), filledTCaseTitleSuffixParams: z.array(z.string()).optional(), @@ -251,19 +275,125 @@ export const UpdateTCaseRequestSchema = z.object({ export type UpdateTCaseRequest = z.infer +export interface TCaseRef { + id: string + seq: number +} + +export interface TCasesResponse { + tcases: TCaseRef[] +} + +export interface TCaseResult { + id: number + status: string + comment: string + tcaseId: string + tcaseVersion: number + authorId: number + timeTaken: number | null + isAutomated: boolean + internal: boolean + createdAt: string + author?: { id: number; name: string; email: string; avatar: string | null; role: string } + run?: { id: number; title: string } + links?: { id: number; url: string; text: string }[] +} + +// Test case references accept an ID, sequence number or legacy ID. +const tcaseRefsSchema = z + .array(z.string().min(1, 'test case reference must not be empty')) + .min(1, 'Must contain at least one test case') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} test cases`) + +export const DeleteTCasesRequestSchema = z.object({ + tcaseIds: tcaseRefsSchema, +}) + +export type DeleteTCasesRequest = z.infer + +export const CopyTCasesRequestSchema = z.object({ + tcaseIds: tcaseRefsSchema, + targetFolderId: z.number().int().positive('targetFolderId must be a positive integer'), + sourceProjectId: z.string().min(1, 'sourceProjectId must not be empty').optional(), +}) + +export type CopyTCasesRequest = z.infer + +export const MoveTCaseItemsSchema = z + .array( + z.object({ + id: z.string().min(1, 'id must not be empty'), + pos: z.number().int('pos must be an integer'), + }) + ) + .min(1, 'Must contain at least one item') + .max(MAX_BULK_ITEMS, `Must contain at most ${MAX_BULK_ITEMS} items`) + +export const MoveTCasesRequestSchema = z.object({ + newParentId: z.number().int().positive('newParentId must be a positive integer'), + items: MoveTCaseItemsSchema, +}) + +export type MoveTCasesRequest = z.infer + +export const UpdateTCasesPriorityRequestSchema = z.object({ + tcaseIds: tcaseRefsSchema, + priority: priorityEnum, +}) + +export type UpdateTCasesPriorityRequest = z.infer + +const tagTitleSchema = z + .string() + .min(1, 'tag must not be empty') + .max(255, 'tag must be at most 255 characters') + +// The API takes a request that changes nothing; the CLI refuses one, with --body too. +const hasChange = (req: { add?: unknown[]; remove?: unknown[] }) => + (req.add?.length ?? 0) + (req.remove?.length ?? 0) > 0 + +export const UpdateTCasesTagsRequestSchema = z + .object({ + tcaseIds: tcaseRefsSchema, + add: z.array(tagTitleSchema).optional(), + remove: z.array(tagTitleSchema).optional(), + }) + .refine(hasChange, { message: 'add or remove must list at least one tag', path: ['add'] }) + +export type UpdateTCasesTagsRequest = z.infer + +export const RequirementRefsSchema = z.array(requirementRefSchema) + +export const UpdateTCasesRequirementsRequestSchema = z + .object({ + tcaseIds: tcaseRefsSchema, + add: RequirementRefsSchema.optional(), + remove: RequirementRefsSchema.optional(), + }) + .refine(hasChange, { + message: 'add or remove must list at least one requirement', + path: ['add'], + }) + +export type UpdateTCasesRequirementsRequest = z.infer + +export const ListTCaseResultsRequestSchema = z.object({ + ...paginationParams, + include: z.array(z.string()).optional(), + createdAfter: z.string().optional(), + excludeStatus: z.array(z.string()).optional(), + internal: z.boolean().optional(), +}) + +export type ListTCaseResultsRequest = z.infer + export const createTCaseApi = (fetcher: typeof fetch) => { fetcher = withJson(fetcher) return { - getPaginated: async (projectCode: ResourceId, request: GetTCasesRequest) => { - const validated = validateRequest(request, GetTCasesRequestSchema) - return fetcher( - appendSearchParams(`/api/public/v0/project/${projectCode}/tcase`, validated) - ).then((r) => jsonResponse>(r)) - }, - getBySeq: async (projectCode: ResourceId, request: GetTCasesBySeqRequest) => { const validated = validateRequest(request, GetTCasesBySeqRequestSchema) - return fetcher(`/api/public/v0/project/${projectCode}/tcase/seq`, { + return fetcher(`/api/public/v0/project/${projectCode}/tcase/seq/paginated`, { method: 'POST', body: JSON.stringify(validated), }).then((r) => jsonResponse>(r)) @@ -271,7 +401,7 @@ export const createTCaseApi = (fetcher: typeof fetch) => { createBatch: async (projectCode: ResourceId, request: CreateTCasesRequest) => { const validated = validateRequest(request, CreateTCasesBatchRequestSchema) - return fetcher(`/api/public/v0/project/${projectCode}/tcase/bulk`, { + return fetcher(`/api/public/v0/project/${projectCode}/tcase/batch`, { method: 'POST', body: JSON.stringify(validated), }).then((r) => jsonResponse(r)) @@ -280,7 +410,7 @@ 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`, validated) + appendSearchParams(`/api/public/v0/project/${projectCode}/tcase/paginated`, validated) ).then((r) => jsonResponse>(r)) }, @@ -311,5 +441,77 @@ export const createTCaseApi = (fetcher: typeof fetch) => { body: JSON.stringify(validated), }).then((r) => jsonResponse<{ message: string }>(r)) }, + + delete: async (projectCode: ResourceId, req: DeleteTCasesRequest) => { + const validated = validateRequest(req, DeleteTCasesRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/delete`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + copy: async (projectCode: ResourceId, req: CopyTCasesRequest) => { + const validated = validateRequest(req, CopyTCasesRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/copy`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + move: async (projectCode: ResourceId, req: MoveTCasesRequest) => { + const validated = validateRequest(req, MoveTCasesRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/location`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + updatePriority: async (projectCode: ResourceId, req: UpdateTCasesPriorityRequest) => { + const validated = validateRequest(req, UpdateTCasesPriorityRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/priority`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + updateTags: async (projectCode: ResourceId, req: UpdateTCasesTagsRequest) => { + const validated = validateRequest(req, UpdateTCasesTagsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/tags`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + updateRequirements: async (projectCode: ResourceId, req: UpdateTCasesRequirementsRequest) => { + const validated = validateRequest(req, UpdateTCasesRequirementsRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/tcase/requirements`, { + method: 'PATCH', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + + getVersion: (projectCode: ResourceId, id: ResourceId, version: number) => + fetcher(`/api/public/v0/project/${projectCode}/tcase/${id}/version/${version}`).then((r) => + jsonResponse(r) + ), + + rollback: (projectCode: ResourceId, id: ResourceId, version: number) => + fetcher(`/api/public/v0/project/${projectCode}/tcase/${id}/version/${version}/rollback`, { + method: 'POST', + }).then((r) => jsonResponse(r)), + + listResults: async ( + projectCode: ResourceId, + id: ResourceId, + params?: ListTCaseResultsRequest + ) => { + const validated = params ? validateRequest(params, ListTCaseResultsRequestSchema) : {} + return fetcher( + appendSearchParams( + `/api/public/v0/project/${projectCode}/tcase/${id}/result/paginated`, + validated + ) + ).then((r) => jsonResponse>(r)) + }, } } diff --git a/src/api/trash.ts b/src/api/trash.ts new file mode 100644 index 0000000..4f3bcba --- /dev/null +++ b/src/api/trash.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { + MessageResponse, + PaginatedResponse, + ResourceId, + paginationParams, + validateRequest, +} from './schemas' +import { appendSearchParams, jsonResponse, withJson } from './utils' + +export const trashResourceTypes = ['test_case', 'test_run', 'test_plan', 'milestone'] as const + +export interface TrashEntry { + id: string + name: string + resourceType: (typeof trashResourceTypes)[number] + authorId: number + author: { id: number; name: string } + items: Array<{ + seq: number + folderIds: number[] | null + tcaseIds: string[] | null + runIds: number[] | null + planIds: number[] | null + milestoneIds: number[] | null + }> + run?: { closedAt: string | null; statusCounts: Record } + plan?: { closedAt: string | null } + createdAt: string +} + +export const ListTrashRequestSchema = z.object({ + ...paginationParams, + resourceTypes: z.array(z.enum(trashResourceTypes)).optional(), +}) + +export type ListTrashRequest = z.infer + +export const RestoreTrashRequestSchema = z.object({ + deleteLogIds: z + .array(z.string().length(23, 'trash entry IDs are 23 characters long')) + .min(1, 'Must contain at least one trash entry ID') + .max(100, 'Must contain at most 100 trash entry IDs'), +}) + +export type RestoreTrashRequest = z.infer + +export const createTrashApi = (fetcher: typeof fetch) => { + fetcher = withJson(fetcher) + return { + list: async (projectCode: ResourceId, params?: ListTrashRequest) => { + const validated = params ? validateRequest(params, ListTrashRequestSchema) : {} + return fetcher( + appendSearchParams(`/api/public/v0/project/${projectCode}/trash/paginated`, validated) + ).then((r) => jsonResponse>(r)) + }, + + restore: async (projectCode: ResourceId, req: RestoreTrashRequest) => { + const validated = validateRequest(req, RestoreTrashRequestSchema) + return fetcher(`/api/public/v0/project/${projectCode}/trash/restore`, { + method: 'POST', + body: JSON.stringify(validated), + }).then((r) => jsonResponse(r)) + }, + } +} diff --git a/src/api/utils.ts b/src/api/utils.ts index cb04f94..d81c280 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,3 +1,5 @@ +import { MAX_PAGE_SIZE, type PaginatedResponse } from './schemas' + type FetchMiddleware = (fetcher: typeof fetch) => typeof fetch // TODO: Each middleware adds a frame to the stack trace. V8 defaults to 10 frames (Error.stackTraceLimit). @@ -191,3 +193,17 @@ export const appendSearchParams = (pathname: string, obj: T): } return pathname } + +/** Reads every page of a paginated list, at the largest page size the API allows. */ +export const fetchAllPages = async ( + fetchPage: (page: { offset: number; limit: number }) => Promise> +): Promise<{ total: number; data: T[] }> => { + const data: T[] = [] + for (let offset = 0; ; offset += MAX_PAGE_SIZE) { + const page = await fetchPage({ offset, limit: MAX_PAGE_SIZE }) + data.push(...page.data) + if (page.data.length < MAX_PAGE_SIZE || data.length >= page.total) { + return { total: page.total, data } + } + } +} diff --git a/src/commands/api/builder.ts b/src/commands/api/builder.ts index a38de36..7d2f79f 100644 --- a/src/commands/api/builder.ts +++ b/src/commands/api/builder.ts @@ -50,7 +50,10 @@ function buildOptions(specs: (ApiQueryOptionSpec | ApiFieldSpec)[]): Record JSON.stringify(c)).join(', ')}]` + : spec.describe, choices: spec.choices, ...spec.yargsOption, } diff --git a/src/commands/api/manifests/ai-rules.ts b/src/commands/api/manifests/ai-rules.ts new file mode 100644 index 0000000..a66f9a3 --- /dev/null +++ b/src/commands/api/manifests/ai-rules.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { aiRuleTypes } from '../../../api/ai-rules' +import { printJson, apiDocsEpilog } from '../utils' +import { commonHelp, projectCodeParam, splitList } from './utils' +import type { ApiEndpointSpec } from '../types' + +const help = { + ...commonHelp, + types: `Comma-separated rule types to list: ${aiRuleTypes.join(', ')}. The rules applied when writing test cases are system, all and tcase.`, + enabled: 'List only enabled (true) or disabled (false) rules.', + list: { + describe: + 'List the AI rules that apply in a project: its own and the global rules enabled for it.', + epilog: apiDocsEpilog('ai_rules', 'list-ai-rules'), + examples: [ + { usage: '$0 api ai-rules list --project-code PRJ', description: 'List AI rules' }, + { + usage: '$0 api ai-rules list --project-code PRJ --types system,all,tcase --enabled', + description: 'List the rules applied when writing test cases', + }, + ], + }, +} as const + +const typesSchema = z.string().refine( + (value) => + value + .split(',') + .map((item) => item.trim()) + .every((item) => (aiRuleTypes as readonly string[]).includes(item)), + `must be comma-separated values of ${aiRuleTypes.join(', ')}` +) + +const list: ApiEndpointSpec = { + id: 'ai-rules.list', + commandPath: ['ai-rules', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { name: 'types', type: 'string', describe: help.types, schema: typesSchema }, + { name: 'enabled', type: 'boolean', describe: help.enabled }, + ], + transformQuery: (query) => ({ ...query, types: splitList(query.types) }), + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.aiRules.list( + pathParams['project-code'], + query as Parameters[1] + ) + ) + }, +} + +export const aiRuleSpecs: ApiEndpointSpec[] = [list] diff --git a/src/commands/api/manifests/configurations.ts b/src/commands/api/manifests/configurations.ts new file mode 100644 index 0000000..27dfe50 --- /dev/null +++ b/src/commands/api/manifests/configurations.ts @@ -0,0 +1,185 @@ +import { + configurationTitleSchema, + type ListConfigurationsRequest, +} from '../../../api/configurations' +import { resourceIdSchema } from '../../../api/schemas' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { commonHelp, paginationOptions, projectCodeParam, splitList } from './utils' +import type { ApiEndpointSpec } from '../types' + +const help = { + ...commonHelp, + id: 'Configuration ID.', + title: 'Configuration name (1-64 characters), unique in the project, e.g. "Chrome".', + include: + 'Comma-separated additional fields to include. runCount adds the number of runs using each.', + list: { + describe: 'List the configurations of a project, the environments runs target.', + epilog: apiDocsEpilog('configurations', 'list-configurations'), + examples: [ + { + usage: '$0 api configurations list --project-code PRJ', + description: 'List configurations', + }, + ], + }, + get: { + describe: 'Get a configuration, with the number of runs using it.', + epilog: apiDocsEpilog('configurations', 'get-configuration'), + examples: [ + { + usage: + '$0 api configurations get --project-code PRJ --configuration-id 1CJo8oDjj_vkGo2rQAdzz4F', + description: 'Get a configuration', + }, + ], + }, + create: { + describe: 'Create a configuration.', + epilog: apiDocsEpilog('configurations', 'create-configuration'), + examples: [ + { + usage: '$0 api configurations create --project-code PRJ --title Chrome', + description: 'Create a configuration', + }, + ], + }, + update: { + describe: 'Rename a configuration. Runs using it show the new name.', + epilog: apiDocsEpilog('configurations', 'update-configuration'), + examples: [ + { + usage: + '$0 api configurations update --project-code PRJ --configuration-id 1CJo8oDjj_vkGo2rQAdzz4F --title Firefox', + description: 'Rename a configuration', + }, + ], + }, + delete: { + describe: + 'Delete a configuration for good. Runs that used it keep their results but no longer have a configuration.', + epilog: apiDocsEpilog('configurations', 'delete-configuration'), + examples: [ + { + usage: + '$0 api configurations delete --project-code PRJ --configuration-id 1CJo8oDjj_vkGo2rQAdzz4F', + description: 'Delete a configuration', + }, + ], + }, +} as const + +const idParam = { + name: 'configuration-id', + type: 'string', + describe: help.id, + schema: resourceIdSchema, +} as const + +const titleField = { + name: 'title', + type: 'string', + describe: help.title, + schema: configurationTitleSchema, +} as const + +const titleCheck = (argv: Record) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required' + +const list: ApiEndpointSpec = { + id: 'configurations.list', + commandPath: ['configurations', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { name: 'include', type: 'string', describe: help.include, helpChoices: ['runCount'] }, + ...paginationOptions(['title', 'created_at']), + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + include: splitList(query.include), + }), + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.configurations.list(pathParams['project-code'], query as ListConfigurationsRequest) + ) + }, +} + +const get: ApiEndpointSpec = { + id: 'configurations.get', + commandPath: ['configurations', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, idParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.configurations.get(pathParams['project-code'], pathParams['configuration-id']) + ) + }, +} + +const create: ApiEndpointSpec = { + id: 'configurations.create', + commandPath: ['configurations', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [titleField], + check: titleCheck, + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.configurations.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const update: ApiEndpointSpec = { + id: 'configurations.update', + commandPath: ['configurations', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, idParam], + fieldOptions: [titleField], + check: titleCheck, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.configurations.update( + pathParams['project-code'], + pathParams['configuration-id'], + body as Parameters[2] + ) + ) + }, +} + +const remove: ApiEndpointSpec = { + id: 'configurations.delete', + commandPath: ['configurations', 'delete'], + describe: help.delete.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, idParam], + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.configurations.delete(pathParams['project-code'], pathParams['configuration-id']) + ) + }, +} + +export const configurationSpecs: ApiEndpointSpec[] = [list, get, create, update, remove] diff --git a/src/commands/api/manifests/folders.ts b/src/commands/api/manifests/folders.ts index e7e78e9..2eabd35 100644 --- a/src/commands/api/manifests/folders.ts +++ b/src/commands/api/manifests/folders.ts @@ -1,21 +1,45 @@ -import { BulkCreateFoldersRequestSchema } from '../../../api/folders' import { - offsetParam, - pageParam, - limitParam, - sortFieldParam, - sortOrderParam, -} from '../../../api/schemas' -import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' + BulkCreateFoldersRequestSchema, + CopyFoldersRequestSchema, + CreateFolderRequestSchema, + FolderPositionsSchema, + GetFolderTreeRequestSchema, + MoveFoldersRequestSchema, + type ListFoldersRequest, +} from '../../../api/folders' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { + commonHelp, + idListSchema, + intIdParam, + intListSchema, + paginationOptions, + projectCodeParam, + splitIntList, + splitList, +} from './utils' import type { ApiEndpointSpec } from '../types' const help = { ...commonHelp, folders: `JSON array of folder objects. Each folder has a "path" (string array) and optional "comment" (supports HTML).\nExample: '[{"path": ["Parent", "Child"]}]'`, + 'folder-id': 'Folder ID.', + 'folder-ids': 'Comma-separated folder IDs (e.g., "1,2,3").', + title: 'Folder name, 1-255 characters, unique among its siblings.', + 'parent-id': 'Parent folder ID; 0 or omitted for a root folder.', + pos: '0-based position among the siblings; omitted adds the folder last.', + comment: 'Folder comment (supports HTML). On update, "" clears it.', + 'new-parent-id': 'Folder to move the folders into; 0 for the root.', + items: + 'JSON array of the folders to move and their final positions among the new parent\'s children. A negative pos counts from the end (-1 is last).\nExample: \'[{"id": 4, "pos": 0}, {"id": 5, "pos": -1}]\'', + 'target-folder-id': 'Folder to copy into; 0 or omitted for the root.', + 'source-project-id': 'Code or ID of the project the folders are in; defaults to --project-code.', + 'tcase-ids': 'Comma-separated test cases to delete too: IDs, sequence numbers or legacy IDs.', + include: + 'Additional fields to include. tcaseCount adds the number of test cases in each folder and its subtree.', list: { describe: 'List folders in a project.', - epilog: apiDocsEpilog('folders', 'list-project-folders'), + epilog: apiDocsEpilog('folders', 'list-folders'), examples: [ { usage: '$0 api folders list --project-code PRJ', @@ -25,7 +49,7 @@ const help = { }, bulkCreate: { describe: 'Create or update multiple folders at once.', - epilog: apiDocsEpilog('folders', 'bulk-upsert-folders'), + epilog: apiDocsEpilog('folders', 'upsert-folders'), examples: [ { usage: @@ -39,42 +63,124 @@ const help = { }, ], }, + get: { + describe: 'Get a folder by ID.', + epilog: apiDocsEpilog('folders', 'get-folder'), + examples: [ + { + usage: '$0 api folders get --project-code PRJ --folder-id 4', + description: 'Get a folder', + }, + ], + }, + tree: { + describe: 'Get all folders of a project as a tree, in the order the web app shows them.', + epilog: apiDocsEpilog('folders', 'get-folder-tree'), + examples: [ + { + usage: '$0 api folders tree --project-code PRJ --include tcaseCount', + description: 'Get the folder tree with test case counts', + }, + ], + }, + path: { + describe: 'Get the folders from the root down to a folder, itself included.', + epilog: apiDocsEpilog('folders', 'get-folder-path'), + examples: [ + { + usage: '$0 api folders path --project-code PRJ --folder-id 4', + description: 'Get the path of a folder', + }, + ], + }, + create: { + describe: 'Create a folder.', + epilog: apiDocsEpilog('folders', 'create-folder'), + examples: [ + { + usage: '$0 api folders create --project-code PRJ --title "Login" --parent-id 4', + description: 'Create a subfolder', + }, + { + usage: '$0 api folders create --project-code PRJ --body \'{"title": "Login"}\'', + description: 'Create a root folder using --body', + }, + ], + }, + update: { + describe: 'Rename a folder or change its comment.', + epilog: apiDocsEpilog('folders', 'update-folder'), + examples: [ + { + usage: '$0 api folders update --project-code PRJ --folder-id 4 --title "Sign in"', + description: 'Rename a folder', + }, + ], + }, + move: { + describe: + 'Move folders, with their subfolders and test cases, under a new parent, or reorder them.', + epilog: apiDocsEpilog('folders', 'move-folders'), + examples: [ + { + usage: + '$0 api folders move --project-code PRJ --new-parent-id 10 --items \'[{"id": 4, "pos": 0}, {"id": 5, "pos": -1}]\'', + description: 'Move two folders into folder 10, first and last', + }, + ], + }, + copy: { + describe: 'Copy folders, with their subfolders and test cases, into a folder.', + epilog: apiDocsEpilog('folders', 'copy-folders'), + examples: [ + { + usage: '$0 api folders copy --project-code PRJ --folder-ids 4,5 --target-folder-id 10', + description: 'Copy two folders into folder 10', + }, + { + usage: '$0 api folders copy --project-code PRJ --folder-ids 4 --source-project-id OLD', + description: 'Copy a folder from another project to the root', + }, + ], + }, + delete: { + describe: + 'Delete folders with their subfolders and test cases. They go to the trash as one entry.', + epilog: apiDocsEpilog('folders', 'delete-folders'), + examples: [ + { + usage: '$0 api folders delete --project-code PRJ --folder-ids 4,5', + description: 'Delete two folders', + }, + { + usage: '$0 api folders delete --project-code PRJ --folder-ids 4 --tcase-ids 12,13', + description: 'Delete a folder and two more test cases', + }, + ], + }, } as const +const folderIdParam = intIdParam('folder-id', help['folder-id']) + const list: ApiEndpointSpec = { id: 'folders.list', commandPath: ['folders', 'list'], describe: help.list.describe, bodyMode: 'none', pathParams: [projectCodeParam], - queryOptions: [ - { name: 'offset', type: 'number', describe: help.offset, schema: offsetParam }, - { name: 'limit', type: 'number', describe: help.limit, schema: limitParam }, - { name: 'page', type: 'number', describe: help.page, schema: pageParam }, - { - name: 'sort-field', - type: 'string', - describe: help['sort-field'], - schema: sortFieldParam, - choices: ['id', 'project_id', 'title', 'pos', 'parent_id', 'created_at', 'updated_at'], - }, - { - name: 'sort-order', - type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], - }, - ], + queryOptions: paginationOptions([ + 'id', + 'project_id', + 'title', + 'pos', + 'parent_id', + 'created_at', + 'updated_at', + ]), epilog: help.list.epilog, examples: help.list.examples, execute: async (api, { pathParams, query }) => { - printJson( - await api.folders.getPaginated( - pathParams['project-code'], - query as Parameters[1] - ) - ) + printJson(await api.folders.list(pathParams['project-code'], query as ListFoldersRequest)) }, } @@ -116,4 +222,263 @@ const bulkCreate: ApiEndpointSpec = { }, } -export const folderSpecs: ApiEndpointSpec[] = [list, bulkCreate] +const get: ApiEndpointSpec = { + id: 'folders.get', + commandPath: ['folders', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, folderIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.folders.get(pathParams['project-code'], pathParams['folder-id'])) + }, +} + +const tree: ApiEndpointSpec = { + id: 'folders.tree', + commandPath: ['folders', 'tree'], + describe: help.tree.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { + name: 'include', + type: 'string', + describe: help.include, + schema: GetFolderTreeRequestSchema.shape.include, + helpChoices: ['tcaseCount'], + }, + ], + epilog: help.tree.epilog, + examples: help.tree.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.folders.getTree( + pathParams['project-code'], + query as Parameters[1] + ) + ) + }, +} + +const path: ApiEndpointSpec = { + id: 'folders.path', + commandPath: ['folders', 'path'], + describe: help.path.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, folderIdParam], + epilog: help.path.epilog, + examples: help.path.examples, + execute: async (api, { pathParams }) => { + printJson(await api.folders.getPath(pathParams['project-code'], pathParams['folder-id'])) + }, +} + +const create: ApiEndpointSpec = { + id: 'folders.create', + commandPath: ['folders', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateFolderRequestSchema.shape.title, + }, + { + name: 'parent-id', + type: 'number', + describe: help['parent-id'], + schema: CreateFolderRequestSchema.shape.parentId, + }, + { + name: 'pos', + type: 'number', + describe: help.pos, + schema: CreateFolderRequestSchema.shape.pos, + }, + { name: 'comment', type: 'string', describe: help.comment }, + ], + check: (argv) => { + return argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required' + }, + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.folders.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const update: ApiEndpointSpec = { + id: 'folders.update', + commandPath: ['folders', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, folderIdParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateFolderRequestSchema.shape.title, + }, + { name: 'comment', type: 'string', describe: help.comment }, + ], + check: (argv) => { + return argv.body !== undefined || + argv['body-file'] !== undefined || + argv.title !== undefined || + argv.comment !== undefined + ? true + : 'At least one field to update is required (--body, --body-file, --title, --comment)' + }, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.folders.update( + pathParams['project-code'], + pathParams['folder-id'], + body as Parameters[2] + ) + ) + }, +} + +const move: ApiEndpointSpec = { + id: 'folders.move', + commandPath: ['folders', 'move'], + describe: help.move.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'new-parent-id', + type: 'number', + describe: help['new-parent-id'], + schema: MoveFoldersRequestSchema.shape.newParentId, + }, + { + name: 'items', + type: 'string', + describe: help.items, + schema: FolderPositionsSchema, + jsonParse: true, + }, + ], + check: (argv) => { + return argv.body !== undefined || argv['body-file'] !== undefined || argv.items !== undefined + ? true + : 'Either --body, --body-file, or --items is required' + }, + epilog: help.move.epilog, + examples: help.move.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.folders.move( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const copy: ApiEndpointSpec = { + id: 'folders.copy', + commandPath: ['folders', 'copy'], + describe: help.copy.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { name: 'folder-ids', type: 'string', describe: help['folder-ids'], schema: intListSchema }, + { + name: 'target-folder-id', + type: 'number', + describe: help['target-folder-id'], + schema: CopyFoldersRequestSchema.shape.targetFolderId, + }, + { + name: 'source-project-id', + type: 'string', + describe: help['source-project-id'], + schema: CopyFoldersRequestSchema.shape.sourceProjectId, + }, + ], + transformFields: (fields) => ({ + ...kebabToCamelCaseKeys(fields), + folderIds: splitIntList(fields['folder-ids']), + }), + check: (argv) => { + return argv.body !== undefined || + argv['body-file'] !== undefined || + argv['folder-ids'] !== undefined + ? true + : 'Either --body, --body-file, or --folder-ids is required' + }, + epilog: help.copy.epilog, + examples: help.copy.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.folders.copy( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const del: ApiEndpointSpec = { + id: 'folders.delete', + commandPath: ['folders', 'delete'], + describe: help.delete.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { name: 'folder-ids', type: 'string', describe: help['folder-ids'], schema: intListSchema }, + { name: 'tcase-ids', type: 'string', describe: help['tcase-ids'], schema: idListSchema }, + ], + transformFields: (fields) => ({ + folderIds: splitIntList(fields['folder-ids']), + tcaseIds: splitList(fields['tcase-ids']), + }), + check: (argv) => { + return argv.body !== undefined || + argv['body-file'] !== undefined || + argv['folder-ids'] !== undefined + ? true + : 'Either --body, --body-file, or --folder-ids is required' + }, + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.folders.delete( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +export const folderSpecs: ApiEndpointSpec[] = [ + list, + bulkCreate, + get, + tree, + path, + create, + update, + move, + copy, + del, +] diff --git a/src/commands/api/manifests/index.ts b/src/commands/api/manifests/index.ts index 4ff3d26..262c031 100644 --- a/src/commands/api/manifests/index.ts +++ b/src/commands/api/manifests/index.ts @@ -1,5 +1,7 @@ import type { ApiEndpointSpec } from '../types' +import { aiRuleSpecs } from './ai-rules' import { auditLogSpecs } from './audit-logs' +import { configurationSpecs } from './configurations' import { customFieldSpecs } from './custom-fields' import { fileSpecs } from './files' import { folderSpecs } from './folders' @@ -14,10 +16,13 @@ import { sharedStepSpecs } from './shared-steps' import { tagSpecs } from './tags' import { testCaseSpecs } from './test-cases' import { testPlanSpecs } from './test-plans' +import { trashSpecs } from './trash' import { userSpecs } from './users' export const allSpecs: ApiEndpointSpec[] = [ + ...aiRuleSpecs, ...auditLogSpecs, + ...configurationSpecs, ...customFieldSpecs, ...fileSpecs, ...folderSpecs, @@ -32,5 +37,6 @@ export const allSpecs: ApiEndpointSpec[] = [ ...tagSpecs, ...testCaseSpecs, ...testPlanSpecs, + ...trashSpecs, ...userSpecs, ] diff --git a/src/commands/api/manifests/milestones.ts b/src/commands/api/manifests/milestones.ts index e2bde15..cc15c23 100644 --- a/src/commands/api/manifests/milestones.ts +++ b/src/commands/api/manifests/milestones.ts @@ -1,6 +1,6 @@ -import { CreateMilestoneRequestSchema } from '../../../api/milestones' +import { CreateMilestoneRequestSchema, type ListMilestonesRequest } from '../../../api/milestones' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { commonHelp, paginationOptions, projectCodeParam } from './utils' import type { ApiEndpointSpec } from '../types' const help = { @@ -9,7 +9,7 @@ const help = { title: 'Display title for the milestone (max 255 characters).', list: { describe: 'List milestones in a project.', - epilog: apiDocsEpilog('milestone', 'list-project-milestones'), + epilog: apiDocsEpilog('milestone', 'list-milestones'), examples: [ { usage: '$0 api milestones list --project-code PRJ', @@ -39,16 +39,14 @@ const list: ApiEndpointSpec = { describe: help.list.describe, bodyMode: 'none', pathParams: [projectCodeParam], - queryOptions: [{ name: 'archived', type: 'boolean', describe: help.archived }], + queryOptions: [ + { name: 'archived', type: 'boolean', describe: help.archived }, + ...paginationOptions(['title', 'created_at', 'updated_at', 'archived_at']), + ], epilog: help.list.epilog, examples: help.list.examples, execute: async (api, { pathParams, query }) => { - printJson( - await api.milestones.list( - pathParams['project-code'], - query as Parameters[1] - ) - ) + printJson(await api.milestones.list(pathParams['project-code'], query as ListMilestonesRequest)) }, } diff --git a/src/commands/api/manifests/projects.ts b/src/commands/api/manifests/projects.ts index c6890c0..e36c8ba 100644 --- a/src/commands/api/manifests/projects.ts +++ b/src/commands/api/manifests/projects.ts @@ -1,6 +1,13 @@ -import { CreateProjectRequestSchema, projectLinksSchema } from '../../../api/projects' +import { z } from 'zod' +import { + CreateProjectRequestSchema, + projectLinksSchema, + projectTeamMembersSchema, + userRoles, +} from '../../../api/projects' +import type { ListProjectsRequest } from '../../../api/projects' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { commonHelp, paginationOptions, projectCodeParam, splitList } from './utils' import type { ApiEndpointSpec } from '../types' const help = { @@ -11,13 +18,26 @@ const help = { 'JSON array of project links. Each link has "text" and "url" fields.\nExample: \'[{"text": "Docs", "url": "https://example.com"}]\'', 'overview-title': 'Title for the project overview page (max 255 characters).', 'overview-description': 'Description for the project overview page (supports HTML).', + archived: + 'Archive (true) or unarchive (false) the project. Archiving makes everything in it read-only, except this command.', + 'update-links': + 'JSON array that replaces the project links; [] removes them. Each link has "text" and "url" fields.\nExample: \'[{"text": "Docs", "url": "https://example.com"}]\'', + 'team-members': + 'JSON array that replaces the project\'s team (shown on its overview page), in display order; [] clears it. Each member has "userId" (a user with access to the project) and "role" (their role in the team, as free text).\nExample: \'[{"userId": 1, "role": "QA lead"}]\'', + roles: `Comma-separated global roles to filter by: ${userRoles.join(', ')}.`, list: { - describe: 'List all projects.', + describe: 'List projects.', epilog: apiDocsEpilog('projects', 'list-projects'), - examples: [{ usage: '$0 api projects list', description: 'List all projects' }], + examples: [ + { usage: '$0 api projects list', description: 'List all projects' }, + { + usage: '$0 api projects list --limit 20 --sort-field updated_at --sort-order desc', + description: 'List the 20 most recently updated projects', + }, + ], }, get: { - describe: 'Get a project by code or ID.', + describe: 'Get a project by code or ID, with its team members.', epilog: apiDocsEpilog('projects', 'get-project'), examples: [ { usage: '$0 api projects get --project-code PRJ', description: 'Get a project by code' }, @@ -37,6 +57,41 @@ const help = { }, ], }, + update: { + describe: + "Update a project's title, overview, links or team, or archive or unarchive it. Owners and admins only. It also works on an archived project, which stays archived unless --archived false is passed.", + epilog: apiDocsEpilog('projects', 'update-project'), + examples: [ + { + usage: '$0 api projects update --project-code PRJ --title "Checkout"', + description: 'Rename a project', + }, + { + usage: '$0 api projects update --project-code PRJ --archived false', + description: 'Unarchive a project', + }, + { + usage: + '$0 api projects update --project-code PRJ --team-members \'[{"userId": 1, "role": "QA lead"}]\'', + description: 'Replace the team card', + }, + ], + }, + usersList: { + describe: + 'List the users who can access a project: owners, admins, and users given access to it or to all projects.', + epilog: apiDocsEpilog('projects', 'list-project-users'), + examples: [ + { + usage: '$0 api projects users list --project-code PRJ', + description: 'List the users of a project', + }, + { + usage: '$0 api projects users list --project-code PRJ --roles user,test-runner', + description: 'List the users with some roles', + }, + ], + }, } as const const list: ApiEndpointSpec = { @@ -45,10 +100,11 @@ const list: ApiEndpointSpec = { describe: help.list.describe, bodyMode: 'none', pathParams: [], + queryOptions: paginationOptions(['code', 'title', 'created_at', 'updated_at']), epilog: help.list.epilog, examples: help.list.examples, - execute: async (api) => { - printJson(await api.projects.list()) + execute: async (api, { query }) => { + printJson(await api.projects.list(query as ListProjectsRequest)) }, } @@ -116,4 +172,103 @@ const create: ApiEndpointSpec = { }, } -export const projectSpecs: ApiEndpointSpec[] = [list, get, create] +const updateFields = [ + 'title', + 'archived', + 'overview-title', + 'overview-description', + 'links', + 'team-members', +] + +const update: ApiEndpointSpec = { + id: 'projects.update', + commandPath: ['projects', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateProjectRequestSchema.shape.title, + }, + { name: 'archived', type: 'boolean', describe: help.archived }, + { + name: 'overview-title', + type: 'string', + describe: help['overview-title'], + schema: CreateProjectRequestSchema.shape.overviewTitle.unwrap(), + }, + { + name: 'overview-description', + type: 'string', + describe: help['overview-description'], + schema: CreateProjectRequestSchema.shape.overviewDescription.unwrap(), + }, + { + name: 'links', + type: 'string', + describe: help['update-links'], + schema: projectLinksSchema, + jsonParse: true, + }, + { + name: 'team-members', + type: 'string', + describe: help['team-members'], + schema: projectTeamMembersSchema, + jsonParse: true, + }, + ], + check: (argv) => { + const hasField = updateFields.some((f) => argv[f] !== undefined) + if (argv.body === undefined && argv['body-file'] === undefined && !hasField) { + const fieldList = updateFields.map((f) => `--${f}`).join(', ') + return `At least one field to update is required (--body, --body-file, ${fieldList})` + } + return true + }, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.projects.update( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const rolesSchema = z.string().refine( + (value) => + value + .split(',') + .map((item) => item.trim()) + .every((item) => (userRoles as readonly string[]).includes(item)), + `must be comma-separated values of ${userRoles.join(', ')}` +) + +const usersList: ApiEndpointSpec = { + id: 'projects.users.list', + commandPath: ['projects', 'users', 'list'], + describe: help.usersList.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [{ name: 'roles', type: 'string', describe: help.roles, schema: rolesSchema }], + transformQuery: (query) => ({ roles: splitList(query.roles) }), + epilog: help.usersList.epilog, + examples: help.usersList.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.projects.listUsers( + pathParams['project-code'], + query as Parameters[1] + ) + ) + }, +} + +export const projectSpecs: ApiEndpointSpec[] = [list, get, create, update, usersList] diff --git a/src/commands/api/manifests/requirements.ts b/src/commands/api/manifests/requirements.ts index 1bc11e8..ea7c769 100644 --- a/src/commands/api/manifests/requirements.ts +++ b/src/commands/api/manifests/requirements.ts @@ -1,11 +1,21 @@ -import { sortFieldParam, sortOrderParam } from '../../../api/schemas' +import { + CreateRequirementRequestSchema, + UpdateRequirementRequestSchema, + type ListRequirementsRequest, +} from '../../../api/requirements' +import { resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' -import type { ApiEndpointSpec } from '../types' +import { commonHelp, paginationOptions, projectCodeParam } from './utils' +import type { ApiEndpointSpec, ApiPathParamSpec } from '../types' const help = { ...commonHelp, - include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + include: 'Additional fields to include. tcaseCount adds the number of linked test cases.', + 'requirement-id': 'Requirement ID.', + text: 'Requirement text, 1-255 characters, unique in the project.', + url: 'URL of the requirement (an HTTP or HTTPS URL, max 255 characters).', + 'integration-issue-id': + "ID or key of an issue in the project's Jira or Linear integration to link the requirement to (e.g., PROJ-123). Ignored when the project has no such integration.", list: { describe: 'List requirements in a project.', epilog: apiDocsEpilog('requirements', 'list-requirements'), @@ -20,8 +30,66 @@ const help = { }, ], }, + get: { + describe: 'Get a requirement, with the number of test cases linked to it.', + epilog: apiDocsEpilog('requirements', 'get-requirement'), + examples: [ + { + usage: + '$0 api requirements get --project-code PRJ --requirement-id 1CJo8oDjj_vkGo2rQAdzz4F', + description: 'Get a requirement', + }, + ], + }, + create: { + describe: 'Create a requirement, optionally linked to a Jira or Linear issue.', + epilog: apiDocsEpilog('requirements', 'create-requirement'), + examples: [ + { + usage: + '$0 api requirements create --project-code PRJ --text "Login" --url https://example.com/spec/login', + description: 'Create a requirement', + }, + { + usage: + '$0 api requirements create --project-code PRJ --text "Login" --integration-issue-id PROJ-123', + description: 'Create a requirement linked to a Jira issue', + }, + ], + }, + update: { + describe: + 'Update a requirement. Omitted fields are kept; --url "" clears the URL and --integration-issue-id "" removes the issue link.', + epilog: apiDocsEpilog('requirements', 'update-requirement'), + examples: [ + { + usage: + '$0 api requirements update --project-code PRJ --requirement-id 1CJo8oDjj_vkGo2rQAdzz4F --text "Sign in"', + description: 'Rename a requirement', + }, + ], + }, + delete: { + describe: + 'Delete a requirement. It is unlinked from every test case and cannot be restored; its link is removed from the Jira or Linear issue.', + epilog: apiDocsEpilog('requirements', 'delete-requirement'), + examples: [ + { + usage: + '$0 api requirements delete --project-code PRJ --requirement-id 1CJo8oDjj_vkGo2rQAdzz4F', + description: 'Delete a requirement', + }, + ], + }, } as const +const requirementIdParam: ApiPathParamSpec = { + name: 'requirement-id', + type: 'string', + describe: help['requirement-id'], + schema: resourceIdSchema, +} + const list: ApiEndpointSpec = { id: 'requirements.list', commandPath: ['requirements', 'list'], @@ -29,32 +97,135 @@ const list: ApiEndpointSpec = { bodyMode: 'none', pathParams: [projectCodeParam], queryOptions: [ + ...paginationOptions(['created_at', 'text']), + { name: 'include', type: 'string', describe: help.include, helpChoices: ['tcaseCount'] }, + ], + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.requirements.list(pathParams['project-code'], query as ListRequirementsRequest) + ) + }, +} + +const get: ApiEndpointSpec = { + id: 'requirements.get', + commandPath: ['requirements', 'get'], + describe: help.get.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, requirementIdParam], + epilog: help.get.epilog, + examples: help.get.examples, + execute: async (api, { pathParams }) => { + printJson(await api.requirements.get(pathParams['project-code'], pathParams['requirement-id'])) + }, +} + +const create: ApiEndpointSpec = { + id: 'requirements.create', + commandPath: ['requirements', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ { - name: 'sort-field', + name: 'text', type: 'string', - describe: help['sort-field'], - schema: sortFieldParam, - choices: ['created_at', 'text'], + describe: help.text, + schema: CreateRequirementRequestSchema.shape.text, }, { - name: 'sort-order', + name: 'url', type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], + describe: help.url, + schema: CreateRequirementRequestSchema.shape.url.unwrap(), + }, + { + name: 'integration-issue-id', + type: 'string', + describe: help['integration-issue-id'], + schema: CreateRequirementRequestSchema.shape.integrationIssueId.unwrap(), }, - { name: 'include', type: 'string', describe: help.include }, ], - epilog: help.list.epilog, - examples: help.list.examples, - execute: async (api, { pathParams, query }) => { + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.text !== undefined + ? true + : 'Either --body, --body-file, or --text is required', + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.requirements.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const updateFields = ['text', 'url', 'integration-issue-id'] + +const update: ApiEndpointSpec = { + id: 'requirements.update', + commandPath: ['requirements', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, requirementIdParam], + fieldOptions: [ + { + name: 'text', + type: 'string', + describe: help.text, + schema: UpdateRequirementRequestSchema.shape.text.unwrap(), + }, + { + name: 'url', + type: 'string', + describe: `${help.url} Pass "" to clear it.`, + schema: UpdateRequirementRequestSchema.shape.url.unwrap(), + }, + { + name: 'integration-issue-id', + type: 'string', + describe: `${help['integration-issue-id']} Pass "" to remove the link.`, + schema: UpdateRequirementRequestSchema.shape.integrationIssueId.unwrap(), + }, + ], + check: (argv) => { + const hasField = updateFields.some((f) => argv[f] !== undefined) + if (argv.body === undefined && argv['body-file'] === undefined && !hasField) { + const fieldList = updateFields.map((f) => `--${f}`).join(', ') + return `At least one field to update is required (--body, --body-file, ${fieldList})` + } + return true + }, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { printJson( - await api.requirements.list( + await api.requirements.update( pathParams['project-code'], - query as Parameters[1] + pathParams['requirement-id'], + body as Parameters[2] ) ) }, } -export const requirementSpecs: ApiEndpointSpec[] = [list] +const remove: ApiEndpointSpec = { + id: 'requirements.delete', + commandPath: ['requirements', 'delete'], + describe: help.delete.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, requirementIdParam], + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.requirements.delete(pathParams['project-code'], pathParams['requirement-id']) + ) + }, +} + +export const requirementSpecs: ApiEndpointSpec[] = [list, get, create, update, remove] diff --git a/src/commands/api/manifests/runs.ts b/src/commands/api/manifests/runs.ts index 41a27fd..cd54d6c 100644 --- a/src/commands/api/manifests/runs.ts +++ b/src/commands/api/manifests/runs.ts @@ -4,12 +4,13 @@ import { CreateRunLogRequestSchema, QueryPlansSchema, CloneRunRequestSchema, + type ListRunsRequest, type ListRunTCasesRequest, CreateRunLogRequest, } from '../../../api/runs' -import { limitParam, sortFieldParam, sortOrderParam, resourceIdSchema } from '../../../api/schemas' +import { resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { commonHelp, paginationOptions, projectCodeParam, splitList } from './utils' import type { ApiEndpointSpec } from '../types' // CreateRunRequestSchema stays a ZodObject despite its .superRefine() refinement, @@ -29,12 +30,12 @@ const help = { 'query-plans': 'JSON array of query plan objects that select which test cases to include.\nExample: \'[{"tcaseIds": ["abc123"]}]\'', closed: 'Filter by closed status. If true, returns only closed runs.', - limit: 'Maximum number of runs to return.', 'milestone-ids': 'Comma-separated milestone IDs to filter by (e.g., "1,2,3").', 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: 'Include additional fields. Use "folder" to include folder details.', + include: 'Comma-separated additional fields to include.', + 'run-include': 'Comma-separated related data to include.', comment: 'Log message body (supports HTML).', create: { describe: 'Create a new test run.', @@ -54,12 +55,16 @@ const help = { }, list: { describe: 'List test runs in a project.', - epilog: apiDocsEpilog('run', 'list-project-runs'), + epilog: apiDocsEpilog('run', 'list-runs'), examples: [ { usage: '$0 api runs list --project-code PRJ', description: 'List test runs in a project', }, + { + usage: '$0 api runs list --project-code PRJ --closed false --include statusCounts', + description: 'List open runs with their result counts', + }, ], }, clone: { @@ -205,7 +210,13 @@ const list: ApiEndpointSpec = { queryOptions: [ { name: 'closed', type: 'boolean', describe: help.closed }, { name: 'milestone-ids', type: 'string', describe: help['milestone-ids'] }, - { name: 'limit', type: 'number', describe: help.limit, schema: limitParam }, + { + 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), @@ -213,13 +224,12 @@ const list: ApiEndpointSpec = { 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 Parameters[1]) - ) + printJson(await api.runs.list(pathParams['project-code'], query as ListRunsRequest)) }, } @@ -301,21 +311,20 @@ const tcasesList: ApiEndpointSpec = { { name: 'search', type: 'string', describe: help.search }, { name: 'tags', type: 'string', describe: help.tags }, { name: 'priorities', type: 'string', describe: help.priorities }, - { name: 'include', type: 'string', describe: help.include }, - { name: 'sort-field', type: 'string', describe: help['sort-field'], schema: sortFieldParam }, { - name: 'sort-order', + name: 'include', type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], + describe: help.include, + helpChoices: ['folder', 'steps', 'tags', 'precondition', 'requirements', 'customFields'], }, + ...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, + include: splitList(query.include), }), epilog: help.tcasesList.epilog, examples: help.tcasesList.examples, diff --git a/src/commands/api/manifests/shared-preconditions.ts b/src/commands/api/manifests/shared-preconditions.ts index 0885afa..c6eaf70 100644 --- a/src/commands/api/manifests/shared-preconditions.ts +++ b/src/commands/api/manifests/shared-preconditions.ts @@ -1,13 +1,17 @@ -import { z } from 'zod' -import { sortFieldParam, sortOrderParam } from '../../../api/schemas' +import { + CreateSharedPreconditionRequestSchema, + type ListSharedPreconditionsRequest, +} from '../../../api/shared-preconditions' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { commonHelp, intIdParam, paginationOptions, projectCodeParam } from './utils' import type { ApiEndpointSpec } from '../types' const help = { ...commonHelp, id: 'Shared precondition ID.', - include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + include: 'Additional fields to include. tcaseCount adds the number of linked test cases.', + title: 'Shared precondition title (1-255 characters), unique in the project.', + text: 'Precondition text (supports HTML).', list: { describe: 'List shared preconditions in a project.', epilog: apiDocsEpilog('shared_preconditions', 'list-shared-preconditions'), @@ -28,6 +32,41 @@ const help = { }, ], }, + create: { + describe: + 'Create a shared precondition, which test cases reuse through "sharedPreconditionId".', + epilog: apiDocsEpilog('shared_preconditions', 'create-shared-precondition'), + examples: [ + { + usage: + '$0 api shared-preconditions create --project-code PRJ --title "Logged in" --text "

The user is logged in

"', + description: 'Create a shared precondition', + }, + ], + }, + update: { + describe: + "Update a shared precondition's title or text. Every test case that uses it gets a new version.", + epilog: apiDocsEpilog('shared_preconditions', 'update-shared-precondition'), + examples: [ + { + usage: + '$0 api shared-preconditions update --project-code PRJ --id 1 --text "

The user is logged out

"', + description: 'Change the text of a shared precondition', + }, + ], + }, + delete: { + describe: + 'Delete a shared precondition for good. Test cases that use it keep its text as a standalone precondition.', + epilog: apiDocsEpilog('shared_preconditions', 'delete-shared-precondition'), + examples: [ + { + usage: '$0 api shared-preconditions delete --project-code PRJ --id 1', + description: 'Delete a shared precondition', + }, + ], + }, } as const const list: ApiEndpointSpec = { @@ -37,21 +76,8 @@ const list: ApiEndpointSpec = { bodyMode: 'none', pathParams: [projectCodeParam], queryOptions: [ - { - name: 'sort-field', - type: 'string', - describe: help['sort-field'], - schema: sortFieldParam, - choices: ['created_at', 'title'], - }, - { - name: 'sort-order', - type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], - }, - { name: 'include', type: 'string', describe: help.include }, + ...paginationOptions(['created_at', 'title']), + { name: 'include', type: 'string', describe: help.include, helpChoices: ['tcaseCount'] }, ], epilog: help.list.epilog, examples: help.list.examples, @@ -59,7 +85,7 @@ const list: ApiEndpointSpec = { printJson( await api.sharedPreconditions.list( pathParams['project-code'], - query as Parameters[1] + query as ListSharedPreconditionsRequest ) ) }, @@ -70,15 +96,7 @@ const get: ApiEndpointSpec = { commandPath: ['shared-preconditions', 'get'], describe: help.get.describe, bodyMode: 'none', - pathParams: [ - projectCodeParam, - { - name: 'id', - type: 'number', - describe: help.id, - schema: z.number().int().positive('must be a positive integer'), - }, - ], + pathParams: [projectCodeParam, intIdParam('id', help.id)], epilog: help.get.epilog, examples: help.get.examples, execute: async (api, { pathParams }) => { @@ -88,4 +106,83 @@ const get: ApiEndpointSpec = { }, } -export const sharedPreconditionSpecs: ApiEndpointSpec[] = [list, get] +const titleField = { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateSharedPreconditionRequestSchema.shape.title, +} as const + +const textField = { + name: 'text', + type: 'string', + describe: help.text, + schema: CreateSharedPreconditionRequestSchema.shape.text, +} as const + +const create: ApiEndpointSpec = { + id: 'shared-preconditions.create', + commandPath: ['shared-preconditions', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [titleField, textField], + check: (argv) => { + return argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required' + }, + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.sharedPreconditions.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const update: ApiEndpointSpec = { + id: 'shared-preconditions.update', + commandPath: ['shared-preconditions', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, intIdParam('id', help.id)], + fieldOptions: [titleField, textField], + check: (argv) => { + const hasField = ['title', 'text'].some((f) => argv[f] !== undefined) + return argv.body !== undefined || argv['body-file'] !== undefined || hasField + ? true + : 'At least one field to update is required (--body, --body-file, --title, --text)' + }, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.sharedPreconditions.update( + pathParams['project-code'], + pathParams['id'] as number, + body as Parameters[2] + ) + ) + }, +} + +const remove: ApiEndpointSpec = { + id: 'shared-preconditions.delete', + commandPath: ['shared-preconditions', 'delete'], + describe: help.delete.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, intIdParam('id', help.id)], + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams }) => { + printJson( + await api.sharedPreconditions.delete(pathParams['project-code'], pathParams['id'] as number) + ) + }, +} + +export const sharedPreconditionSpecs: ApiEndpointSpec[] = [list, get, create, update, remove] diff --git a/src/commands/api/manifests/shared-steps.ts b/src/commands/api/manifests/shared-steps.ts index 341b9a8..105bc38 100644 --- a/src/commands/api/manifests/shared-steps.ts +++ b/src/commands/api/manifests/shared-steps.ts @@ -1,13 +1,22 @@ -import { z } from 'zod' -import { sortFieldParam, sortOrderParam } from '../../../api/schemas' +import { + CreateSharedStepRequestSchema, + CreateSubStepsSchema, + UpdateSubStepsSchema, + type ListSharedStepsRequest, +} from '../../../api/shared-steps' import { printJson, apiDocsEpilog } from '../utils' -import { commonHelp, projectCodeParam } from './utils' +import { commonHelp, intIdParam, paginationOptions, projectCodeParam } from './utils' import type { ApiEndpointSpec } from '../types' const help = { ...commonHelp, id: 'Shared step ID.', - include: 'Include additional fields. Use "tcaseCount" to include linked test case count.', + include: 'Additional fields to include. tcaseCount adds the number of linked test cases.', + title: 'Shared step title (1-255 characters), unique in the project.', + 'sub-steps': + 'JSON array of sub-steps, in order. Each has optional "description" and "expected" (HTML) and "data" (test data).\nExample: \'[{"description": "Open the login page", "expected": "The form is shown"}]\'', + 'update-sub-steps': + 'JSON array that replaces the sub-steps. Include an existing sub-step\'s "id" to keep it; sub-steps left out are removed.\nExample: \'[{"id": 2, "description": "Open the login page"}, {"description": "New step"}]\'', list: { describe: 'List shared steps in a project.', epilog: apiDocsEpilog('shared_steps', 'list-shared-steps'), @@ -29,6 +38,39 @@ const help = { }, ], }, + create: { + describe: 'Create a shared step, which test cases reuse through "sharedStepId" in their steps.', + epilog: apiDocsEpilog('shared_steps', 'create-shared-step'), + examples: [ + { + usage: + '$0 api shared-steps create --project-code PRJ --title "Log in" --sub-steps \'[{"description": "Open the login page"}]\'', + description: 'Create a shared step', + }, + ], + }, + update: { + describe: + "Update a shared step's title or sub-steps. Every test case that uses it gets a new version.", + epilog: apiDocsEpilog('shared_steps', 'update-shared-step'), + examples: [ + { + usage: '$0 api shared-steps update --project-code PRJ --id 1 --title "Log in as admin"', + description: 'Rename a shared step', + }, + ], + }, + delete: { + describe: + 'Delete a shared step for good. Test cases that use it keep its sub-steps as standalone steps.', + epilog: apiDocsEpilog('shared_steps', 'delete-shared-step'), + examples: [ + { + usage: '$0 api shared-steps delete --project-code PRJ --id 1', + description: 'Delete a shared step', + }, + ], + }, } as const const list: ApiEndpointSpec = { @@ -38,30 +80,14 @@ const list: ApiEndpointSpec = { bodyMode: 'none', pathParams: [projectCodeParam], queryOptions: [ - { - name: 'sort-field', - type: 'string', - describe: help['sort-field'], - schema: sortFieldParam, - choices: ['created_at', 'title'], - }, - { - name: 'sort-order', - type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], - }, - { name: 'include', type: 'string', describe: help.include }, + ...paginationOptions(['created_at', 'title']), + { name: 'include', type: 'string', describe: help.include, helpChoices: ['tcaseCount'] }, ], epilog: help.list.epilog, examples: help.list.examples, execute: async (api, { pathParams, query }) => { printJson( - await api.sharedSteps.list( - pathParams['project-code'], - query as Parameters[1] - ) + await api.sharedSteps.list(pathParams['project-code'], query as ListSharedStepsRequest) ) }, } @@ -71,15 +97,7 @@ const get: ApiEndpointSpec = { commandPath: ['shared-steps', 'get'], describe: help.get.describe, bodyMode: 'none', - pathParams: [ - projectCodeParam, - { - name: 'id', - type: 'number', - describe: help.id, - schema: z.number().int().positive('must be a positive integer'), - }, - ], + pathParams: [projectCodeParam, intIdParam('id', help.id)], epilog: help.get.epilog, examples: help.get.examples, execute: async (api, { pathParams }) => { @@ -87,4 +105,95 @@ const get: ApiEndpointSpec = { }, } -export const sharedStepSpecs: ApiEndpointSpec[] = [list, get] +const create: ApiEndpointSpec = { + id: 'shared-steps.create', + commandPath: ['shared-steps', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateSharedStepRequestSchema.shape.title, + }, + { + name: 'sub-steps', + type: 'string', + describe: help['sub-steps'], + schema: CreateSubStepsSchema, + jsonParse: true, + }, + ], + check: (argv) => { + return argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required' + }, + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.sharedSteps.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const update: ApiEndpointSpec = { + id: 'shared-steps.update', + commandPath: ['shared-steps', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, intIdParam('id', help.id)], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateSharedStepRequestSchema.shape.title, + }, + { + name: 'sub-steps', + type: 'string', + describe: help['update-sub-steps'], + schema: UpdateSubStepsSchema, + jsonParse: true, + }, + ], + check: (argv) => { + const hasField = ['title', 'sub-steps'].some((f) => argv[f] !== undefined) + return argv.body !== undefined || argv['body-file'] !== undefined || hasField + ? true + : 'At least one field to update is required (--body, --body-file, --title, --sub-steps)' + }, + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.sharedSteps.update( + pathParams['project-code'], + pathParams['id'] as number, + body as Parameters[2] + ) + ) + }, +} + +const remove: ApiEndpointSpec = { + id: 'shared-steps.delete', + commandPath: ['shared-steps', 'delete'], + describe: help.delete.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, intIdParam('id', help.id)], + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams }) => { + printJson(await api.sharedSteps.delete(pathParams['project-code'], pathParams['id'] as number)) + }, +} + +export const sharedStepSpecs: ApiEndpointSpec[] = [list, get, create, update, remove] diff --git a/src/commands/api/manifests/tags.ts b/src/commands/api/manifests/tags.ts index d6f5827..65fb8d6 100644 --- a/src/commands/api/manifests/tags.ts +++ b/src/commands/api/manifests/tags.ts @@ -1,28 +1,207 @@ +import { + CreateTagRequestSchema, + RenameTagsRequestSchema, + UpdateTagRequestSchema, + type ListTagsRequest, +} from '../../../api/tags' import { printJson, apiDocsEpilog } from '../utils' -import { projectCodeParam } from './utils' +import { + intIdParam, + intListSchema, + paginationOptions, + projectCodeParam, + splitIntList, +} from './utils' import type { ApiEndpointSpec } from '../types' const help = { + title: 'Tag title, 1-255 characters, unique in the project.', + tags: `JSON array of tags to rename, each with its "id" and new "title".\nExample: '[{"id": 1, "title": "smoke"}]'`, + 'tag-ids': 'Comma-separated IDs of the tags to delete (e.g., "1,2").', + include: 'Additional fields to include. tcaseCount adds the number of test cases with the tag.', list: { - describe: 'List all tags in a project.', - epilog: apiDocsEpilog('tag', 'list-project-tags'), + describe: 'List the tags of a project.', + epilog: apiDocsEpilog('tag', 'list-tags'), examples: [ { usage: '$0 api tags list --project-code PRJ', description: 'List tags in a project' }, ], }, + create: { + describe: 'Create a tag.', + epilog: apiDocsEpilog('tag', 'create-tag'), + examples: [ + { usage: '$0 api tags create --project-code PRJ --title smoke', description: 'Create a tag' }, + ], + }, + update: { + describe: 'Update a tag. Test cases keep the tag under its new title.', + epilog: apiDocsEpilog('tag', 'rename-tag'), + examples: [ + { + usage: '$0 api tags update --project-code PRJ --tag-id 1 --title regression', + description: 'Update a tag', + }, + ], + }, + rename: { + describe: 'Rename several tags at once.', + epilog: apiDocsEpilog('tag', 'rename-tags'), + examples: [ + { + usage: + '$0 api tags rename --project-code PRJ --tags \'[{"id": 1, "title": "smoke"}, {"id": 2, "title": "e2e"}]\'', + description: 'Rename two tags', + }, + ], + }, + delete: { + describe: + 'Delete tags. They are removed from every test case and cannot be restored. An open live run filtered only by deleted tags stops filtering by tag, so it takes in every test case its other filters match.', + epilog: apiDocsEpilog('tag', 'delete-tags'), + examples: [ + { + usage: '$0 api tags delete --project-code PRJ --tag-ids 1,2', + description: 'Delete two tags', + }, + ], + }, } as const +const tagIdParam = intIdParam('tag-id', 'Tag ID.') + const list: ApiEndpointSpec = { id: 'tags.list', commandPath: ['tags', 'list'], describe: help.list.describe, bodyMode: 'none', pathParams: [projectCodeParam], + queryOptions: [ + { name: 'include', type: 'string', describe: help.include, helpChoices: ['tcaseCount'] }, + ...paginationOptions(['created_at', 'title']), + ], epilog: help.list.epilog, examples: help.list.examples, - execute: async (api, { pathParams }) => { - printJson(await api.tags.list(pathParams['project-code'])) + execute: async (api, { pathParams, query }) => { + printJson(await api.tags.list(pathParams['project-code'], query as ListTagsRequest)) + }, +} + +const create: ApiEndpointSpec = { + id: 'tags.create', + commandPath: ['tags', 'create'], + describe: help.create.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: CreateTagRequestSchema.shape.title, + }, + ], + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required', + epilog: help.create.epilog, + examples: help.create.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.tags.create( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const update: ApiEndpointSpec = { + id: 'tags.update', + commandPath: ['tags', 'update'], + describe: help.update.describe, + bodyMode: 'json', + pathParams: [projectCodeParam, tagIdParam], + fieldOptions: [ + { + name: 'title', + type: 'string', + describe: help.title, + schema: UpdateTagRequestSchema.shape.title, + }, + ], + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.title !== undefined + ? true + : 'Either --body, --body-file, or --title is required', + epilog: help.update.epilog, + examples: help.update.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.tags.update( + pathParams['project-code'], + pathParams['tag-id'], + body as Parameters[2] + ) + ) + }, +} + +const rename: ApiEndpointSpec = { + id: 'tags.rename', + commandPath: ['tags', 'rename'], + describe: help.rename.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'tags', + type: 'string', + describe: help.tags, + schema: RenameTagsRequestSchema.shape.tags, + jsonParse: true, + }, + ], + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.tags !== undefined + ? true + : 'Either --body, --body-file, or --tags is required', + epilog: help.rename.epilog, + examples: help.rename.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.tags.rename( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +const remove: ApiEndpointSpec = { + id: 'tags.delete', + commandPath: ['tags', 'delete'], + describe: help.delete.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { name: 'tag-ids', type: 'string', describe: help['tag-ids'], schema: intListSchema }, + ], + transformFields: (fields) => ({ tagIds: splitIntList(fields['tag-ids']) }), + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv['tag-ids'] !== undefined + ? true + : 'Either --body, --body-file, or --tag-ids is required', + epilog: help.delete.epilog, + examples: help.delete.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.tags.delete( + pathParams['project-code'], + body as Parameters[1] + ) + ) }, } -export const tagSpecs: ApiEndpointSpec[] = [list] +export const tagSpecs: ApiEndpointSpec[] = [list, create, update, rename, remove] diff --git a/src/commands/api/manifests/test-cases.ts b/src/commands/api/manifests/test-cases.ts index 3ddf9f4..670f9c3 100644 --- a/src/commands/api/manifests/test-cases.ts +++ b/src/commands/api/manifests/test-cases.ts @@ -3,23 +3,32 @@ import { CreateTCaseRequestSchema, UpdateTCaseRequestSchema, StepsArraySchema, + MoveTCaseItemsSchema, + RequirementRefsSchema, customFieldsSchema, parameterValueSchema, parameterValueWithIdSchema, type ListTCasesRequest, type CountTCasesRequest, + type CopyTCasesRequest, + type DeleteTCasesRequest, + type ListTCaseResultsRequest, + type MoveTCasesRequest, + type UpdateTCasesPriorityRequest, + type UpdateTCasesRequirementsRequest, + type UpdateTCasesTagsRequest, } from '../../../api/tcases' -import { - offsetParam, - pageParam, - limitParam, - sortFieldParam, - sortOrderParam, - resourceIdSchema, -} from '../../../api/schemas' +import { resourceIdSchema } from '../../../api/schemas' import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' -import { commonHelp, projectCodeParam } from './utils' -import type { ApiEndpointSpec } from '../types' +import { + commonHelp, + idListSchema, + intIdParam, + paginationOptions, + projectCodeParam, + splitList, +} from './utils' +import type { ApiEndpointSpec, ApiFieldSpec, ApiPathParamSpec } from '../types' // CreateTCaseRequestSchema stays a ZodObject despite its .superRefine() refinement, // so its field definitions are available directly via .shape. @@ -37,17 +46,45 @@ const help = { 'is-draft': 'Whether the test case is a draft.', draft: 'Filter by draft status (true or false).', steps: - 'JSON array of step objects.\nExample: \'[{"description": "Click login", "expected": "Page loads"}]\'', + 'JSON array of step objects, each with "description", "expected" and optional test "data" (or a "sharedStepId"). On update it replaces the current steps, so send each step\'s data to keep it.\nExample: \'[{"description": "Click login", "expected": "Page loads"}]\'', 'custom-fields': 'JSON object mapping custom field keys to values.\nExample: \'{"field1": {"isDefault": false, "value": "some value"}}\'', 'parameter-values': 'JSON array of parameter value sets.\nExample: \'[{"values": {"browser": "Chrome"}}]\'', + pos: 'Position in the folder, 0-based.', + requirements: + 'JSON array of requirements, each with "text" and "url". On update it replaces the current list.\nExample: \'[{"text": "REQ-1", "url": "https://example.com/req/1"}]\'', + links: + 'JSON array of links, each with "text" and "url". On update it replaces the current list.\nExample: \'[{"text": "Spec", "url": "https://example.com/spec"}]\'', + files: + 'JSON array of files to attach, each with "id" and "url" from `files upload` plus "fileName", "mimeType" and "size". On update it replaces the current list.\nExample: \'[{"id": "1CfK...", "url": "https://...", "fileName": "log.txt", "mimeType": "text/plain", "size": 120}]\'', + 'title-suffix-params': + 'Comma-separated template parameters whose values are appended to the titles of its filled test cases (e.g., "browser,device"). Template test cases only.', folders: 'Comma-separated folder IDs to filter by (e.g., "1,2,3").', tagsFilter: 'Comma-separated tag IDs to filter by (e.g., "1,2,3").', 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-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}]\'', + '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': + 'JSON array of requirements to link (missing ones are created).\nExample: \'[{"text": "REQ-1", "url": "https://example.com/req/1"}]\'', + 'remove-requirements': + 'JSON array of requirements to unlink, matched by text and url. One also in --add is kept.', + include: 'Comma-separated related data to include.', + 'created-after': 'Only results created after this time (RFC 3339, e.g., "2026-01-01T00:00:00Z").', + '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.', - epilog: apiDocsEpilog('tcases', 'list-project-test-cases'), + epilog: apiDocsEpilog('tcases', 'list-test-cases'), examples: [ { usage: '$0 api test-cases list --project-code PRJ', @@ -107,8 +144,138 @@ const help = { }, ], }, + delete: { + describe: + 'Delete test cases. They go to the trash as one entry, which the response describes; its id restores them.', + epilog: apiDocsEpilog('tcases', 'delete-test-cases'), + examples: [ + { + usage: '$0 api test-cases delete --project-code PRJ --tcase-ids 12,13', + description: 'Delete two test cases by sequence number', + }, + ], + }, + copy: { + describe: 'Copy test cases into a folder, optionally from another project.', + epilog: apiDocsEpilog('tcases', 'copy-test-cases'), + examples: [ + { + usage: '$0 api test-cases copy --project-code PRJ --tcase-ids 12,13 --target-folder-id 5', + description: 'Copy test cases within a project', + }, + { + usage: + '$0 api test-cases copy --project-code PRJ --tcase-ids 4 --target-folder-id 5 --source-project-id OLD', + description: 'Copy a test case from another project', + }, + ], + }, + move: { + describe: + 'Move test cases to a folder, or reorder them within their folder. Moves are not versioned.', + epilog: apiDocsEpilog('tcases', 'move-test-cases'), + examples: [ + { + usage: + '$0 api test-cases move --project-code PRJ --new-parent-id 5 --items \'[{"id": "12", "pos": 0}, {"id": "13", "pos": 1}]\'', + description: 'Move two test cases to the top of a folder', + }, + ], + }, + updatePriority: { + describe: 'Set the priority of several test cases. Each changed test case gets a new version.', + epilog: apiDocsEpilog('tcases', 'update-test-case-priority'), + examples: [ + { + usage: + '$0 api test-cases update-priority --project-code PRJ --tcase-ids 12,13 --priority high', + description: 'Raise the priority of two test cases', + }, + ], + }, + updateTags: { + describe: + 'Add tags to and remove tags from several test cases, keeping their other tags. Each changed test case gets a new version.', + epilog: apiDocsEpilog('tcases', 'update-test-case-tags'), + examples: [ + { + usage: + '$0 api test-cases update-tags --project-code PRJ --tcase-ids 12,13 --add smoke --remove flaky', + description: 'Tag two test cases as smoke and drop the flaky tag', + }, + ], + }, + updateRequirements: { + describe: + 'Link requirements to and unlink them from several test cases, keeping their other requirements. Each changed test case gets a new version.', + epilog: apiDocsEpilog('tcases', 'update-test-case-requirements'), + examples: [ + { + usage: + '$0 api test-cases update-requirements --project-code PRJ --tcase-ids 12 --add \'[{"text": "REQ-1"}]\'', + description: 'Link a requirement to a test case', + }, + ], + }, + getVersion: { + describe: 'Get an earlier version of a test case.', + epilog: apiDocsEpilog('tcases', 'get-test-case-version'), + examples: [ + { + usage: '$0 api test-cases get-version --project-code PRJ --tcase-id 12 --tcase-version 2', + description: 'Get version 2 of a test case', + }, + ], + }, + rollback: { + describe: + 'Roll a test case back to an earlier version, saved as a new version. The folder and position stay as they are.', + epilog: apiDocsEpilog('tcases', 'roll-back-test-case'), + examples: [ + { + usage: '$0 api test-cases rollback --project-code PRJ --tcase-id 12 --tcase-version 2', + description: 'Restore the content of version 2', + }, + ], + }, + resultsList: { + describe: 'List the results of a test case across all runs, most recent first.', + epilog: apiDocsEpilog('tcases', 'list-test-case-results'), + examples: [ + { + usage: '$0 api test-cases results list --project-code PRJ --tcase-id 12 --include run', + description: 'List the results of a test case with their runs', + }, + ], + }, } as const +const tcaseIdParam: ApiPathParamSpec = { + name: 'tcase-id', + type: 'string', + describe: help['tcase-id'], + schema: resourceIdSchema, +} + +const tcaseVersionParam = intIdParam('tcase-version', help['tcase-version']) + +const tcaseIdsField: ApiFieldSpec = { + name: 'tcase-ids', + type: 'string', + describe: help['tcase-ids'], + 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` + const list: ApiEndpointSpec = { id: 'test-cases.list', commandPath: ['test-cases', 'list'], @@ -116,9 +283,18 @@ const list: ApiEndpointSpec = { bodyMode: 'none', pathParams: [projectCodeParam], queryOptions: [ - { name: 'offset', type: 'number', describe: help.offset, schema: offsetParam }, - { name: 'limit', type: 'number', describe: help.limit, schema: limitParam }, - { name: 'page', type: 'number', describe: help.page, schema: pageParam }, + ...paginationOptions([ + 'id', + 'seq', + 'folder_id', + 'author_id', + 'pos', + 'title', + 'priority', + 'created_at', + '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 }, @@ -129,19 +305,22 @@ const list: ApiEndpointSpec = { describe: 'Comma-separated test case types to filter by (e.g., "standalone,template").', }, { name: 'draft', type: 'boolean', describe: help.draft }, - { name: 'sort-field', type: 'string', describe: help['sort-field'], schema: sortFieldParam }, - { - name: 'sort-order', - type: 'string', - describe: help['sort-order'], - schema: sortOrderParam, - choices: ['asc', 'desc'], - }, { name: 'include', type: 'string', - describe: - 'Comma-separated additional fields to include.\nValid values: steps, tags, requirements, customFields, parameterValues, folder, path, project.', + describe: 'Comma-separated additional fields to include.', + helpChoices: [ + 'precondition', + 'steps', + 'tags', + 'requirements', + 'customFields', + 'filterableCustomFields', + 'parameterValues', + 'folder', + 'path', + 'project', + ], }, ], transformQuery: (query) => ({ @@ -229,6 +408,11 @@ function transformTCaseFields(fields: Record): Record !!tag) } + if (fields['title-suffix-params'] !== undefined) { + result.filledTCaseTitleSuffixParams = splitList(fields['title-suffix-params']) + } + delete result.titleSuffixParams + return result } @@ -295,6 +479,29 @@ const tcaseCreate: ApiEndpointSpec = { schema: z.array(parameterValueSchema), jsonParse: true, }, + { name: 'title-suffix-params', type: 'string', describe: help['title-suffix-params'] }, + { name: 'pos', type: 'number', describe: help.pos, schema: CreateTCaseShape.pos.unwrap() }, + { + name: 'requirements', + type: 'string', + describe: help.requirements, + schema: CreateTCaseShape.requirements.unwrap(), + jsonParse: true, + }, + { + name: 'links', + type: 'string', + describe: help.links, + schema: CreateTCaseShape.links.unwrap(), + jsonParse: true, + }, + { + name: 'files', + type: 'string', + describe: help.files, + schema: CreateTCaseShape.files.unwrap(), + jsonParse: true, + }, ], transformFields: transformTCaseFields, check: (argv) => { @@ -374,6 +581,28 @@ const tcaseUpdate: ApiEndpointSpec = { schema: z.array(parameterValueWithIdSchema), jsonParse: true, }, + { name: 'title-suffix-params', type: 'string', describe: help['title-suffix-params'] }, + { + name: 'requirements', + type: 'string', + describe: help.requirements, + schema: UpdateTCaseRequestSchema.shape.requirements.unwrap(), + jsonParse: true, + }, + { + name: 'links', + type: 'string', + describe: help.links, + schema: UpdateTCaseRequestSchema.shape.links.unwrap(), + jsonParse: true, + }, + { + name: 'files', + type: 'string', + describe: help.files, + schema: UpdateTCaseRequestSchema.shape.files.unwrap(), + jsonParse: true, + }, ], transformFields: transformTCaseFields, check: (argv) => { @@ -387,6 +616,10 @@ const tcaseUpdate: ApiEndpointSpec = { 'steps', 'custom-fields', 'parameter-values', + 'title-suffix-params', + 'requirements', + 'links', + 'files', ] const hasField = updateFields.some((f) => argv[f] !== undefined) if (argv.body === undefined && argv['body-file'] === undefined && !hasField) { @@ -408,4 +641,280 @@ const tcaseUpdate: ApiEndpointSpec = { }, } -export const testCaseSpecs: ApiEndpointSpec[] = [list, get, count, tcaseCreate, tcaseUpdate] +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 tcaseMove: ApiEndpointSpec = { + id: 'test-cases.move', + commandPath: ['test-cases', 'move'], + describe: help.move.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + { + name: 'new-parent-id', + type: 'number', + describe: help['new-parent-id'], + schema: z.number().int().positive('must be a positive integer'), + }, + { + name: 'items', + type: 'string', + describe: help.items, + schema: MoveTCaseItemsSchema, + jsonParse: true, + }, + ], + check: requireFieldsOrBody('new-parent-id', 'items'), + epilog: help.move.epilog, + examples: help.move.examples, + execute: async (api, { pathParams, body }) => { + printJson(await api.testCases.move(pathParams['project-code'], body as MoveTCasesRequest)) + }, +} + +const tcaseUpdatePriority: ApiEndpointSpec = { + id: 'test-cases.update-priority', + commandPath: ['test-cases', 'update-priority'], + describe: help.updatePriority.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + tcaseIdsField, + { + name: 'priority', + type: 'string', + describe: help.priority, + choices: ['low', 'medium', 'high'] as const, + }, + ], + transformFields: (fields) => ({ + ...kebabToCamelCaseKeys(fields), + tcaseIds: splitList(fields['tcase-ids']), + }), + check: requireFieldsOrBody('tcase-ids', 'priority'), + epilog: help.updatePriority.epilog, + examples: help.updatePriority.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testCases.updatePriority( + pathParams['project-code'], + body as UpdateTCasesPriorityRequest + ) + ) + }, +} + +/** 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'], + describe: help.updateTags.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + tcaseIdsField, + { name: 'add', type: 'string', describe: help['add-tags'] }, + { name: 'remove', type: 'string', describe: help['remove-tags'] }, + ], + transformFields: (fields) => ({ + tcaseIds: splitList(fields['tcase-ids']), + add: splitList(fields.add), + remove: splitList(fields.remove), + }), + check: requireTCaseIdsAndChange, + epilog: help.updateTags.epilog, + examples: help.updateTags.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testCases.updateTags(pathParams['project-code'], body as UpdateTCasesTagsRequest) + ) + }, +} + +const tcaseUpdateRequirements: ApiEndpointSpec = { + id: 'test-cases.update-requirements', + commandPath: ['test-cases', 'update-requirements'], + describe: help.updateRequirements.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [ + tcaseIdsField, + { + name: 'add', + type: 'string', + describe: help['add-requirements'], + schema: RequirementRefsSchema, + jsonParse: true, + }, + { + name: 'remove', + type: 'string', + describe: help['remove-requirements'], + schema: RequirementRefsSchema, + jsonParse: true, + }, + ], + transformFields: (fields) => ({ + tcaseIds: splitList(fields['tcase-ids']), + add: fields.add, + remove: fields.remove, + }), + check: requireTCaseIdsAndChange, + epilog: help.updateRequirements.epilog, + examples: help.updateRequirements.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.testCases.updateRequirements( + pathParams['project-code'], + body as UpdateTCasesRequirementsRequest + ) + ) + }, +} + +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 tcaseResultsList: ApiEndpointSpec = { + id: 'test-cases.results.list', + commandPath: ['test-cases', 'results', 'list'], + describe: help.resultsList.describe, + bodyMode: 'none', + pathParams: [projectCodeParam, tcaseIdParam], + queryOptions: [ + ...paginationOptions(['created_at']), + { + name: 'include', + type: 'string', + describe: help.include, + helpChoices: ['run', 'links', 'author'], + }, + { name: 'created-after', type: 'string', describe: help['created-after'] }, + { name: 'exclude-status', type: 'string', describe: help['exclude-status'] }, + { name: 'internal', type: 'boolean', describe: help.internal }, + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + include: splitList(query.include), + excludeStatus: splitList(query['exclude-status']), + }), + epilog: help.resultsList.epilog, + examples: help.resultsList.examples, + execute: async (api, { pathParams, query }) => { + printJson( + await api.testCases.listResults( + pathParams['project-code'], + pathParams['tcase-id'], + query as ListTCaseResultsRequest + ) + ) + }, +} + +export const testCaseSpecs: ApiEndpointSpec[] = [ + list, + get, + count, + tcaseCreate, + tcaseUpdate, + tcaseDelete, + tcaseCopy, + tcaseMove, + tcaseUpdatePriority, + tcaseUpdateTags, + tcaseUpdateRequirements, + tcaseGetVersion, + tcaseRollback, + tcaseResultsList, +] diff --git a/src/commands/api/manifests/trash.ts b/src/commands/api/manifests/trash.ts new file mode 100644 index 0000000..e38d0f8 --- /dev/null +++ b/src/commands/api/manifests/trash.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' +import { trashResourceTypes, type ListTrashRequest } from '../../../api/trash' +import { printJson, apiDocsEpilog, kebabToCamelCaseKeys } from '../utils' +import { commonHelp, idListSchema, paginationOptions, projectCodeParam, splitList } from './utils' +import type { ApiEndpointSpec } from '../types' + +const help = { + ...commonHelp, + 'resource-types': `Comma-separated entry types to list: ${trashResourceTypes.join(', ')}. test_case covers deleted folders and test cases.`, + ids: 'Comma-separated trash entry IDs to restore (1 to 100). They are restored together: if one cannot be, none is.', + list: { + describe: 'List trash entries in a project, most recent first.', + epilog: apiDocsEpilog('trash', 'list-trash'), + examples: [ + { usage: '$0 api trash list --project-code PRJ', description: 'List trash entries' }, + { + usage: '$0 api trash list --project-code PRJ --resource-types test_case', + description: 'List deleted folders and test cases', + }, + ], + }, + restore: { + describe: 'Restore trash entries.', + epilog: apiDocsEpilog('trash', 'restore-from-trash'), + examples: [ + { + usage: '$0 api trash restore --project-code PRJ --ids 1CJo8oDjj_vkGo2rQAdzz4F', + description: 'Restore a trash entry', + }, + ], + }, +} as const + +const resourceTypesSchema = z.string().refine( + (value) => + value + .split(',') + .map((item) => item.trim()) + .every((item) => (trashResourceTypes as readonly string[]).includes(item)), + `must be comma-separated values of ${trashResourceTypes.join(', ')}` +) + +const list: ApiEndpointSpec = { + id: 'trash.list', + commandPath: ['trash', 'list'], + describe: help.list.describe, + bodyMode: 'none', + pathParams: [projectCodeParam], + queryOptions: [ + { + name: 'resource-types', + type: 'string', + describe: help['resource-types'], + schema: resourceTypesSchema, + }, + ...paginationOptions(['created_at']), + ], + transformQuery: (query) => ({ + ...kebabToCamelCaseKeys(query), + resourceTypes: splitList(query['resource-types']), + }), + epilog: help.list.epilog, + examples: help.list.examples, + execute: async (api, { pathParams, query }) => { + printJson(await api.trash.list(pathParams['project-code'], query as ListTrashRequest)) + }, +} + +const restore: ApiEndpointSpec = { + id: 'trash.restore', + commandPath: ['trash', 'restore'], + describe: help.restore.describe, + bodyMode: 'json', + pathParams: [projectCodeParam], + fieldOptions: [{ name: 'ids', type: 'string', describe: help.ids, schema: idListSchema }], + transformFields: (fields) => ({ deleteLogIds: splitList(fields.ids) }), + check: (argv) => + argv.body !== undefined || argv['body-file'] !== undefined || argv.ids !== undefined + ? true + : 'Either --body, --body-file, or --ids is required', + epilog: help.restore.epilog, + examples: help.restore.examples, + execute: async (api, { pathParams, body }) => { + printJson( + await api.trash.restore( + pathParams['project-code'], + body as Parameters[1] + ) + ) + }, +} + +export const trashSpecs: ApiEndpointSpec[] = [list, restore] diff --git a/src/commands/api/manifests/utils.ts b/src/commands/api/manifests/utils.ts index 046099d..9138e7d 100644 --- a/src/commands/api/manifests/utils.ts +++ b/src/commands/api/manifests/utils.ts @@ -1,17 +1,41 @@ import { z } from 'zod' -import type { ApiPathParamSpec } from '../types' +import { limitParam, offsetParam, sortFieldParam, sortOrderParam } from '../../../api/schemas' +import type { ApiPathParamSpec, ApiQueryOptionSpec } from '../types' /** * Common help text shared across multiple manifest files. */ export const commonHelp = { - 'sort-field': 'Field to sort by (e.g., "title", "createdAt", "updatedAt").', + 'sort-field': 'Field to sort by.', 'sort-order': 'Sort direction (asc or desc).', offset: 'Number of items to skip before returning results (defaults to 0).', - page: '[Deprecated] Use --offset instead. Page number for pagination (starts at 1). Ignored when --offset is set.', - limit: 'Maximum number of items to return (0-5000). Use 0 to return only the total count.', + limit: + 'Maximum number of items to return (0-1000, defaults to 100). Use 0 to return only the total count.', } as const +/** + * Pagination and sorting options of a list command. `sortFields` are the fields its endpoint + * sorts by. + */ +export const paginationOptions = (sortFields: readonly string[]): 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'], + schema: sortFieldParam, + helpChoices: sortFields, + }, + { + name: 'sort-order', + type: 'string', + describe: commonHelp['sort-order'], + schema: sortOrderParam, + choices: ['asc', 'desc'], + }, +] + /** * Reusable project-code path parameter with validation. */ @@ -21,3 +45,41 @@ export const projectCodeParam: ApiPathParamSpec = { describe: 'Project code identifying the QA Sphere project.', schema: z.string().regex(/^[a-zA-Z0-9]+$/, 'must contain only latin letters and digits'), } + +/** + * Positive-integer ID path parameter, e.g. a folder or tag ID. + */ +export const intIdParam = (name: string, describe: string): ApiPathParamSpec => ({ + name, + type: 'number', + describe, + schema: z.number().int().positive('must be a positive integer'), +}) + +/** + * Comma-separated lists are validated as text, then split by `splitList`/`splitIntList` + * in `transformFields` or `transformQuery`. + */ +export const intListSchema = z + .string() + .regex(/^\s*[1-9]\d*\s*(,\s*[1-9]\d*\s*)*$/, 'must be comma-separated positive integers') + +export const idListSchema = z + .string() + .regex( + /^\s*[a-zA-Z0-9_-]+\s*(,\s*[a-zA-Z0-9_-]+\s*)*$/, + 'must be comma-separated IDs (letters, digits, dashes and underscores)' + ) + +export const splitList = (value: unknown): unknown => + typeof value === 'string' + ? value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== '') + : value + +export const splitIntList = (value: unknown): unknown => { + const items = splitList(value) + return Array.isArray(items) ? items.map(Number) : items +} diff --git a/src/commands/api/types.ts b/src/commands/api/types.ts index c6ce539..dfb9f79 100644 --- a/src/commands/api/types.ts +++ b/src/commands/api/types.ts @@ -15,7 +15,13 @@ interface ApiOptionBase { type: ApiValueType describe: string schema?: ZodType + /** Allowed values, enforced by yargs: any other value is rejected before the request. */ choices?: readonly string[] + /** + * Allowed values, only listed in the help like `choices`. Not enforced, so the API decides + * what it accepts and newly supported values work without a CLI update. + */ + helpChoices?: readonly string[] yargsOption?: Record } diff --git a/src/commands/api/utils.ts b/src/commands/api/utils.ts index 18e0266..ff1ee3e 100644 --- a/src/commands/api/utils.ts +++ b/src/commands/api/utils.ts @@ -6,13 +6,12 @@ import { sortFieldParam, sortOrderParam, offsetParam, - pageParam, limitParam, type SortOrder, } from '../../api/schemas' import type { ApiFieldSpec, ApiPathParamSpec } from './types' -export { sortFieldParam, sortOrderParam, offsetParam, pageParam, limitParam, type SortOrder } +export { sortFieldParam, sortOrderParam, offsetParam, limitParam, type SortOrder } export function printJson(data: unknown): void { console.log(JSON.stringify(data, null, 2)) diff --git a/src/tests/api/ai-rules/list.spec.ts b/src/tests/api/ai-rules/list.spec.ts new file mode 100644 index 0000000..f2bb7eb --- /dev/null +++ b/src/tests/api/ai-rules/list.spec.ts @@ -0,0 +1,76 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { AIRule } from '../../../api/ai-rules' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'ai-rules', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + aiRules: [{ id: 'r1', name: 'Step wording', scope: 'project', type: 'tcase', enabled: true }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/ai-rule`, ({ 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 AI rules', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes type and enabled params', async ({ project }) => { + await runCommand('--project-code', project.code, '--types', 'system,all,tcase', '--enabled') + expect(lastSearchParams!.getAll('types')).toEqual(['system', 'all', 'tcase']) + expect(lastSearchParams!.get('enabled')).toBe('true') + }) + + test('lists disabled rules', async ({ project }) => { + await runCommand('--project-code', project.code, '--enabled', 'false') + expect(lastSearchParams!.get('enabled')).toBe('false') + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects an unknown rule type', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--types', 'tcase,bogus'), + /--types.*must be comma-separated values of/ + ) + }) +}) + +test('lists AI rules on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand<{ aiRules: AIRule[] }>( + '--project-code', + project.code, + '--types', + 'system' + ) + // A new project comes with its system rules + expect(result.aiRules.length).toBeGreaterThan(0) + expect(result.aiRules.every((rule) => rule.type === 'system')).toBe(true) +}) diff --git a/src/tests/api/configurations/create.spec.ts b/src/tests/api/configurations/create.spec.ts new file mode 100644 index 0000000..1a11a49 --- /dev/null +++ b/src/tests/api/configurations/create.spec.ts @@ -0,0 +1,94 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { Configuration } from '../../../api/configurations' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'configurations', 'create', ...args) + +describe('mocked', () => { + const created = { id: 'c1', title: 'Chrome', runCount: 0 } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/configuration`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(created, { status: 201 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a configuration', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--title', 'Chrome') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ title: 'Chrome' }) + expect(result).toEqual(created) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Chrome' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--title', 'Firefox'], + expectedRequest: { title: 'Firefox' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: 'x'.repeat(65) }, /at most 64 characters/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--title', 'Chrome']) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --title is required/ + ) + }) + + test('rejects an empty title', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', ''), + /--title.*must not be empty/ + ) + }) +}) + +test('creates a configuration on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'CLI configuration' + ) + expect(result.title).toBe('CLI configuration') + expect(result.runCount).toBe(0) +}) diff --git a/src/tests/api/configurations/delete.spec.ts b/src/tests/api/configurations/delete.spec.ts new file mode 100644 index 0000000..019b1b0 --- /dev/null +++ b/src/tests/api/configurations/delete.spec.ts @@ -0,0 +1,67 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Configuration } from '../../../api/configurations' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'configurations', 'delete', ...args) + +describe('mocked', () => { + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/configuration/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ message: 'Configuration deleted' }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a configuration', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--configuration-id', 'c1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('c1') + expect(result).toEqual({ message: 'Configuration deleted' }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--configuration-id', 'c1']) + testRejectsInvalidIdentifier(runCommand, 'configuration-id', 'resource', [ + '--project-code', + 'PRJ', + ]) +}) + +test('deletes a configuration on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'configurations', + 'create', + '--project-code', + project.code, + '--title', + 'CLI deleted configuration' + ) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--configuration-id', + created.id + ) + expect(result).toHaveProperty('message') +}) diff --git a/src/tests/api/configurations/get.spec.ts b/src/tests/api/configurations/get.spec.ts new file mode 100644 index 0000000..7524b22 --- /dev/null +++ b/src/tests/api/configurations/get.spec.ts @@ -0,0 +1,68 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Configuration } from '../../../api/configurations' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'configurations', 'get', ...args) + +describe('mocked', () => { + const mockConfiguration = { id: 'c1', title: 'Chrome', runCount: 2 } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/configuration/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockConfiguration) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a configuration', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--configuration-id', 'c1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('c1') + expect(result).toEqual(mockConfiguration) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--configuration-id', 'c1']) + testRejectsInvalidIdentifier(runCommand, 'configuration-id', 'resource', [ + '--project-code', + 'PRJ', + ]) +}) + +test('gets a configuration on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'configurations', + 'create', + '--project-code', + project.code, + '--title', + 'CLI fetched configuration' + ) + const result = await runCommand( + '--project-code', + project.code, + '--configuration-id', + created.id + ) + expect(result).toEqual(created) +}) diff --git a/src/tests/api/configurations/list.spec.ts b/src/tests/api/configurations/list.spec.ts new file mode 100644 index 0000000..9f91ca1 --- /dev/null +++ b/src/tests/api/configurations/list.spec.ts @@ -0,0 +1,102 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Configuration } from '../../../api/configurations' +import type { PaginatedResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'configurations', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + total: 1, + offset: 0, + limit: 100, + data: [{ id: 'c1', title: 'Chrome', runCount: 2 }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/configuration/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 configurations', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes include, pagination and sort params', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--include', + 'runCount', + '--offset', + '5', + '--sort-field', + 'created_at', + '--sort-order', + 'desc' + ) + expect(lastSearchParams!.getAll('include')).toEqual(['runCount']) + expect(lastSearchParams!.get('offset')).toBe('5') + expect(lastSearchParams!.get('sortField')).toBe('created_at') + expect(lastSearchParams!.get('sortOrder')).toBe('desc') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('lists configurations on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'configurations', + 'create', + '--project-code', + project.code, + '--title', + 'CLI listed configuration' + ) + const result = await runCommand>( + '--project-code', + project.code, + '--include', + 'runCount', + '--limit', + '100' + ) + expect(result.data.find((c) => c.id === created.id)?.runCount).toBe(0) +}) diff --git a/src/tests/api/configurations/update.spec.ts b/src/tests/api/configurations/update.spec.ts new file mode 100644 index 0000000..3725137 --- /dev/null +++ b/src/tests/api/configurations/update.spec.ts @@ -0,0 +1,117 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { Configuration } from '../../../api/configurations' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'configurations', 'update', ...args) + +describe('mocked', () => { + const updated = { id: 'c1', title: 'Firefox', runCount: 2 } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/configuration/:id`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(updated) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('renames a configuration', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--configuration-id', + 'c1', + '--title', + 'Firefox' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('c1') + expect(lastRequest).toEqual({ title: 'Firefox' }) + expect(result).toEqual(updated) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Firefox' } + const requiredArgs = ['--project-code', 'PRJ', '--configuration-id', 'c1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--title', 'Safari'], + expectedRequest: { title: 'Safari' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--configuration-id', + 'c1', + '--title', + 'X', + ]) + testRejectsInvalidIdentifier(runCommand, 'configuration-id', 'resource', [ + '--project-code', + 'PRJ', + '--title', + 'X', + ]) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--configuration-id', 'c1'), + /Either --body, --body-file, or --title is required/ + ) + }) +}) + +test('renames a configuration on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'configurations', + 'create', + '--project-code', + project.code, + '--title', + 'CLI configuration' + ) + const result = await runCommand( + '--project-code', + project.code, + '--configuration-id', + created.id, + '--title', + 'CLI renamed configuration' + ) + expect(result.id).toBe(created.id) + expect(result.title).toBe('CLI renamed configuration') +}) diff --git a/src/tests/api/folders/bulk-create.spec.ts b/src/tests/api/folders/bulk-create.spec.ts index 57c7dc3..6a1ed2a 100644 --- a/src/tests/api/folders/bulk-create.spec.ts +++ b/src/tests/api/folders/bulk-create.spec.ts @@ -19,7 +19,7 @@ describe('mocked', () => { let lastParams: PathParams = {} useMockServer( http.post( - `${baseURL}/api/public/v0/project/:projectCode/tcase/folder/bulk`, + `${baseURL}/api/public/v0/project/:projectCode/folder/upsert`, async ({ request, params }) => { expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) lastParams = params @@ -57,6 +57,16 @@ describe('mocked', () => { h.testBodyFile(validBody, validBody, requiredArgs) h.testInvalidJson(requiredArgs) h.testInvalidBody({ folders: [] }, /Must contain at least one folder/, requiredArgs) + h.testInvalidBody( + { folders: Array.from({ length: 1001 }, (_, i) => ({ path: [`Folder ${i}`] })) }, + /Must contain at most 1000 folders/, + requiredArgs + ) + h.testInvalidBody( + { folders: [{ path: ['Suite', 'x'.repeat(256)] }] }, + /folder names must be at most 255 characters/, + requiredArgs + ) } ) }) diff --git a/src/tests/api/folders/copy.spec.ts b/src/tests/api/folders/copy.spec.ts new file mode 100644 index 0000000..de875f8 --- /dev/null +++ b/src/tests/api/folders/copy.spec.ts @@ -0,0 +1,110 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { FolderIdsResponse } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'copy', ...args) + +describe('mocked', () => { + const mockResponse = { folders: [{ id: 20 }, { id: 21 }] } + + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/folder/copy`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('copies folders', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--folder-ids', + '4, 5', + '--target-folder-id', + '10', + '--source-project-id', + 'OLD' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ folderIds: [4, 5], targetFolderId: 10, sourceProjectId: 'OLD' }) + expect(result).toEqual(mockResponse) + }) + + test('copies a folder to the root of the same project', async ({ project }) => { + await runCommand('--project-code', project.code, '--folder-ids', '4') + expect(lastRequest).toEqual({ folderIds: [4] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { folderIds: [4], targetFolderId: 10 } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--folder-ids', '6,7'], + expectedRequest: { folderIds: [6, 7], targetFolderId: 10 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ folderIds: [] }, /Must contain at least one folder/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--folder-ids', '4']) + + test('requires folder-ids or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--target-folder-id', '10'), + /Either --body, --body-file, or --folder-ids is required/ + ) + }) + + test('rejects folder-ids that are not integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-ids', '4,abc'), + /--folder-ids.*comma-separated positive integers/ + ) + }) +}) + +test('copies a folder on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + const result = await runCommand( + '--project-code', + project.code, + '--folder-ids', + String(folderId) + ) + expect(result.folders).toHaveLength(1) + expect(result.folders[0].id).not.toBe(folderId) +}) diff --git a/src/tests/api/folders/create.spec.ts b/src/tests/api/folders/create.spec.ts new file mode 100644 index 0000000..194334d --- /dev/null +++ b/src/tests/api/folders/create.spec.ts @@ -0,0 +1,114 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { Folder } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'folders', 'create', ...args) + +describe('mocked', () => { + const mockFolder = { id: 7, parentId: 4, pos: 0, title: 'Login', comment: '', projectId: 'p1' } + + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/folder`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockFolder, { status: 201 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a folder', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'Login', + '--parent-id', + '4', + '--pos', + '0', + '--comment', + '

Sign-in tests

' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Login', + parentId: 4, + pos: 0, + comment: '

Sign-in tests

', + }) + expect(result).toEqual(mockFolder) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Login' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'Original', parentId: 4 }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden', parentId: 4 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /title must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--title', 'Login']) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --title is required/ + ) + }) + + test('rejects an empty title', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', ''), + /--title.*title must not be empty/ + ) + }) + + test('rejects a negative parent-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', 'Login', '--parent-id', '-1'), + /--parent-id.*non-negative/ + ) + }) +}) + +test('creates a folder on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await runCommand('--project-code', project.code, '--title', 'LiveCreate') + expect(typeof folder.id).toBe('number') + expect(folder.title).toBe('LiveCreate') + expect(folder.parentId).toBe(0) +}) diff --git a/src/tests/api/folders/delete.spec.ts b/src/tests/api/folders/delete.spec.ts new file mode 100644 index 0000000..59799bf --- /dev/null +++ b/src/tests/api/folders/delete.spec.ts @@ -0,0 +1,133 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TrashEntry } from '../../../api/trash' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'folders', 'delete', ...args) + +describe('mocked', () => { + const mockEntry = { + id: '1CJo8oDjj_vkGo2rQAdzz4F', + name: 'Folder Login (2 test cases)', + resourceType: 'test_case', + authorId: 1, + author: { id: 1, name: 'Jane' }, + items: [ + { + seq: 1, + folderIds: [4, 5], + tcaseIds: ['12', 'abc'], + runIds: null, + planIds: null, + milestoneIds: null, + }, + ], + createdAt: '2026-01-01T00:00:00Z', + } + + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/folder/delete`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockEntry) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('deletes folders and test cases', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--folder-ids', + '4,5', + '--tcase-ids', + '12,abc' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ folderIds: [4, 5], tcaseIds: ['12', 'abc'] }) + expect(result).toEqual(mockEntry) + }) + + test('deletes a single folder', async ({ project }) => { + await runCommand('--project-code', project.code, '--folder-ids', '4') + expect(lastRequest).toEqual({ folderIds: [4] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { folderIds: [4], tcaseIds: ['12'] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--folder-ids', '6'], + expectedRequest: { folderIds: [6], tcaseIds: ['12'] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ folderIds: [] }, /Must contain at least one folder/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--folder-ids', '4']) + + test('requires folder-ids or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12'), + /Either --body, --body-file, or --folder-ids is required/ + ) + }) + + test('rejects folder-ids that are not integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-ids', '4;5'), + /--folder-ids.*comma-separated positive integers/ + ) + }) + + test('rejects malformed tcase-ids', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-ids', '4', '--tcase-ids', 'a/b'), + /--tcase-ids.*comma-separated IDs/ + ) + }) +}) + +test('deletes a folder on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + const entry = await runCommand( + '--project-code', + project.code, + '--folder-ids', + String(folderId) + ) + expect(entry.resourceType).toBe('test_case') + expect(entry.items.flatMap((item) => item.folderIds ?? [])).toContain(folderId) +}) diff --git a/src/tests/api/folders/get.spec.ts b/src/tests/api/folders/get.spec.ts new file mode 100644 index 0000000..308e948 --- /dev/null +++ b/src/tests/api/folders/get.spec.ts @@ -0,0 +1,65 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Folder } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'get', ...args) + +describe('mocked', () => { + const mockFolder = { id: 4, parentId: 0, pos: 0, title: 'Login', comment: '', projectId: 'p1' } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/folder/:folderId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockFolder) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a folder', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--folder-id', '4') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.folderId).toBe('4') + expect(result).toEqual(mockFolder) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--folder-id', '1']) + + test('rejects a non-positive folder-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-id', '0'), + /--folder-id.*must be a positive integer/ + ) + }) +}) + +test('gets a folder on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + const folder = await runCommand( + '--project-code', + project.code, + '--folder-id', + String(folderId) + ) + expect(folder.id).toBe(folderId) +}) diff --git a/src/tests/api/folders/list.spec.ts b/src/tests/api/folders/list.spec.ts index 33959e1..6e3cedf 100644 --- a/src/tests/api/folders/list.spec.ts +++ b/src/tests/api/folders/list.spec.ts @@ -28,7 +28,7 @@ describe('mocked', () => { useMockServer( http.get( - `${baseURL}/api/public/v0/project/:projectCode/tcase/folders`, + `${baseURL}/api/public/v0/project/:projectCode/folder/paginated`, ({ request, params }) => { expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) lastParams = params @@ -50,11 +50,19 @@ describe('mocked', () => { }) test('passes offset and limit as query parameters', async ({ project }) => { - await runCommand('--project-code', project.code, '--offset', '10', '--limit', '5') + const result = await runCommand( + '--project-code', + project.code, + '--offset', + '10', + '--limit', + '5' + ) expect(lastParams.projectCode).toBe(project.code) expect(lastUrl).not.toBeNull() expect(lastUrl!.searchParams.get('offset')).toEqual('10') expect(lastUrl!.searchParams.get('limit')).toEqual('5') + expect(result).toEqual(mockResponse) }) test('passes sort-field as query parameter', async ({ project }) => { @@ -71,22 +79,27 @@ describe('mocked', () => { expect(lastUrl!.searchParams.get('sortField')).toEqual('created_at') expect(lastUrl!.searchParams.get('sortOrder')).toEqual('desc') }) + + test('leaves checking sort-field to the API', async ({ project }) => { + await runCommand('--project-code', project.code, '--sort-field', 'unlisted_field') + expect(lastUrl!.searchParams.get('sortField')).toEqual('unlisted_field') + }) }) describe('validation errors', () => { testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') - test('rejects invalid sort-field value', async () => { + test('rejects the removed --page option', async () => { await expectValidationError( - () => runCommand('--project-code', 'PRJ', '--sort-field', 'invalid_field'), - /sort-field|choices|invalid_field/i + () => runCommand('--project-code', 'PRJ', '--page', '1'), + /Unknown argument: page/ ) }) - test('rejects --page with non-integer', async () => { + test('rejects --limit above 1000', async () => { await expectValidationError( - () => runCommand('--project-code', 'PRJ', '--page', '1.5'), - /--page.*expected int/i + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ ) }) diff --git a/src/tests/api/folders/move.spec.ts b/src/tests/api/folders/move.spec.ts new file mode 100644 index 0000000..d80da3e --- /dev/null +++ b/src/tests/api/folders/move.spec.ts @@ -0,0 +1,145 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { FolderIdsResponse } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'move', ...args) + +describe('mocked', () => { + const mockResponse = { folders: [{ id: 4 }, { id: 5 }] } + + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/folder/location`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockResponse) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('moves folders', async ({ project }) => { + const items = [ + { id: 4, pos: 0 }, + { id: 5, pos: -1 }, + ] + const result = await runCommand( + '--project-code', + project.code, + '--new-parent-id', + '10', + '--items', + JSON.stringify(items) + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ newParentId: 10, items }) + expect(result).toEqual(mockResponse) + }) + + test('moves folders to the root', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--new-parent-id', + '0', + '--items', + JSON.stringify([{ id: 4, pos: 0 }]) + ) + expect(lastRequest).toEqual({ newParentId: 0, items: [{ id: 4, pos: 0 }] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { newParentId: 10, items: [{ id: 4, pos: 0 }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--new-parent-id', '0'], + expectedRequest: { newParentId: 0, items: [{ id: 4, pos: 0 }] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { newParentId: 10, items: [] }, + /Must contain at least one item/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--new-parent-id', + '10', + '--items', + JSON.stringify([{ id: 4, pos: 0 }]), + ]) + + test('requires items or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--new-parent-id', '10'), + /Either --body, --body-file, or --items is required/ + ) + }) + + test('requires new-parent-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--items', JSON.stringify([{ id: 4, pos: 0 }])), + /--new-parent-id/ + ) + }) + + test('rejects items that are not an array of positions', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--new-parent-id', + '10', + '--items', + JSON.stringify([{ id: 'x', pos: 0 }]) + ), + /--items\.0\.id/ + ) + }) +}) + +test('moves a folder on live server', { tags: ['live'] }, async ({ project }) => { + const parentId = (await createFolder(project.code)).ids[0][0] + const childId = (await createFolder(project.code)).ids[0][0] + const result = await runCommand( + '--project-code', + project.code, + '--new-parent-id', + String(parentId), + '--items', + JSON.stringify([{ id: childId, pos: 0 }]) + ) + expect(result.folders).toEqual([{ id: childId }]) +}) diff --git a/src/tests/api/folders/path.spec.ts b/src/tests/api/folders/path.spec.ts new file mode 100644 index 0000000..56cde62 --- /dev/null +++ b/src/tests/api/folders/path.spec.ts @@ -0,0 +1,78 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { Folder, FolderListResponse } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'path', ...args) + +describe('mocked', () => { + const mockResponse = { + folders: [ + { id: 1, parentId: 0, pos: 0, title: 'Suite', comment: '', projectId: 'p1' }, + { id: 4, parentId: 1, pos: 0, title: 'Login', comment: '', projectId: 'p1' }, + ], + } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/folder/:folderId/path`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockResponse) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets the path of a folder', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--folder-id', '4') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.folderId).toBe('4') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--folder-id', '1']) + + test('rejects a non-positive folder-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-id', '-2'), + /--folder-id.*must be a positive integer/ + ) + }) +}) + +test('gets the path of a folder on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli<{ ids: number[][] }>( + 'api', + 'folders', + 'bulk-create', + '--project-code', + project.code, + '--folders', + JSON.stringify([{ path: ['PathParent', 'PathChild'] }]) + ) + const [parentId, childId] = created.ids[0] + const result = await runCommand>( + '--project-code', + project.code, + '--folder-id', + String(childId) + ) + expect(result.folders.map((f) => f.id)).toEqual([parentId, childId]) +}) diff --git a/src/tests/api/folders/tree.spec.ts b/src/tests/api/folders/tree.spec.ts new file mode 100644 index 0000000..96d7565 --- /dev/null +++ b/src/tests/api/folders/tree.spec.ts @@ -0,0 +1,87 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { FolderListResponse, FolderTreeNode } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'folders', 'tree', ...args) + +describe('mocked', () => { + const mockResponse = { + folders: [ + { + id: 1, + parentId: 0, + pos: 0, + title: 'Suite', + comment: '', + projectId: 'p1', + tcaseCount: 3, + children: [ + { + id: 4, + parentId: 1, + pos: 0, + title: 'Login', + comment: '', + projectId: 'p1', + tcaseCount: 3, + }, + ], + }, + ], + } + + let lastUrl: URL | null = null + let lastParams: PathParams = {} + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/folder/tree`, ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastUrl = new URL(request.url) + return HttpResponse.json(mockResponse) + }) + ) + + afterEach(() => { + lastUrl = null + lastParams = {} + }) + + test('gets the folder tree', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(lastUrl!.searchParams.has('include')).toBe(false) + expect(result).toEqual(mockResponse) + }) + + test('passes include as a query parameter', async ({ project }) => { + await runCommand('--project-code', project.code, '--include', 'tcaseCount') + expect(lastUrl!.searchParams.getAll('include')).toEqual(['tcaseCount']) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') +}) + +test('gets the folder tree on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + const result = await runCommand>( + '--project-code', + project.code, + '--include', + 'tcaseCount' + ) + const folder = result.folders.find((f) => f.id === folderId) + expect(folder).toBeDefined() + expect(folder!.tcaseCount).toBe(0) +}) diff --git a/src/tests/api/folders/update.spec.ts b/src/tests/api/folders/update.spec.ts new file mode 100644 index 0000000..5405d86 --- /dev/null +++ b/src/tests/api/folders/update.spec.ts @@ -0,0 +1,117 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { Folder } from '../../../api/folders' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'folders', 'update', ...args) + +describe('mocked', () => { + const mockFolder = { id: 4, parentId: 0, pos: 0, title: 'Sign in', comment: '', projectId: 'p1' } + + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/folder/:folderId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(mockFolder) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('renames a folder', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--folder-id', + '4', + '--title', + 'Sign in' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.folderId).toBe('4') + expect(lastRequest).toEqual({ title: 'Sign in' }) + expect(result).toEqual(mockFolder) + }) + + test('clears the comment with an empty --comment', async ({ project }) => { + await runCommand('--project-code', project.code, '--folder-id', '4', '--comment', '') + expect(lastRequest).toEqual({ comment: '' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Sign in', comment: '

Auth

' } + const requiredArgs = ['--project-code', 'PRJ', '--folder-id', '4'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'Original' }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({}, /title or comment is required/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--folder-id', + '4', + '--title', + 'Sign in', + ]) + + test('rejects a non-positive folder-id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-id', '0', '--title', 'Sign in'), + /--folder-id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--folder-id', '4'), + /At least one field to update is required/ + ) + }) +}) + +test('renames a folder on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + const folder = await runCommand( + '--project-code', + project.code, + '--folder-id', + String(folderId), + '--title', + 'LiveRenamed' + ) + expect(folder.id).toBe(folderId) + expect(folder.title).toBe('LiveRenamed') +}) diff --git a/src/tests/api/milestones/list.spec.ts b/src/tests/api/milestones/list.spec.ts index af22da8..6da6943 100644 --- a/src/tests/api/milestones/list.spec.ts +++ b/src/tests/api/milestones/list.spec.ts @@ -1,6 +1,7 @@ import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' import type { Milestone } from '../../../api/milestones' +import type { PaginatedResponse } from '../../../api/schemas' import { test, baseURL, @@ -14,26 +15,37 @@ const runCommand = (...args: string[]) => runCli('api', 'milestones', 'list', ...args) describe('mocked', () => { - const mockMilestones = [{ id: 1, title: 'v1.0', archived: false }] + const mockMilestones = [{ id: 1, title: 'v1.0', archivedAt: null }] let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/milestone`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - return HttpResponse.json({ milestones: mockMilestones }) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/milestone/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json({ total: 1, offset: 0, limit: 100, data: mockMilestones }) + } + ) ) afterEach(() => { lastParams = {} + lastSearchParams = null }) test('lists milestones in a project', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockMilestones) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockMilestones }) + }) + + test('passes the archived filter', async ({ project }) => { + await runCommand('--project-code', project.code, '--archived') + expect(lastSearchParams?.get('archived')).toBe('true') }) }) @@ -51,12 +63,12 @@ test('creates and lists milestones on live server', { tags: ['live'] }, async ({ '--title', 'v1.0' ) - const list = await runCli( + const list = await runCli>( 'api', 'milestones', 'list', '--project-code', project.code ) - expect(list.some((m) => m.id === created.id)).toBe(true) + expect(list.data.find((m) => m.id === created.id)?.archivedAt).toBeNull() }) diff --git a/src/tests/api/projects/list.spec.ts b/src/tests/api/projects/list.spec.ts index bf20b2a..fe35400 100644 --- a/src/tests/api/projects/list.spec.ts +++ b/src/tests/api/projects/list.spec.ts @@ -1,7 +1,8 @@ import { HttpResponse, http } from 'msw' -import { describe, expect } from 'vitest' +import { afterEach, describe, expect } from 'vitest' import type { Project } from '../../../api/projects' -import { test, baseURL, token, useMockServer, runCli } from '../test-helper' +import type { PaginatedResponse } from '../../../api/schemas' +import { test, baseURL, token, useMockServer, runCli, expectValidationError } from '../test-helper' const runCommand = (...args: string[]) => runCli('api', 'projects', 'list', ...args) @@ -10,24 +11,54 @@ describe('mocked', () => { { id: 'p1', code: 'PRJ', title: 'Project 1' }, { id: 'p2', code: 'TST', title: 'Project 2' }, ] + const mockResponse = { total: 2, offset: 0, limit: 100, data: mockProjects } + + let lastSearchParams: URLSearchParams | null = null useMockServer( - http.get(`${baseURL}/api/public/v0/project`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/paginated`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - return HttpResponse.json({ projects: mockProjects }) + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) }) ) - test('lists all projects', async () => { + afterEach(() => { + lastSearchParams = null + }) + + test('lists projects', async () => { const result = await runCommand() - expect(result).toEqual(mockProjects) + expect(lastSearchParams?.toString()).toBe('') + expect(result).toEqual(mockResponse) + }) + + test('passes pagination and sort params', async () => { + await runCommand('--limit', '20', '--sort-field', 'updated_at', '--sort-order', 'desc') + expect(lastSearchParams?.has('offset')).toBe(false) + expect(lastSearchParams?.get('limit')).toBe('20') + expect(lastSearchParams?.get('sortField')).toBe('updated_at') + expect(lastSearchParams?.get('sortOrder')).toBe('desc') + }) +}) + +describe('validation errors', () => { + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) + + test('rejects the removed --page option', async () => { + await expectValidationError(() => runCommand('--page', '1'), /Unknown argument: page/) }) }) test('lists projects on live server', { tags: ['live'] }, async () => { - const result = await runCommand() - expect(Array.isArray(result)).toBe(true) - for (const p of result as Project[]) { + const result = await runCommand>() + expect(result.data.length).toBe(Math.min(result.total, 100)) + for (const p of result.data) { expect(p).toHaveProperty('id') expect(p).toHaveProperty('code') expect(p).toHaveProperty('title') diff --git a/src/tests/api/projects/update.spec.ts b/src/tests/api/projects/update.spec.ts new file mode 100644 index 0000000..c153a66 --- /dev/null +++ b/src/tests/api/projects/update.spec.ts @@ -0,0 +1,156 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { Project } from '../../../api/projects' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'projects', 'update', ...args) + +describe('mocked', () => { + const updated = { id: 'p1', code: 'PRJ', title: 'Checkout', teamMembers: [] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch(`${baseURL}/api/public/v0/project/:projectCode`, async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(updated) + }) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('updates the title and overview', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'Checkout', + '--overview-title', + 'Overview', + '--overview-description', + '

About

' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + title: 'Checkout', + overviewTitle: 'Overview', + overviewDescription: '

About

', + }) + expect(result).toEqual(updated) + }) + + test('archives and unarchives', async ({ project }) => { + await runCommand('--project-code', project.code, '--archived') + expect(lastRequest).toEqual({ archived: true }) + await runCommand('--project-code', project.code, '--archived', 'false') + expect(lastRequest).toEqual({ archived: false }) + }) + + test('replaces the team, ordering members by the list', async ({ project }) => { + await runCommand( + '--project-code', + project.code, + '--team-members', + JSON.stringify([ + { userId: 1, role: 'QA lead' }, + { userId: 2, role: 'Tester', pos: 5 }, + ]) + ) + expect(lastRequest).toEqual({ + teamMembers: [ + { userId: 1, role: 'QA lead', pos: 0 }, + { userId: 2, role: 'Tester', pos: 5 }, + ], + }) + }) + + test('an empty links list clears them', async ({ project }) => { + await runCommand('--project-code', project.code, '--links', '[]') + expect(lastRequest).toEqual({ links: [] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Checkout' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'From body', overviewTitle: 'Overview' }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden', overviewTitle: 'Overview' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /title must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--title', 'X']) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /At least one field to update is required/ + ) + }) + + test('rejects a team member without a role', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--team-members', '[{"userId": 1, "role": ""}]'), + /--team-members.*role must not be empty/ + ) + }) + + test('rejects a team member with an invalid user ID', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--team-members', '[{"userId": 0, "role": "QA"}]'), + /--team-members.*userId must be a positive integer/ + ) + }) + + test('rejects a link without text or with a URL that is not http or https', async () => { + await expectValidationError( + () => + runCommand('--project-code', 'PRJ', '--links', '[{"text": "", "url": "https://a.com"}]'), + /--links.*text must not be empty/ + ) + await expectValidationError( + () => + runCommand('--project-code', 'PRJ', '--links', '[{"text": "Spec", "url": "ftp://a.com"}]'), + /--links.*must be an http or https URL/ + ) + }) +}) + +// Only the overview title changes, so cleanup can still find and delete the project +test('updates a project on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--overview-title', + 'CLI overview' + ) + expect(result.code).toBe(project.code) + expect(result.overviewTitle).toBe('CLI overview') + expect(Array.isArray(result.teamMembers)).toBe(true) +}) diff --git a/src/tests/api/projects/users-list.spec.ts b/src/tests/api/projects/users-list.spec.ts new file mode 100644 index 0000000..6f2c443 --- /dev/null +++ b/src/tests/api/projects/users-list.spec.ts @@ -0,0 +1,64 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { ProjectUser } from '../../../api/projects' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'projects', 'users', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + users: [{ id: 1, email: 'jane@example.com', name: 'Jane', avatar: null, role: 'owner' }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get(`${baseURL}/api/public/v0/project/:projectCode/users`, ({ 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 the users of a project', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(result).toEqual(mockResponse) + }) + + test('passes the role filter', async ({ project }) => { + await runCommand('--project-code', project.code, '--roles', 'user, test-runner') + expect(lastSearchParams!.getAll('roles')).toEqual(['user', 'test-runner']) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects an unknown role', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--roles', 'user,guest'), + /--roles.*must be comma-separated values of/ + ) + }) +}) + +test('lists project users on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand<{ users: ProjectUser[] }>('--project-code', project.code) + expect(result.users.length).toBeGreaterThan(0) +}) diff --git a/src/tests/api/requirements/create.spec.ts b/src/tests/api/requirements/create.spec.ts new file mode 100644 index 0000000..8fda19c --- /dev/null +++ b/src/tests/api/requirements/create.spec.ts @@ -0,0 +1,120 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Requirement } from '../../../api/requirements' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'requirements', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/requirement`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json( + { id: 'req1', ...(lastRequest as object), tcaseCount: 0 }, + { status: 201 } + ) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a requirement', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--text', + 'Login', + '--url', + 'https://example.com/spec/login' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ text: 'Login', url: 'https://example.com/spec/login' }) + expect(result).toEqual({ + id: 'req1', + text: 'Login', + url: 'https://example.com/spec/login', + tcaseCount: 0, + }) + }) + + test('links the requirement to an issue', async () => { + await runCommand('--project-code', 'PRJ', '--text', 'Login', '--integration-issue-id', 'PROJ-1') + expect(lastRequest).toEqual({ text: 'Login', integrationIssueId: 'PROJ-1' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { text: 'Login', url: 'https://example.com/spec/login' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { text: 'Original' }, + flags: ['--text', 'Overridden'], + expectedRequest: { text: 'Overridden' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ text: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--text', 'Login']) + + test('rejects a URL that is not http or https', async () => { + for (const url of ['not a url', 'ftp://example.com/req']) { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--text', 'Login', '--url', url), + /--url.*must be an http or https URL/ + ) + } + }) + + test('requires text or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --text is required/ + ) + }) +}) + +test('creates a requirement on live server', { tags: ['live'] }, async ({ project }) => { + const text = `CLI requirement ${randomBytes(4).toString('hex')}` + const created = await runCommand( + '--project-code', + project.code, + '--text', + text, + '--url', + 'https://example.com/spec' + ) + expect(created.text).toBe(text) + expect(created.url).toBe('https://example.com/spec') + expect(typeof created.id).toBe('string') +}) diff --git a/src/tests/api/requirements/delete.spec.ts b/src/tests/api/requirements/delete.spec.ts new file mode 100644 index 0000000..487fc1b --- /dev/null +++ b/src/tests/api/requirements/delete.spec.ts @@ -0,0 +1,71 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Requirement } from '../../../api/requirements' +import type { MessageResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'requirements', 'delete', ...args) + +describe('mocked', () => { + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/requirement/:requirementId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ message: 'Requirement deleted' }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a requirement', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--requirement-id', 'req1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.requirementId).toBe('req1') + expect(result).toEqual({ message: 'Requirement deleted' }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--requirement-id', 'req1']) + testRejectsInvalidIdentifier(runCommand, 'requirement-id', 'resource', ['--project-code', 'PRJ']) + + test('requires a requirement ID', async () => { + await expectValidationError(() => runCommand('--project-code', 'PRJ'), /requirement-id/) + }) +}) + +test('deletes a requirement on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'requirements', + 'create', + '--project-code', + project.code, + '--text', + `CLI requirement ${randomBytes(4).toString('hex')}` + ) + const result = await runCommand( + '--project-code', + project.code, + '--requirement-id', + created.id + ) + expect(result).toHaveProperty('message') +}) diff --git a/src/tests/api/requirements/get.spec.ts b/src/tests/api/requirements/get.spec.ts new file mode 100644 index 0000000..91b4ab6 --- /dev/null +++ b/src/tests/api/requirements/get.spec.ts @@ -0,0 +1,73 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Requirement } from '../../../api/requirements' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'requirements', 'get', ...args) + +describe('mocked', () => { + const mockRequirement = { + id: 'req1', + text: 'Login', + url: 'https://example.com/spec/login', + tcaseCount: 2, + } + + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/requirement/:requirementId`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockRequirement) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a requirement', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--requirement-id', 'req1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.requirementId).toBe('req1') + expect(result).toEqual(mockRequirement) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--requirement-id', 'req1']) + testRejectsInvalidIdentifier(runCommand, 'requirement-id', 'resource', ['--project-code', 'PRJ']) +}) + +test('gets a requirement on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'requirements', + 'create', + '--project-code', + project.code, + '--text', + `CLI requirement ${randomBytes(4).toString('hex')}` + ) + const fetched = await runCommand( + '--project-code', + project.code, + '--requirement-id', + created.id + ) + expect(fetched.id).toBe(created.id) + expect(fetched.tcaseCount).toBe(0) +}) diff --git a/src/tests/api/requirements/list.spec.ts b/src/tests/api/requirements/list.spec.ts index 18aa336..11fad6a 100644 --- a/src/tests/api/requirements/list.spec.ts +++ b/src/tests/api/requirements/list.spec.ts @@ -1,6 +1,7 @@ import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' import type { Requirement } from '../../../api/requirements' +import type { PaginatedResponse } from '../../../api/schemas' import { test, baseURL, @@ -19,11 +20,14 @@ describe('mocked', () => { let lastParams: PathParams = {} useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/requirement`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - return HttpResponse.json({ requirements: mockRequirements }) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/requirement/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ total: 1, offset: 0, limit: 100, data: mockRequirements }) + } + ) ) afterEach(() => { @@ -33,7 +37,7 @@ describe('mocked', () => { test('lists requirements', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockRequirements) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockRequirements }) }) }) @@ -43,18 +47,18 @@ describe('validation errors', () => { describe('live', { tags: ['live'] }, () => { test('lists requirements', async ({ project }) => { - const result = await runCli( + const result = await runCli>( 'api', 'requirements', 'list', '--project-code', project.code ) - expect(Array.isArray(result)).toBe(true) + expect(Array.isArray(result.data)).toBe(true) }) test('lists requirements with include', async ({ project }) => { - const result = await runCli( + const result = await runCli>( 'api', 'requirements', 'list', @@ -63,8 +67,8 @@ describe('live', { tags: ['live'] }, () => { '--include', 'tcaseCount' ) - expect(Array.isArray(result)).toBe(true) - for (const req of result) { + expect(Array.isArray(result.data)).toBe(true) + for (const req of result.data) { expect(req).toHaveProperty('tcaseCount') expect(typeof req.tcaseCount).toBe('number') } diff --git a/src/tests/api/requirements/update.spec.ts b/src/tests/api/requirements/update.spec.ts new file mode 100644 index 0000000..8586ef0 --- /dev/null +++ b/src/tests/api/requirements/update.spec.ts @@ -0,0 +1,134 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Requirement } from '../../../api/requirements' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'requirements', 'update', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/requirement/:requirementId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 'req1', text: 'Sign in', url: '', tcaseCount: 0 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('updates a requirement', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--requirement-id', + 'req1', + '--text', + 'Sign in' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.requirementId).toBe('req1') + expect(lastRequest).toEqual({ text: 'Sign in' }) + expect(result).toEqual({ id: 'req1', text: 'Sign in', url: '', tcaseCount: 0 }) + }) + + test('sends empty strings to clear the URL and the issue link', async () => { + await runCommand( + '--project-code', + 'PRJ', + '--requirement-id', + 'req1', + '--url', + '', + '--integration-issue-id', + '' + ) + expect(lastRequest).toEqual({ url: '', integrationIssueId: '' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { text: 'Sign in' } + const requiredArgs = ['--project-code', 'PRJ', '--requirement-id', 'req1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { text: 'Original', url: 'https://example.com' }, + flags: ['--text', 'Overridden'], + expectedRequest: { text: 'Overridden', url: 'https://example.com' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ text: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--requirement-id', + 'req1', + '--text', + 'Sign in', + ]) + testRejectsInvalidIdentifier(runCommand, 'requirement-id', 'resource', [ + '--project-code', + 'PRJ', + '--text', + 'Sign in', + ]) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--requirement-id', 'req1'), + /At least one field to update is required/ + ) + }) +}) + +test('updates a requirement on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'requirements', + 'create', + '--project-code', + project.code, + '--text', + `CLI requirement ${randomBytes(4).toString('hex')}`, + '--url', + 'https://example.com/spec' + ) + const text = `CLI requirement ${randomBytes(4).toString('hex')}` + const updated = await runCommand( + '--project-code', + project.code, + '--requirement-id', + created.id, + '--text', + text + ) + expect(updated.text).toBe(text) + expect(updated.url).toBe('https://example.com/spec') +}) diff --git a/src/tests/api/runs/list.spec.ts b/src/tests/api/runs/list.spec.ts index 435d80d..df39311 100644 --- a/src/tests/api/runs/list.spec.ts +++ b/src/tests/api/runs/list.spec.ts @@ -1,7 +1,9 @@ import type { Run } from '../../../api/runs' +import type { PaginatedResponse } from '../../../api/schemas' import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' import { + expectValidationError, test, baseURL, token, @@ -22,12 +24,15 @@ describe('mocked', () => { let lastSearchParams: URLSearchParams | null = null useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/run`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - lastSearchParams = new URL(request.url).searchParams - return HttpResponse.json({ runs: mockRuns }) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/run/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json({ total: 1, offset: 0, limit: 100, data: mockRuns }) + } + ) ) afterEach(() => { @@ -38,20 +43,42 @@ describe('mocked', () => { test('lists runs in a project', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockRuns) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockRuns }) }) test('passes filter params', async ({ project }) => { - await runCommand('--project-code', project.code, '--closed', '--limit', '10') + await runCommand( + '--project-code', + project.code, + '--closed', + '--milestone-ids', + '1,2', + '--include', + 'statusCounts, milestone', + '--limit', + '10', + '--sort-field', + 'last_activity_at' + ) expect(lastParams.projectCode).toBe(project.code) expect(lastSearchParams).not.toBeNull() expect(lastSearchParams!.get('closed')).toBe('true') + expect(lastSearchParams!.getAll('milestoneIds')).toEqual(['1', '2']) + expect(lastSearchParams!.getAll('include')).toEqual(['statusCounts', 'milestone']) expect(lastSearchParams!.get('limit')).toBe('10') + expect(lastSearchParams!.get('sortField')).toBe('last_activity_at') }) }) describe('validation errors', () => { testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) }) test('lists runs on live server', { tags: ['live'] }, async ({ project }) => { @@ -59,6 +86,14 @@ test('lists runs on live server', { tags: ['live'] }, async ({ project }) => { const folderId = folder.ids[0][0] const tcase = await createTCase(project.code, folderId) const run = await createRun(project.code, [tcase.id]) - const result = await runCli('api', 'runs', 'list', '--project-code', project.code) - expect(result.some((r) => r.id === run.id)).toBe(true) + const result = await runCli>( + 'api', + 'runs', + 'list', + '--project-code', + project.code, + '--include', + 'statusCounts' + ) + expect(result.data.find((r) => r.id === run.id)?.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 5c101e6..4326724 100644 --- a/src/tests/api/runs/tcases-list.spec.ts +++ b/src/tests/api/runs/tcases-list.spec.ts @@ -1,4 +1,5 @@ import type { RunTCase } from '../../../api/runs' +import type { PaginatedResponse } from '../../../api/schemas' import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' import { @@ -18,14 +19,14 @@ const runCommand = (...args: string[]) => describe('mocked', () => { const mockTCases = [{ id: 'tc1', title: 'Test', seq: 1 }] - const mockResponse = { tcases: mockTCases } + const mockResponse = { total: 1, offset: 0, limit: 100, data: mockTCases } let lastUrl: URL | null = null let lastParams: PathParams = {} useMockServer( http.get( - `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase`, + `${baseURL}/api/public/v0/project/:projectCode/run/:runId/tcase/paginated`, ({ request, params }) => { expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) lastParams = params @@ -44,15 +45,30 @@ describe('mocked', () => { 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(mockTCases) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockTCases }) }) - test('passes include param as query parameter', async ({ project }) => { - await runCommand('--project-code', project.code, '--run-id', '42', '--include', 'folder') + test('passes include and pagination params as query parameters', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--run-id', + '42', + '--include', + 'folder, steps', + '--offset', + '10', + '--sort-field', + 'status' + ) expect(lastParams.projectCode).toBe(project.code) expect(lastParams.runId).toBe('42') expect(lastUrl).not.toBeNull() - expect(lastUrl!.searchParams.get('include')).toEqual('folder') + expect(lastUrl!.searchParams.getAll('include')).toEqual(['folder', 'steps']) + expect(lastUrl!.searchParams.get('offset')).toEqual('10') + expect(lastUrl!.searchParams.has('limit')).toBe(false) + expect(lastUrl!.searchParams.get('sortField')).toEqual('status') + expect(result).toEqual(mockResponse) }) }) @@ -66,7 +82,7 @@ describe('live', { tags: ['live'] }, () => { const folderId = folder.ids[0][0] const tcase = await createTCase(project.code, folderId) const run = await createRun(project.code, [tcase.id]) - const result = await runCli( + const result = await runCli>( 'api', 'runs', 'test-cases', @@ -76,8 +92,8 @@ describe('live', { tags: ['live'] }, () => { '--run-id', String(run.id) ) - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(1) + expect(result.total).toBe(1) + expect(result.data.length).toBe(1) }) test('lists test cases in a run with include', async ({ project }) => { @@ -85,7 +101,7 @@ describe('live', { tags: ['live'] }, () => { const folderId = folder.ids[0][0] const tcase = await createTCase(project.code, folderId) const run = await createRun(project.code, [tcase.id]) - const result = await runCli( + const result = await runCli>( 'api', 'runs', 'test-cases', @@ -97,9 +113,8 @@ describe('live', { tags: ['live'] }, () => { '--include', 'folder' ) - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(1) - const rtcase = result[0] + expect(result.data.length).toBe(1) + const rtcase = result.data[0] expect(rtcase).toHaveProperty('folder') expect(rtcase.folder).toHaveProperty('id') expect(rtcase.folder).toHaveProperty('title') diff --git a/src/tests/api/shared-preconditions/create.spec.ts b/src/tests/api/shared-preconditions/create.spec.ts new file mode 100644 index 0000000..2de91d2 --- /dev/null +++ b/src/tests/api/shared-preconditions/create.spec.ts @@ -0,0 +1,110 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { SharedPrecondition } from '../../../api/shared-preconditions' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-preconditions', 'create', ...args) + +describe('mocked', () => { + const created = { id: 1, title: 'Logged in', text: '

Logged in

', tcaseCount: 0 } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(created, { status: 201 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a shared precondition', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'Logged in', + '--text', + '

Logged in

' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ title: 'Logged in', text: '

Logged in

' }) + expect(result).toEqual(created) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Logged in', text: '

Logged in

' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--text', '

Logged out

'], + expectedRequest: { ...validBody, text: '

Logged out

' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: 'Logged in', text: '' }, /text must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--title', + 'Logged in', + '--text', + 'x', + ]) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--text', 'x'), + /Either --body, --body-file, or --title is required/ + ) + }) + + test('rejects a title over 255 characters', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', 'x'.repeat(256), '--text', 'x'), + /--title.*must be at most 255 characters/ + ) + }) +}) + +test('creates a shared precondition on live server', { tags: ['live'] }, async ({ project }) => { + const title = `CLI precondition ${randomBytes(4).toString('hex')}` + const result = await runCommand( + '--project-code', + project.code, + '--title', + title, + '--text', + '

The user is logged in

' + ) + expect(result.title).toBe(title) + expect(result.tcaseCount).toBe(0) +}) diff --git a/src/tests/api/shared-preconditions/delete.spec.ts b/src/tests/api/shared-preconditions/delete.spec.ts new file mode 100644 index 0000000..fb2e68f --- /dev/null +++ b/src/tests/api/shared-preconditions/delete.spec.ts @@ -0,0 +1,74 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { SharedPrecondition } from '../../../api/shared-preconditions' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-preconditions', 'delete', ...args) + +describe('mocked', () => { + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ message: 'Precondition deleted' }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a shared precondition', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(result).toEqual({ message: 'Precondition deleted' }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1']) + + test('rejects a non-positive id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '0'), + /--id.*must be a positive integer/ + ) + }) +}) + +test('deletes a shared precondition on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'shared-preconditions', + 'create', + '--project-code', + project.code, + '--title', + `CLI precondition ${randomBytes(4).toString('hex')}`, + '--text', + '

Logged in

' + ) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--id', + String(created.id) + ) + expect(result).toHaveProperty('message') +}) diff --git a/src/tests/api/shared-preconditions/list.spec.ts b/src/tests/api/shared-preconditions/list.spec.ts index 53763f1..b0e3a50 100644 --- a/src/tests/api/shared-preconditions/list.spec.ts +++ b/src/tests/api/shared-preconditions/list.spec.ts @@ -1,5 +1,6 @@ import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' +import type { PaginatedResponse } from '../../../api/schemas' import type { SharedPrecondition } from '../../../api/shared-preconditions' import { test, @@ -20,11 +21,11 @@ describe('mocked', () => { useMockServer( http.get( - `${baseURL}/api/public/v0/project/:projectCode/shared-precondition`, + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition/paginated`, ({ request, params }) => { expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) lastParams = params - return HttpResponse.json(mockData) + return HttpResponse.json({ total: 1, offset: 0, limit: 100, data: mockData }) } ) ) @@ -36,7 +37,7 @@ describe('mocked', () => { test('lists shared preconditions', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockData) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockData }) }) }) @@ -46,15 +47,24 @@ describe('validation errors', () => { describe('live', { tags: ['live'] }, () => { test('lists shared preconditions', async ({ project }) => { - const result = await runCli( + const created = await runCli( + 'api', + 'shared-preconditions', + 'create', + '--project-code', + project.code, + '--title', + 'CLI listed precondition', + '--text', + '

Logged in

' + ) + const result = await runCli>( 'api', 'shared-preconditions', 'list', '--project-code', project.code ) - // Fresh project has no shared preconditions; API returns null for empty list - // Currently there's no way to create shared preconditions via the public API - expect(result === null || Array.isArray(result)).toBe(true) + expect(result.data.some((p) => p.id === created.id)).toBe(true) }) }) diff --git a/src/tests/api/shared-preconditions/update.spec.ts b/src/tests/api/shared-preconditions/update.spec.ts new file mode 100644 index 0000000..c88911a --- /dev/null +++ b/src/tests/api/shared-preconditions/update.spec.ts @@ -0,0 +1,116 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { SharedPrecondition } from '../../../api/shared-preconditions' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-preconditions', 'update', ...args) + +describe('mocked', () => { + const updated = { id: 1, title: 'Logged in', text: '

Logged out

', tcaseCount: 3 } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/shared-precondition/:id`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(updated) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('updates the text', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--id', + '1', + '--text', + '

Logged out

' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(lastRequest).toEqual({ text: '

Logged out

' }) + expect(result).toEqual(updated) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Logged in' } + const requiredArgs = ['--project-code', 'PRJ', '--id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'From body', text: 'x' }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden', text: 'x' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /title must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1', '--text', 'x']) + + test('rejects a non-positive id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '0', '--text', 'x'), + /--id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '1'), + /At least one field to update is required/ + ) + }) +}) + +test('updates a shared precondition on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'shared-preconditions', + 'create', + '--project-code', + project.code, + '--title', + `CLI precondition ${randomBytes(4).toString('hex')}`, + '--text', + '

Logged in

' + ) + const result = await runCommand( + '--project-code', + project.code, + '--id', + String(created.id), + '--text', + '

Logged out

' + ) + expect(result.id).toBe(created.id) + expect(result.text).toBe('

Logged out

') +}) diff --git a/src/tests/api/shared-steps/create.spec.ts b/src/tests/api/shared-steps/create.spec.ts new file mode 100644 index 0000000..9611c64 --- /dev/null +++ b/src/tests/api/shared-steps/create.spec.ts @@ -0,0 +1,120 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { SharedStep } from '../../../api/shared-steps' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-steps', 'create', ...args) + +describe('mocked', () => { + const created = { id: 1, title: 'Log in', subSteps: [{ id: 2, description: 'Open' }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/shared-step`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(created, { status: 201 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a shared step', async ({ project }) => { + const subSteps = [{ description: 'Open', expected: 'Opened' }] + const result = await runCommand( + '--project-code', + project.code, + '--title', + 'Log in', + '--sub-steps', + JSON.stringify(subSteps) + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ title: 'Log in', subSteps }) + expect(result).toEqual(created) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Log in', subSteps: [{ description: 'Open' }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--title', 'Log out'], + expectedRequest: { ...validBody, title: 'Log out' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { title: 'Log in', subSteps: [] }, + /must have at least one sub-step/, + requiredArgs + ) + h.testInvalidBody( + { title: 'Log in', subSteps: [{ description: ' ' }, {}] }, + /at least one sub-step must have a description, expected result or data/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--title', + 'Log in', + '--sub-steps', + '[{"description": "Open"}]', + ]) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --title is required/ + ) + }) + + test('rejects an empty title', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', '', '--sub-steps', '[{}]'), + /--title.*must not be empty/ + ) + }) +}) + +test('creates a shared step on live server', { tags: ['live'] }, async ({ project }) => { + const title = `CLI step ${randomBytes(4).toString('hex')}` + const result = await runCommand( + '--project-code', + project.code, + '--title', + title, + '--sub-steps', + JSON.stringify([{ description: 'Open the login page', expected: 'The form is shown' }]) + ) + expect(result.title).toBe(title) + expect(result.subSteps).toHaveLength(1) +}) diff --git a/src/tests/api/shared-steps/delete.spec.ts b/src/tests/api/shared-steps/delete.spec.ts new file mode 100644 index 0000000..b97f300 --- /dev/null +++ b/src/tests/api/shared-steps/delete.spec.ts @@ -0,0 +1,74 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { SharedStep } from '../../../api/shared-steps' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-steps', 'delete', ...args) + +describe('mocked', () => { + let lastParams: PathParams = {} + + useMockServer( + http.delete( + `${baseURL}/api/public/v0/project/:projectCode/shared-step/:id`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ message: 'Step deleted' }) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('deletes a shared step', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--id', '1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(result).toEqual({ message: 'Step deleted' }) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1']) + + test('rejects a non-positive id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '0'), + /--id.*must be a positive integer/ + ) + }) +}) + +test('deletes a shared step on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'shared-steps', + 'create', + '--project-code', + project.code, + '--title', + `CLI step ${randomBytes(4).toString('hex')}`, + '--sub-steps', + JSON.stringify([{ description: 'Open' }]) + ) + const result = await runCommand<{ message: string }>( + '--project-code', + project.code, + '--id', + String(created.id) + ) + expect(result).toHaveProperty('message') +}) diff --git a/src/tests/api/shared-steps/list.spec.ts b/src/tests/api/shared-steps/list.spec.ts index c032698..db63cfc 100644 --- a/src/tests/api/shared-steps/list.spec.ts +++ b/src/tests/api/shared-steps/list.spec.ts @@ -1,5 +1,6 @@ import { HttpResponse, http, type PathParams } from 'msw' import { afterEach, describe, expect } from 'vitest' +import type { PaginatedResponse } from '../../../api/schemas' import type { SharedStep } from '../../../api/shared-steps' import { test, @@ -19,11 +20,14 @@ describe('mocked', () => { let lastParams: PathParams = {} useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/shared-step`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - return HttpResponse.json({ sharedSteps: mockData }) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/shared-step/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json({ total: 1, offset: 0, limit: 100, data: mockData }) + } + ) ) afterEach(() => { @@ -33,7 +37,7 @@ describe('mocked', () => { test('lists shared steps', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockData) + expect(result).toEqual({ total: 1, offset: 0, limit: 100, data: mockData }) }) }) @@ -43,18 +47,18 @@ describe('validation errors', () => { describe('live', { tags: ['live'] }, () => { test('lists shared steps', async ({ project }) => { - const result = await runCli( + const result = await runCli>( 'api', 'shared-steps', 'list', '--project-code', project.code ) - expect(Array.isArray(result)).toBe(true) + expect(Array.isArray(result.data)).toBe(true) }) test('lists shared steps with include', async ({ project }) => { - const result = await runCli( + const result = await runCli>( 'api', 'shared-steps', 'list', @@ -63,8 +67,8 @@ describe('live', { tags: ['live'] }, () => { '--include', 'tcaseCount' ) - expect(Array.isArray(result)).toBe(true) - for (const step of result) { + expect(Array.isArray(result.data)).toBe(true) + for (const step of result.data) { expect(step).toHaveProperty('tcaseCount') expect(typeof step.tcaseCount).toBe('number') } diff --git a/src/tests/api/shared-steps/update.spec.ts b/src/tests/api/shared-steps/update.spec.ts new file mode 100644 index 0000000..886a84a --- /dev/null +++ b/src/tests/api/shared-steps/update.spec.ts @@ -0,0 +1,137 @@ +import { randomBytes } from 'node:crypto' +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { SharedStep } from '../../../api/shared-steps' +import { + test, + baseURL, + token, + useMockServer, + runCli, + expectValidationError, + testRejectsInvalidIdentifier, + testBodyInput, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'shared-steps', 'update', ...args) + +describe('mocked', () => { + const updated = { id: 1, title: 'Log in as admin', subSteps: [{ id: 2, description: 'Open' }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/shared-step/:id`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(updated) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('renames a shared step', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--id', + '1', + '--title', + 'Log in as admin' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.id).toBe('1') + expect(lastRequest).toEqual({ title: 'Log in as admin' }) + expect(result).toEqual(updated) + }) + + test('replaces the sub-steps, keeping those with an id', async ({ project }) => { + const subSteps = [{ id: 2, description: 'Open' }, { description: 'New step' }] + await runCommand( + '--project-code', + project.code, + '--id', + '1', + '--sub-steps', + JSON.stringify(subSteps) + ) + expect(lastRequest).toEqual({ subSteps }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'Log in as admin' } + const requiredArgs = ['--project-code', 'PRJ', '--id', '1'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'From body', subSteps: [{ id: 2, description: 'Open' }] }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden', subSteps: [{ id: 2, description: 'Open' }] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--id', '1', '--title', 'X']) + + test('rejects a non-positive id', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '0', '--title', 'X'), + /--id.*must be a positive integer/ + ) + }) + + test('requires a field to update', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '1'), + /At least one field to update is required/ + ) + }) + + test('rejects sub-steps that have no content', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--id', '1', '--sub-steps', '[{"id": 2}]'), + /--sub-steps.*at least one sub-step must have a description, expected result or data/ + ) + }) +}) + +test('updates a shared step on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'shared-steps', + 'create', + '--project-code', + project.code, + '--title', + `CLI step ${randomBytes(4).toString('hex')}`, + '--sub-steps', + JSON.stringify([{ description: 'Open' }]) + ) + const title = `CLI step ${randomBytes(4).toString('hex')}` + const result = await runCommand( + '--project-code', + project.code, + '--id', + String(created.id), + '--title', + title + ) + expect(result.id).toBe(created.id) + expect(result.title).toBe(title) +}) diff --git a/src/tests/api/tags/create.spec.ts b/src/tests/api/tags/create.spec.ts new file mode 100644 index 0000000..07b0669 --- /dev/null +++ b/src/tests/api/tags/create.spec.ts @@ -0,0 +1,86 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Tag } from '../../../api/tags' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'tags', 'create', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post(`${baseURL}/api/public/v0/project/:projectCode/tag`, async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 1, title: 'smoke' }, { status: 201 }) + }) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('creates a tag', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--title', 'smoke') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ title: 'smoke' }) + expect(result).toEqual({ id: 1, title: 'smoke' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'smoke' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { title: 'Original' }, + flags: ['--title', 'Overridden'], + expectedRequest: { title: 'Overridden' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--title', 'smoke']) + + test('rejects an empty title', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--title', ''), + /--title.*must not be empty/ + ) + }) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --title is required/ + ) + }) +}) + +test('creates a tag on live server', { tags: ['live'] }, async ({ project }) => { + const title = `cli-${randomBytes(4).toString('hex')}` + const created = await runCommand('--project-code', project.code, '--title', title) + expect(created.title).toBe(title) + expect(typeof created.id).toBe('number') +}) diff --git a/src/tests/api/tags/delete.spec.ts b/src/tests/api/tags/delete.spec.ts new file mode 100644 index 0000000..8c56782 --- /dev/null +++ b/src/tests/api/tags/delete.spec.ts @@ -0,0 +1,113 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Tag } from '../../../api/tags' +import type { MessageResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'tags', 'delete', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tag/delete`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ message: 'Tags deleted' }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('deletes tags', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--tag-ids', '1, 2') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tagIds: [1, 2] }) + expect(result).toEqual({ message: 'Tags deleted' }) + }) + + test('deletes a single tag', async () => { + await runCommand('--project-code', 'PRJ', '--tag-ids', '7') + expect(lastRequest).toEqual({ tagIds: [7] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tagIds: [1, 2] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { tagIds: [1] }, + flags: ['--tag-ids', '3'], + expectedRequest: { tagIds: [3] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ tagIds: [] }, /Must contain at least one tag ID/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--tag-ids', '1']) + + test('rejects tag IDs that are not positive integers', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tag-ids', '1,abc'), + /--tag-ids.*must be comma-separated positive integers/ + ) + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tag-ids', '1,0'), + /--tag-ids.*must be comma-separated positive integers/ + ) + }) + + test('requires tag IDs or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --tag-ids is required/ + ) + }) +}) + +test('deletes tags on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'tags', + 'create', + '--project-code', + project.code, + '--title', + `cli-${randomBytes(4).toString('hex')}` + ) + const result = await runCommand( + '--project-code', + project.code, + '--tag-ids', + String(created.id) + ) + expect(result).toHaveProperty('message') + const remaining = await runCli('api', 'tags', 'list', '--project-code', project.code) + expect(remaining.map((t) => t.id)).not.toContain(created.id) +}) diff --git a/src/tests/api/tags/list.spec.ts b/src/tests/api/tags/list.spec.ts index c5ee7ff..0a4962e 100644 --- a/src/tests/api/tags/list.spec.ts +++ b/src/tests/api/tags/list.spec.ts @@ -18,23 +18,34 @@ describe('mocked', () => { ] let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/tag`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - return HttpResponse.json({ tags: mockTags }) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/tag/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json({ total: 2, offset: 0, limit: 100, data: mockTags }) + } + ) ) afterEach(() => { lastParams = {} + lastSearchParams = null }) test('lists tags in a project', async ({ project }) => { const result = await runCommand('--project-code', project.code) expect(lastParams.projectCode).toBe(project.code) - expect(result).toEqual(mockTags) + expect(result).toEqual({ total: 2, offset: 0, limit: 100, data: mockTags }) + }) + + test('passes include as a query parameter', async ({ project }) => { + await runCommand('--project-code', project.code, '--include', 'tcaseCount') + expect(lastSearchParams?.get('include')).toBe('tcaseCount') }) }) diff --git a/src/tests/api/tags/rename.spec.ts b/src/tests/api/tags/rename.spec.ts new file mode 100644 index 0000000..fe619f3 --- /dev/null +++ b/src/tests/api/tags/rename.spec.ts @@ -0,0 +1,114 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Tag } from '../../../api/tags' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'tags', 'rename', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch(`${baseURL}/api/public/v0/project/:projectCode/tag`, async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + const body = (await request.json()) as { tags: Tag[] } + lastRequest = body + return HttpResponse.json(body) + }) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('renames several tags', async ({ project }) => { + const tags = [ + { id: 1, title: 'smoke' }, + { id: 2, title: 'e2e' }, + ] + const result = await runCommand('--project-code', project.code, '--tags', JSON.stringify(tags)) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tags }) + expect(result).toEqual({ tags }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tags: [{ id: 1, title: 'smoke' }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ tags: [] }, /Must contain at least one tag/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tags', + JSON.stringify([{ id: 1, title: 'smoke' }]), + ]) + + test('rejects --tags that is not JSON', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tags', 'smoke'), + /Failed to parse --tags as JSON/ + ) + }) + + test('rejects a tag with an empty title', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tags', JSON.stringify([{ id: 1, title: '' }])), + /--tags\.0\.title.*must not be empty/ + ) + }) + + test('requires tags or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --tags is required/ + ) + }) +}) + +test('renames several tags on live server', { tags: ['live'] }, async ({ project }) => { + const createTag = () => + runCli( + 'api', + 'tags', + 'create', + '--project-code', + project.code, + '--title', + `cli-${randomBytes(4).toString('hex')}` + ) + const first = await createTag() + const second = await createTag() + const tags = [ + { id: first.id, title: `cli-${randomBytes(4).toString('hex')}` }, + { id: second.id, title: `cli-${randomBytes(4).toString('hex')}` }, + ] + const result = await runCommand<{ tags: Tag[] }>( + '--project-code', + project.code, + '--tags', + JSON.stringify(tags) + ) + expect(result.tags).toEqual(tags) +}) diff --git a/src/tests/api/tags/update.spec.ts b/src/tests/api/tags/update.spec.ts new file mode 100644 index 0000000..3e0e6de --- /dev/null +++ b/src/tests/api/tags/update.spec.ts @@ -0,0 +1,111 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import { randomBytes } from 'node:crypto' +import type { Tag } from '../../../api/tags' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'tags', 'update', ...args) + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tag/:tagId`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ id: 7, title: 'regression' }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('renames a tag', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tag-id', + '7', + '--title', + 'regression' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastParams.tagId).toBe('7') + expect(lastRequest).toEqual({ title: 'regression' }) + expect(result).toEqual({ id: 7, title: 'regression' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { title: 'regression' } + const requiredArgs = ['--project-code', 'PRJ', '--tag-id', '7'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ title: '' }, /must not be empty/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tag-id', + '7', + '--title', + 'regression', + ]) + + test('rejects a non-positive tag ID', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tag-id', '0', '--title', 'regression'), + /--tag-id.*must be a positive integer/ + ) + }) + + test('requires a title or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tag-id', '7'), + /Either --body, --body-file, or --title is required/ + ) + }) +}) + +test('renames a tag on live server', { tags: ['live'] }, async ({ project }) => { + const created = await runCli( + 'api', + 'tags', + 'create', + '--project-code', + project.code, + '--title', + `cli-${randomBytes(4).toString('hex')}` + ) + const title = `cli-${randomBytes(4).toString('hex')}` + const renamed = await runCommand( + '--project-code', + project.code, + '--tag-id', + String(created.id), + '--title', + title + ) + expect(renamed).toEqual({ id: created.id, title }) +}) diff --git a/src/tests/api/test-cases/copy.spec.ts b/src/tests/api/test-cases/copy.spec.ts new file mode 100644 index 0000000..38a2d53 --- /dev/null +++ b/src/tests/api/test-cases/copy.spec.ts @@ -0,0 +1,127 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCasesResponse } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'copy', ...args) + +describe('mocked', () => { + const response = { tcases: [{ id: 'tc9', seq: 9 }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tcase/copy`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(response, { status: 201 }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('copies test cases into a folder', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + '12,13', + '--target-folder-id', + '5' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tcaseIds: ['12', '13'], targetFolderId: 5 }) + expect(result).toEqual(response) + }) + + test('copies test cases from another project', async () => { + await runCommand( + '--project-code', + 'PRJ', + '--tcase-ids', + '4', + '--target-folder-id', + '5', + '--source-project-id', + 'OLD' + ) + expect(lastRequest).toEqual({ tcaseIds: ['4'], targetFolderId: 5, sourceProjectId: 'OLD' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tcaseIds: ['12'], targetFolderId: 5 } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--target-folder-id', '6'], + expectedRequest: { tcaseIds: ['12'], targetFolderId: 6 }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ tcaseIds: ['12'] }, /targetFolderId|expected number/i, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-ids', + '12', + '--target-folder-id', + '5', + ]) + + test('rejects a target folder that is not a positive integer', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12', '--target-folder-id', '0'), + /--target-folder-id.*must be a positive integer/ + ) + }) + + test('requires the test cases and the target folder', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12'), + /Either --body, --body-file, or --tcase-ids and --target-folder-id is required/ + ) + }) +}) + +test('copies test cases on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const folderId = folder.ids[0][0] + const created = await createTCase(project.code, folderId) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + created.id, + '--target-folder-id', + String(folderId) + ) + expect(result.tcases).toHaveLength(1) + expect(result.tcases[0].id).not.toBe(created.id) +}) diff --git a/src/tests/api/test-cases/create.spec.ts b/src/tests/api/test-cases/create.spec.ts index 30867f6..b758821 100644 --- a/src/tests/api/test-cases/create.spec.ts +++ b/src/tests/api/test-cases/create.spec.ts @@ -154,25 +154,53 @@ describe('mocked', () => { }) }) - test('creates a test case with requirements and links', async ({ project }) => { - const body = JSON.stringify({ + test('creates a test case with requirements, links and files', async ({ project }) => { + const requirements = [{ text: 'REQ-1', url: 'https://example.com/req/1' }, { text: 'REQ-2' }] + const links = [{ text: 'Docs', url: 'https://example.com/docs' }] + const files = [ + { + id: 'f1', + url: 'https://example.com/f1', + fileName: 'log.txt', + mimeType: 'text/plain', + size: 3, + }, + ] + const expected = { title: 'With links', type: 'standalone', folderId: 1, priority: 'low', - requirements: [{ text: 'REQ-1', url: 'https://example.com/req/1' }], - links: [{ text: 'Docs', url: 'https://example.com/docs' }], - }) - await runCommand('--project-code', project.code, '--body', body) + pos: 0, + requirements, + links, + files, + } + await runCommand('--project-code', project.code, '--body', JSON.stringify(expected)) expect(lastParams.projectCode).toBe(project.code) - expect(lastRequest).toEqual({ - title: 'With links', - type: 'standalone', - folderId: 1, - priority: 'low', - requirements: [{ text: 'REQ-1', url: 'https://example.com/req/1' }], - links: [{ text: 'Docs', url: 'https://example.com/docs' }], - }) + expect(lastRequest).toEqual(expected) + + await runCommand( + '--project-code', + project.code, + '--title', + 'With links', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'low', + '--pos', + '0', + '--requirements', + JSON.stringify(requirements), + '--links', + JSON.stringify(links), + '--files', + JSON.stringify(files) + ) + expect(lastRequest).toEqual(expected) }) test('creates a test case using individual fields', async ({ project }) => { @@ -337,7 +365,9 @@ describe('mocked', () => { '--priority', 'high', '--parameter-values', - JSON.stringify(parameterValues) + JSON.stringify(parameterValues), + '--title-suffix-params', + 'browser, device' ) expect(lastRequest).toEqual({ title: 'Login ${browser}', @@ -345,6 +375,7 @@ describe('mocked', () => { folderId: 1, priority: 'high', parameterValues, + filledTCaseTitleSuffixParams: ['browser', 'device'], }) }) @@ -524,6 +555,27 @@ describe('validation errors', () => { ) }) + test('rejects a link that is not an http or https URL', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--title', + 'Test', + '--type', + 'standalone', + '--folder-id', + '1', + '--priority', + 'high', + '--links', + '[{"text": "FTP", "url": "ftp://example.com/spec"}]' + ), + /--links.*must be an http or https URL/ + ) + }) + test('rejects parameterValues on standalone test case', async () => { await expectValidationError( () => diff --git a/src/tests/api/test-cases/delete.spec.ts b/src/tests/api/test-cases/delete.spec.ts new file mode 100644 index 0000000..45eefde --- /dev/null +++ b/src/tests/api/test-cases/delete.spec.ts @@ -0,0 +1,98 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TrashEntry } from '../../../api/trash' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'delete', ...args) + +describe('mocked', () => { + const trashEntry = { id: 'log1', name: '2 test cases', resourceType: 'test_case' } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tcase/delete`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(trashEntry) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('deletes test cases', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--tcase-ids', '12, tc1') + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tcaseIds: ['12', 'tc1'] }) + expect(result).toEqual(trashEntry) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tcaseIds: ['12', '13'] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { tcaseIds: ['12'] }, + flags: ['--tcase-ids', '14'], + expectedRequest: { tcaseIds: ['14'] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ tcaseIds: [] }, /Must contain at least one test case/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--tcase-ids', '12']) + + test('rejects malformed test case references', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12,a/b'), + /--tcase-ids.*must be comma-separated IDs/ + ) + }) + + test('requires test case references or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --tcase-ids is required/ + ) + }) +}) + +test('deletes test cases on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + String(created.seq) + ) + expect(result.resourceType).toBe('test_case') + expect(result.items.flatMap((item) => item.tcaseIds ?? [])).toContain(created.id) +}) diff --git a/src/tests/api/test-cases/get-version.spec.ts b/src/tests/api/test-cases/get-version.spec.ts new file mode 100644 index 0000000..b6aadd0 --- /dev/null +++ b/src/tests/api/test-cases/get-version.spec.ts @@ -0,0 +1,111 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { TCase } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'get-version', ...args) + +describe('mocked', () => { + const mockTCase = { id: 'tc1', title: 'Login test', version: 2 } + let lastParams: PathParams = {} + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/tcase/:id/version/:version`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockTCase) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('gets a version of a test case', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tcase-id', + 'tc1', + '--tcase-version', + '2' + ) + expect(lastParams).toEqual({ projectCode: project.code, id: 'tc1', version: '2' }) + expect(result).toEqual(mockTCase) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-id', + 'tc1', + '--tcase-version', + '1', + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--tcase-version', + '1', + ]) + + test('rejects a version that is not a positive integer', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-id', 'tc1', '--tcase-version', '0'), + /--tcase-version.*must be a positive integer/ + ) + }) +}) + +test( + 'gets an earlier version of a test case on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const original = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + await runCli( + 'api', + 'test-cases', + 'update', + '--project-code', + project.code, + '--tcase-id', + created.id, + '--title', + `${original.title} (edited)` + ) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-id', + created.id, + '--tcase-version', + '1' + ) + expect(result.version).toBe(1) + expect(result.title).toBe(original.title) + } +) diff --git a/src/tests/api/test-cases/list.spec.ts b/src/tests/api/test-cases/list.spec.ts index ed6bed8..ba53409 100644 --- a/src/tests/api/test-cases/list.spec.ts +++ b/src/tests/api/test-cases/list.spec.ts @@ -24,12 +24,15 @@ describe('mocked', () => { let lastSearchParams: URLSearchParams | null = null useMockServer( - http.get(`${baseURL}/api/public/v0/project/:projectCode/tcase`, ({ request, params }) => { - expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) - lastParams = params - lastSearchParams = new URL(request.url).searchParams - return HttpResponse.json(mockResponse) - }) + http.get( + `${baseURL}/api/public/v0/project/:projectCode/tcase/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(mockResponse) + } + ) ) afterEach(() => { @@ -44,11 +47,19 @@ describe('mocked', () => { }) test('passes offset and limit as query parameters', async ({ project }) => { - await runCommand('--project-code', project.code, '--offset', '20', '--limit', '10') + const result = await runCommand( + '--project-code', + project.code, + '--offset', + '20', + '--limit', + '10' + ) expect(lastParams.projectCode).toBe(project.code) expect(lastSearchParams).not.toBeNull() expect(lastSearchParams!.get('offset')).toBe('20') expect(lastSearchParams!.get('limit')).toBe('10') + expect(result).toEqual(mockResponse) }) test('passes filter params', async ({ project }) => { @@ -73,17 +84,17 @@ describe('mocked', () => { describe('validation errors', () => { testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') - test('rejects --page 0', async () => { + test('rejects the removed --page option', async () => { await expectValidationError( - () => runCommand('--project-code', 'PRJ', '--page', '0'), - /--page.*expected number to be >0/i + () => runCommand('--project-code', 'PRJ', '--page', '1'), + /Unknown argument: page/ ) }) - test('rejects --page -1', async () => { + test('rejects --limit above 1000', async () => { await expectValidationError( - () => runCommand('--project-code', 'PRJ', '--page', '-1'), - /--page.*expected number to be >0/i + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ ) }) diff --git a/src/tests/api/test-cases/move.spec.ts b/src/tests/api/test-cases/move.spec.ts new file mode 100644 index 0000000..5608480 --- /dev/null +++ b/src/tests/api/test-cases/move.spec.ts @@ -0,0 +1,139 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCase, TCasesResponse } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'move', ...args) + +describe('mocked', () => { + const response = { tcases: [{ id: 'tc1', seq: 12 }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tcase/location`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(response) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('moves test cases to a folder', async ({ project }) => { + const items = [ + { id: '12', pos: 0 }, + { id: '13', pos: -1 }, + ] + const result = await runCommand( + '--project-code', + project.code, + '--new-parent-id', + '5', + '--items', + JSON.stringify(items) + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ newParentId: 5, items }) + expect(result).toEqual(response) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { newParentId: 5, items: [{ id: '12', pos: 0 }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--new-parent-id', '6'], + expectedRequest: { newParentId: 6, items: [{ id: '12', pos: 0 }] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { newParentId: 5, items: [] }, + /Must contain at least one item/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--new-parent-id', + '5', + '--items', + JSON.stringify([{ id: '12', pos: 0 }]), + ]) + + test('rejects items without a position', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--new-parent-id', + '5', + '--items', + JSON.stringify([{ id: '12' }]) + ), + /--items\.0\.pos/ + ) + }) + + test('requires the folder and the items', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--new-parent-id', '5'), + /Either --body, --body-file, or --new-parent-id and --items is required/ + ) + }) +}) + +test('moves test cases on live server', { tags: ['live'] }, async ({ project }) => { + const source = await createFolder(project.code) + const target = await createFolder(project.code) + const targetId = target.ids[0][0] + const created = await createTCase(project.code, source.ids[0][0]) + const result = await runCommand( + '--project-code', + project.code, + '--new-parent-id', + String(targetId), + '--items', + JSON.stringify([{ id: created.id, pos: 0 }]) + ) + expect(result.tcases.map((t) => t.id)).toEqual([created.id]) + const moved = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(moved.folderId).toBe(targetId) +}) diff --git a/src/tests/api/test-cases/results-list.spec.ts b/src/tests/api/test-cases/results-list.spec.ts new file mode 100644 index 0000000..68cd7d1 --- /dev/null +++ b/src/tests/api/test-cases/results-list.spec.ts @@ -0,0 +1,124 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCaseResult } from '../../../api/tcases' +import type { PaginatedResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + createRun, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'results', 'list', ...args) + +describe('mocked', () => { + const response = { + total: 1, + offset: 0, + limit: 100, + data: [{ id: 1, status: 'passed', tcaseId: 'tc1' }], + } + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/tcase/:id/result/paginated`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastSearchParams = new URL(request.url).searchParams + return HttpResponse.json(response) + } + ) + ) + + beforeEach(() => { + lastParams = {} + lastSearchParams = null + }) + + test('lists the results of a test case', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--tcase-id', 'tc1') + expect(lastParams).toEqual({ projectCode: project.code, id: 'tc1' }) + expect(result).toEqual(response) + }) + + test('passes filters and includes', async () => { + const result = await runCommand( + '--project-code', + 'PRJ', + '--tcase-id', + '12', + '--offset', + '10', + '--limit', + '5', + '--sort-order', + 'asc', + '--include', + 'run,author', + '--exclude-status', + 'open, skipped', + '--created-after', + '2026-01-01T00:00:00Z', + '--internal', + 'false' + ) + expect(lastSearchParams?.get('offset')).toBe('10') + expect(lastSearchParams?.get('limit')).toBe('5') + expect(lastSearchParams?.get('sortOrder')).toBe('asc') + expect(lastSearchParams?.getAll('include')).toEqual(['run', 'author']) + expect(lastSearchParams?.getAll('excludeStatus')).toEqual(['open', 'skipped']) + expect(lastSearchParams?.get('createdAfter')).toBe('2026-01-01T00:00:00Z') + expect(lastSearchParams?.get('internal')).toBe('false') + expect(result).toEqual(response) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--tcase-id', 'tc1']) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', ['--project-code', 'PRJ']) + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-id', 'tc1', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('lists the results of a test case on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const run = await createRun(project.code, [created.id]) + await runCli( + 'api', + 'results', + 'create', + '--project-code', + project.code, + '--run-id', + String(run.id), + '--tcase-id', + created.id, + '--status', + 'passed' + ) + const result = await runCommand>( + '--project-code', + project.code, + '--tcase-id', + created.id, + '--include', + 'run' + ) + expect(result.data.some((r) => r.status === 'passed' && r.run?.id === run.id)).toBe(true) +}) diff --git a/src/tests/api/test-cases/rollback.spec.ts b/src/tests/api/test-cases/rollback.spec.ts new file mode 100644 index 0000000..e6bf0ee --- /dev/null +++ b/src/tests/api/test-cases/rollback.spec.ts @@ -0,0 +1,107 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { TCase } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'rollback', ...args) + +describe('mocked', () => { + const mockTCase = { id: 'tc1', title: 'Login test', version: 3 } + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/tcase/:id/version/:version/rollback`, + ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + return HttpResponse.json(mockTCase) + } + ) + ) + + afterEach(() => { + lastParams = {} + }) + + test('rolls a test case back to a version', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tcase-id', + '12', + '--tcase-version', + '1' + ) + expect(lastParams).toEqual({ projectCode: project.code, id: '12', version: '1' }) + expect(result).toEqual(mockTCase) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-id', + 'tc1', + '--tcase-version', + '1', + ]) + testRejectsInvalidIdentifier(runCommand, 'tcase-id', 'resource', [ + '--project-code', + 'PRJ', + '--tcase-version', + '1', + ]) + + test('rejects a version that is not a positive integer', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-id', 'tc1', '--tcase-version', '-1'), + /--tcase-version.*must be a positive integer/ + ) + }) +}) + +test('rolls a test case back on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const original = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + await runCli( + 'api', + 'test-cases', + 'update', + '--project-code', + project.code, + '--tcase-id', + created.id, + '--title', + `${original.title} (edited)` + ) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-id', + created.id, + '--tcase-version', + '1' + ) + expect(result.title).toBe(original.title) + expect(result.version).toBe(3) +}) diff --git a/src/tests/api/test-cases/update-priority.spec.ts b/src/tests/api/test-cases/update-priority.spec.ts new file mode 100644 index 0000000..5998648 --- /dev/null +++ b/src/tests/api/test-cases/update-priority.spec.ts @@ -0,0 +1,121 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCase, TCasesResponse } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'update-priority', ...args) + +describe('mocked', () => { + const response = { tcases: [{ id: 'tc1', seq: 12 }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tcase/priority`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(response) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('sets the priority of test cases', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + '12,13', + '--priority', + 'high' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tcaseIds: ['12', '13'], priority: 'high' }) + expect(result).toEqual(response) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tcaseIds: ['12'], priority: 'low' } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--priority', 'medium'], + expectedRequest: { tcaseIds: ['12'], priority: 'medium' }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody({ tcaseIds: ['12'], priority: 'urgent' }, /priority/, requiredArgs) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-ids', + '12', + '--priority', + 'high', + ]) + + test('rejects an unknown priority', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12', '--priority', 'urgent'), + /priority/ + ) + }) + + test('requires the test cases and the priority', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--priority', 'high'), + /Either --body, --body-file, or --tcase-ids and --priority is required/ + ) + }) +}) + +test('sets the priority of test cases on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + created.id, + '--priority', + 'high' + ) + expect(result.tcases.map((t) => t.id)).toEqual([created.id]) + const updated = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(updated.priority).toBe('high') +}) diff --git a/src/tests/api/test-cases/update-requirements.spec.ts b/src/tests/api/test-cases/update-requirements.spec.ts new file mode 100644 index 0000000..1428aae --- /dev/null +++ b/src/tests/api/test-cases/update-requirements.spec.ts @@ -0,0 +1,150 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCase, TCasesResponse } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'update-requirements', ...args) + +describe('mocked', () => { + const response = { tcases: [{ id: 'tc1', seq: 12 }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tcase/requirements`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(response) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('links and unlinks requirements', async ({ project }) => { + const add = [{ text: 'REQ-1', url: 'https://example.com/req/1' }] + const remove = [{ text: 'REQ-0' }] + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + '12,13', + '--add', + JSON.stringify(add), + '--remove', + JSON.stringify(remove) + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ tcaseIds: ['12', '13'], add, remove }) + expect(result).toEqual(response) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tcaseIds: ['12'], add: [{ text: 'REQ-1' }] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--tcase-ids', '13'], + expectedRequest: { tcaseIds: ['13'], add: [{ text: 'REQ-1' }] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { tcaseIds: ['12'], add: [{ text: '' }] }, + /text must not be empty/, + requiredArgs + ) + h.testInvalidBody( + { tcaseIds: ['12'], add: [] }, + /add or remove must list at least one requirement/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-ids', + '12', + '--add', + JSON.stringify([{ text: 'REQ-1' }]), + ]) + + test('rejects a requirement without text', async () => { + await expectValidationError( + () => + runCommand( + '--project-code', + 'PRJ', + '--tcase-ids', + '12', + '--add', + JSON.stringify([{ url: 'https://example.com' }]) + ), + /--add\.0\.text/ + ) + }) + + test('requires requirements to link or unlink', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12'), + /Either --add or --remove is required/ + ) + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12', '--remove', '[]'), + /--add.*add or remove must list at least one requirement/ + ) + }) +}) + +test( + 'links a requirement to test cases on live server', + { tags: ['live'] }, + async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + created.id, + '--add', + JSON.stringify([{ text: 'CLI-REQ-1' }]) + ) + expect(result.tcases.map((t) => t.id)).toEqual([created.id]) + const updated = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(updated.requirements?.map((r) => r.text)).toContain('CLI-REQ-1') + } +) diff --git a/src/tests/api/test-cases/update-tags.spec.ts b/src/tests/api/test-cases/update-tags.spec.ts new file mode 100644 index 0000000..fce8b92 --- /dev/null +++ b/src/tests/api/test-cases/update-tags.spec.ts @@ -0,0 +1,145 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { TCase, TCasesResponse } from '../../../api/tcases' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + createTCase, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => + runCli('api', 'test-cases', 'update-tags', ...args) + +describe('mocked', () => { + const response = { tcases: [{ id: 'tc1', seq: 12 }] } + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.patch( + `${baseURL}/api/public/v0/project/:projectCode/tcase/tags`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json(response) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('adds and removes tags', async ({ project }) => { + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + '12,13', + '--add', + 'smoke, regression', + '--remove', + 'flaky' + ) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ + tcaseIds: ['12', '13'], + add: ['smoke', 'regression'], + remove: ['flaky'], + }) + expect(result).toEqual(response) + }) + + test('only adds tags', async () => { + await runCommand('--project-code', 'PRJ', '--tcase-ids', '12', '--add', 'smoke') + expect(lastRequest).toEqual({ tcaseIds: ['12'], add: ['smoke'] }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { tcaseIds: ['12'], add: ['smoke'] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: validBody, + flags: ['--remove', 'flaky'], + expectedRequest: { tcaseIds: ['12'], add: ['smoke'], remove: ['flaky'] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { tcaseIds: [], add: ['smoke'] }, + /Must contain at least one test case/, + requiredArgs + ) + h.testInvalidBody( + { tcaseIds: ['12'] }, + /add or remove must list at least one tag/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', [ + '--tcase-ids', + '12', + '--add', + 'smoke', + ]) + + test('requires tags to add or remove', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12'), + /Either --add or --remove is required/ + ) + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--tcase-ids', '12', '--add', ''), + /--add.*add or remove must list at least one tag/ + ) + }) + + test('requires test case references or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--add', 'smoke'), + /Either --body, --body-file, or --tcase-ids is required/ + ) + }) +}) + +test('adds tags to test cases on live server', { tags: ['live'] }, async ({ project }) => { + const folder = await createFolder(project.code) + const created = await createTCase(project.code, folder.ids[0][0]) + const result = await runCommand( + '--project-code', + project.code, + '--tcase-ids', + created.id, + '--add', + 'cli-smoke' + ) + expect(result.tcases.map((t) => t.id)).toEqual([created.id]) + const updated = await runCli( + 'api', + 'test-cases', + 'get', + '--project-code', + project.code, + '--tcase-id', + created.id + ) + expect(updated.tags?.map((t) => t.title)).toContain('cli-smoke') +}) diff --git a/src/tests/api/test-cases/update.spec.ts b/src/tests/api/test-cases/update.spec.ts index f9db2c8..3ad7cb5 100644 --- a/src/tests/api/test-cases/update.spec.ts +++ b/src/tests/api/test-cases/update.spec.ts @@ -129,7 +129,17 @@ describe('mocked', () => { }) }) - test('updates with --tags, --is-draft, --steps', async ({ project }) => { + test('updates with --tags, --is-draft, --steps, --requirements, --links, --files', async ({ + project, + }) => { + const steps = [ + { + description: 'Click', + expected: 'Opens', + data: [{ type: 'text', label: 'User', data: { value: 'admin' } }], + }, + ] + const files = [{ id: 'f1', fileName: 'log.txt', mimeType: 'text/plain', size: 3 }] await runCommand( '--project-code', project.code, @@ -141,13 +151,25 @@ describe('mocked', () => { 'smoke,e2e', '--is-draft', '--steps', - '[{"description": "Click", "expected": "Opens"}]' + JSON.stringify(steps), + '--requirements', + '[{"text": "REQ-1", "url": "https://example.com/req/1"}]', + '--links', + '[{"text": "Spec", "url": "https://example.com/spec"}]', + '--files', + JSON.stringify(files), + '--title-suffix-params', + 'browser' ) expect(lastRequest).toEqual({ title: 'With extras', tags: ['smoke', 'e2e'], isDraft: true, - steps: [{ description: 'Click', expected: 'Opens' }], + steps, + requirements: [{ text: 'REQ-1', url: 'https://example.com/req/1' }], + links: [{ text: 'Spec', url: 'https://example.com/spec' }], + files, + filledTCaseTitleSuffixParams: ['browser'], }) }) diff --git a/src/tests/api/trash/list.spec.ts b/src/tests/api/trash/list.spec.ts new file mode 100644 index 0000000..574316f --- /dev/null +++ b/src/tests/api/trash/list.spec.ts @@ -0,0 +1,132 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { afterEach, describe, expect } from 'vitest' +import type { PaginatedResponse } from '../../../api/schemas' +import type { TrashEntry } from '../../../api/trash' +import { + test, + baseURL, + token, + useMockServer, + runCli, + testRejectsInvalidIdentifier, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'trash', 'list', ...args) + +describe('mocked', () => { + const mockResponse = { + total: 1, + offset: 0, + limit: 100, + data: [ + { + id: '1CJo8oDjj_vkGo2rQAdzz4F', + name: 'Folder Login (3 test cases)', + resourceType: 'test_case', + authorId: 1, + author: { id: 1, name: 'Jane' }, + items: [ + { + seq: 1, + folderIds: [4], + tcaseIds: null, + runIds: null, + planIds: null, + milestoneIds: null, + }, + ], + createdAt: '2025-01-15T10:30:00Z', + }, + ], + } + + let lastParams: PathParams = {} + let lastSearchParams: URLSearchParams | null = null + + useMockServer( + http.get( + `${baseURL}/api/public/v0/project/:projectCode/trash/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 trash entries', async ({ project }) => { + const result = await runCommand('--project-code', project.code) + expect(lastParams.projectCode).toBe(project.code) + expect(lastSearchParams?.toString()).toBe('') + expect(result).toEqual(mockResponse) + }) + + test('passes filters and pagination', async () => { + const result = await runCommand( + '--project-code', + 'PRJ', + '--resource-types', + 'test_case, test_run', + '--offset', + '10', + '--limit', + '5', + '--sort-field', + 'created_at', + '--sort-order', + 'asc' + ) + expect(lastSearchParams?.getAll('resourceTypes')).toEqual(['test_case', 'test_run']) + expect(lastSearchParams?.get('offset')).toBe('10') + expect(lastSearchParams?.get('limit')).toBe('5') + expect(lastSearchParams?.get('sortField')).toBe('created_at') + expect(lastSearchParams?.get('sortOrder')).toBe('asc') + expect(result).toEqual(mockResponse) + }) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code') + + test('rejects an unknown resource type', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--resource-types', 'test_case,folder'), + /--resource-types.*must be comma-separated values of test_case, test_run, test_plan, milestone/ + ) + }) + + test('rejects a negative offset', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--offset', '-1'), + /--offset/ + ) + }) + + test('rejects a limit above 1000', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--limit', '1001'), + /--limit.*must be at most 1000/ + ) + }) +}) + +test('lists trash entries on live server', { tags: ['live'] }, async ({ project }) => { + const result = await runCommand>( + '--project-code', + project.code, + '--resource-types', + 'test_case' + ) + expect(result).toHaveProperty('total') + expect(Array.isArray(result.data)).toBe(true) + for (const entry of result.data) { + expect(entry.resourceType).toBe('test_case') + } +}) diff --git a/src/tests/api/trash/restore.spec.ts b/src/tests/api/trash/restore.spec.ts new file mode 100644 index 0000000..1647514 --- /dev/null +++ b/src/tests/api/trash/restore.spec.ts @@ -0,0 +1,112 @@ +import { HttpResponse, http, type PathParams } from 'msw' +import { beforeEach, describe, expect } from 'vitest' +import type { MessageResponse } from '../../../api/schemas' +import { + test, + baseURL, + token, + useMockServer, + runCli, + createFolder, + testRejectsInvalidIdentifier, + testBodyInput, + expectValidationError, +} from '../test-helper' + +const runCommand = (...args: string[]) => runCli('api', 'trash', 'restore', ...args) + +const entryA = '1CJo8oDjj_vkGo2rQAdzz4F' +const entryB = '1CJo8oDjj_vkGo2rQAdzz4G' + +describe('mocked', () => { + let lastRequest: unknown = null + let lastParams: PathParams = {} + + useMockServer( + http.post( + `${baseURL}/api/public/v0/project/:projectCode/trash/restore`, + async ({ request, params }) => { + expect(request.headers.get('Authorization')).toEqual(`Bearer ${token}`) + lastParams = params + lastRequest = await request.json() + return HttpResponse.json({ message: 'Restored' }) + } + ) + ) + + beforeEach(() => { + lastRequest = null + lastParams = {} + }) + + test('restores trash entries', async ({ project }) => { + const result = await runCommand('--project-code', project.code, '--ids', `${entryA},${entryB}`) + expect(lastParams.projectCode).toBe(project.code) + expect(lastRequest).toEqual({ deleteLogIds: [entryA, entryB] }) + expect(result).toEqual({ message: 'Restored' }) + }) + + testBodyInput( + runCommand, + () => lastRequest, + (h) => { + const validBody = { deleteLogIds: [entryA] } + const requiredArgs = ['--project-code', 'PRJ'] + h.testInlineBody(validBody, validBody, requiredArgs) + h.testBodyFile(validBody, validBody, requiredArgs) + h.testFieldOverride({ + body: { deleteLogIds: [entryA] }, + flags: ['--ids', entryB], + expectedRequest: { deleteLogIds: [entryB] }, + requiredArgs, + }) + h.testInvalidJson(requiredArgs) + h.testInvalidBody( + { deleteLogIds: [] }, + /Must contain at least one trash entry ID/, + requiredArgs + ) + h.testInvalidBody( + { deleteLogIds: ['1CJo8oDjj'] }, + /trash entry IDs are 23 characters long/, + requiredArgs + ) + } + ) +}) + +describe('validation errors', () => { + testRejectsInvalidIdentifier(runCommand, 'project-code', 'code', ['--ids', entryA]) + + test('rejects IDs with special characters', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ', '--ids', `${entryA},a/b`), + /--ids.*must be comma-separated IDs/ + ) + }) + + test('requires IDs or a body', async () => { + await expectValidationError( + () => runCommand('--project-code', 'PRJ'), + /Either --body, --body-file, or --ids is required/ + ) + }) +}) + +test('restores a deleted folder on live server', { tags: ['live'] }, async ({ project }) => { + const folderId = (await createFolder(project.code)).ids[0][0] + // Folder deletion is another command's job, so the live test calls the API directly. + const deleted = await fetch(`${baseURL}/api/public/v0/project/${project.code}/folder/delete`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ folderIds: [folderId] }), + }).then((r) => r.json() as Promise<{ id: string }>) + + const result = await runCommand( + '--project-code', + project.code, + '--ids', + deleted.id + ) + expect(result).toHaveProperty('message') +}) diff --git a/src/tests/api/utils.spec.ts b/src/tests/api/utils.spec.ts index c3b9df9..8c8a36a 100644 --- a/src/tests/api/utils.spec.ts +++ b/src/tests/api/utils.spec.ts @@ -1,5 +1,33 @@ import { describe, test, expect, vi } from 'vitest' -import { withBaseUrl } from '../../api/utils' +import { fetchAllPages, withBaseUrl } from '../../api/utils' + +describe('fetchAllPages', () => { + const pagesOf = (total: number) => + vi.fn(async ({ offset, limit }: { offset: number; limit: number }) => ({ + total, + offset, + limit, + data: Array.from( + { length: Math.max(0, Math.min(limit, total - offset)) }, + (_, i) => offset + i + ), + })) + + test.each([ + { total: 0, calls: 1 }, + { total: 999, calls: 1 }, + { total: 1000, calls: 1 }, + { total: 2500, calls: 3 }, + ])('reads $total items in $calls requests', async ({ total, calls }) => { + const fetchPage = pagesOf(total) + const result = await fetchAllPages(fetchPage) + expect(result).toEqual({ total, data: Array.from({ length: total }, (_, i) => i) }) + expect(fetchPage).toHaveBeenCalledTimes(calls) + expect(fetchPage.mock.calls.map(([page]) => page)).toEqual( + Array.from({ length: calls }, (_, i) => ({ offset: i * 1000, limit: 1000 })) + ) + }) +}) describe('withBaseUrl', () => { test('strips trailing slashes from base URL', async () => { diff --git a/src/tests/bearer-upload.spec.ts b/src/tests/bearer-upload.spec.ts index 07fd43a..a977f78 100644 --- a/src/tests/bearer-upload.spec.ts +++ b/src/tests/bearer-upload.spec.ts @@ -44,26 +44,42 @@ const server = setupServer( expect(request.headers.get('Authorization')).toEqual(expectedAuth) return HttpResponse.json({ exists: true }) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/folders`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/${projectCode}/folder/paginated`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual(expectedAuth) - return HttpResponse.json({ data: [], total: 0, offset: 0, limit: 50 }) + return HttpResponse.json({ data: [], total: 0, offset: 0, limit: 1000 }) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/paginated`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual(expectedAuth) - return HttpResponse.json({ data: [], total: 0, offset: 0, limit: 50 }) - }), - http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/seq`, ({ request }) => { - expect(request.headers.get('Authorization')).toEqual(expectedAuth) - return HttpResponse.json({ data: runTestCases, total: runTestCases.length }) + return HttpResponse.json({ data: [], total: 0, offset: 0, limit: 1000 }) }), + http.post( + `${baseURL}/api/public/v0/project/${projectCode}/tcase/seq/paginated`, + ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ + data: runTestCases, + total: runTestCases.length, + offset: 0, + limit: 1000, + }) + } + ), http.post(`${baseURL}/api/public/v0/project/${projectCode}/run`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual(expectedAuth) return HttpResponse.json({ id: parseInt(runId) }) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase`, ({ request }) => { - expect(request.headers.get('Authorization')).toEqual(expectedAuth) - return HttpResponse.json({ tcases: runTestCases }) - }), + http.get( + `${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase/paginated`, + ({ request }) => { + expect(request.headers.get('Authorization')).toEqual(expectedAuth) + return HttpResponse.json({ + data: runTestCases, + total: runTestCases.length, + offset: 0, + limit: 1000, + }) + } + ), http.post( new RegExp(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/result/batch`), ({ request }) => { diff --git a/src/tests/result-upload.spec.ts b/src/tests/result-upload.spec.ts index c2fbde0..2423030 100644 --- a/src/tests/result-upload.spec.ts +++ b/src/tests/result-upload.spec.ts @@ -1,6 +1,6 @@ import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' -import { unlinkSync, readdirSync } from 'node:fs' +import { unlinkSync, readdirSync, readFileSync } from 'node:fs' import { afterAll, beforeAll, beforeEach, expect, test, describe, afterEach, vi } from 'vitest' import { run } from '../commands/main' import { PaginatedResponse } from '../api/schemas' @@ -24,6 +24,7 @@ process.env['QAS_URL'] = baseURL let lastCreatedRunTitle = '' // Stores title in the request, for the last create run API call let createRunTitleConflict = false // If true, the create run API returns a title conflict error let createTCasesResponse: CreateTCasesResponse | null = null // Stores mock response for the create tcases API call +let createdTCaseTitles: string[] = [] // Titles sent in the create tcases API calls // Mocks intentionally include only the fields the SUT touches, so the response // types are loosened to Partial<> rather than the full API shape. let overriddenGetPaginatedTCasesResponse: PaginatedResponse> | null = null @@ -34,35 +35,40 @@ const server = setupServer( expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') return HttpResponse.json({ exists: true }) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/folders`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/${projectCode}/folder/paginated`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') const searchParams = new URL(request.url).searchParams expect(searchParams.get('offset')).not.toBeNull() - expect(searchParams.get('page')).toBeNull() + expect(searchParams.get('limit')).toEqual('1000') return HttpResponse.json( - overriddenGetFoldersResponse || { data: [], total: 0, offset: 0, limit: 50 } + overriddenGetFoldersResponse || { data: [], total: 0, offset: 0, limit: 1000 } ) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase`, ({ request }) => { + http.get(`${baseURL}/api/public/v0/project/${projectCode}/tcase/paginated`, ({ request }) => { expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') const searchParams = new URL(request.url).searchParams expect(searchParams.get('offset')).not.toBeNull() - expect(searchParams.get('page')).toBeNull() + expect(searchParams.get('limit')).toEqual('1000') return HttpResponse.json( - overriddenGetPaginatedTCasesResponse || { data: [], total: 0, offset: 0, limit: 50 } + overriddenGetPaginatedTCasesResponse || { data: [], total: 0, offset: 0, limit: 1000 } ) }), - http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/seq`, async ({ request }) => { - expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') - const body = (await request.json()) as { offset?: number; page?: number } - expect(body.offset).toBeTypeOf('number') - expect(body.page).toBeUndefined() - return HttpResponse.json({ - data: runTestCases, - total: runTestCases.length, - }) - }), - http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/bulk`, async ({ request }) => { + http.post( + `${baseURL}/api/public/v0/project/${projectCode}/tcase/seq/paginated`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') + const body = (await request.json()) as { offset?: number; limit?: number } + expect(body.offset).toBeTypeOf('number') + expect(body.limit).toEqual(1000) + return HttpResponse.json({ + data: runTestCases, + total: runTestCases.length, + offset: 0, + limit: 1000, + }) + } + ), + http.post(`${baseURL}/api/public/v0/project/${projectCode}/tcase/batch`, async ({ request }) => { expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') if (!createTCasesResponse) { @@ -77,6 +83,7 @@ const server = setupServer( } const body = (await request.json()) as CreateTCasesRequest + createdTCaseTitles.push(...body.tcases.map((t) => t.title)) if (body.tcases.length !== createTCasesResponse.tcases.length) { return HttpResponse.json( { @@ -109,12 +116,21 @@ const server = setupServer( id: parseInt(runId), }) }), - http.get(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase`, ({ request }) => { - expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') - return HttpResponse.json({ - tcases: runTestCases, - }) - }), + http.get( + `${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/tcase/paginated`, + ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('Bearer QAS_TOKEN') + const searchParams = new URL(request.url).searchParams + expect(searchParams.get('offset')).toEqual('0') + expect(searchParams.get('limit')).toEqual('1000') + return HttpResponse.json({ + data: runTestCases, + total: runTestCases.length, + offset: 0, + limit: 1000, + }) + } + ), http.post( new RegExp(`${baseURL}/api/public/v0/project/${projectCode}/run/${runId}/result/batch`), ({ request }) => { @@ -160,7 +176,7 @@ const countFileUploadApiCalls = () => const countResultUploadApiCalls = () => countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/result/batch')) const countCreateTCasesApiCalls = () => - countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/tcase/bulk')) + countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith('/tcase/batch')) const countCreateRunApiCalls = () => countMockedApiCalls(server, (req) => new URL(req.url).pathname.endsWith(`/project/${projectCode}/run`) @@ -192,6 +208,12 @@ const getMappingFiles = () => readdirSync('.').filter((f) => f.startsWith('qasphere-automapping-') && f.endsWith('.txt')) ) +const readNewMappingLines = (existingMappingFiles?: Set) => { + const newFiles = [...getMappingFiles()].filter((f) => !existingMappingFiles?.has(f)) + expect(newFiles).toHaveLength(1) + return readFileSync(newFiles[0], 'utf8').split('\n') +} + const cleanupGeneratedMappingFiles = (existingMappingFiles?: Set) => { const currentFiles = getMappingFiles() currentFiles.forEach((f) => { @@ -574,6 +596,7 @@ fileTypesWithAllure.forEach((fileType) => { cleanupGeneratedMappingFiles(existingMappingFiles) existingMappingFiles = undefined createTCasesResponse = null + createdTCaseTitles = [] overriddenGetPaginatedTCasesResponse = null overriddenGetFoldersResponse = null }) @@ -595,6 +618,11 @@ fileTypesWithAllure.forEach((fileType) => { ) expect(numCreateTCasesCalls()).toBe(1) expect(numResultUploadCalls()).toBe(3) // 3 results total + expect(createdTCaseTitles).toHaveLength(2) + expect(readNewMappingLines(existingMappingFiles)).toEqual([ + `TEST-006: ${createdTCaseTitles[0]}`, + `TEST-007: ${createdTCaseTitles[1]}`, + ]) }) test('Should not create new test case if one with same title already exists', async () => { @@ -631,6 +659,10 @@ fileTypesWithAllure.forEach((fileType) => { ) expect(numCreateTCasesCalls()).toBe(1) expect(numResultUploadCalls()).toBe(3) // 3 results total + expect(createdTCaseTitles).toHaveLength(1) + expect(readNewMappingLines(existingMappingFiles)).toEqual([ + `TEST-007: ${createdTCaseTitles[0]}`, + ]) }) test('Should not create new test cases if all results have valid markers', async () => { diff --git a/src/utils/result-upload/ResultUploadCommandHandler.ts b/src/utils/result-upload/ResultUploadCommandHandler.ts index 1d80540..845467f 100644 --- a/src/utils/result-upload/ResultUploadCommandHandler.ts +++ b/src/utils/result-upload/ResultUploadCommandHandler.ts @@ -6,7 +6,9 @@ import { parseRunUrl, printErrorThenExit, processTemplate } from '../misc' import { MarkerParser } from './MarkerParser' import { Api, createApi } from '../../api' import type { AuthConfig } from '../credentials' -import { TCase } from '../../api/tcases' +import { MAX_BULK_ITEMS, MAX_PAGE_SIZE } from '../../api/schemas' +import { fetchAllPages } from '../../api/utils' +import { CreateTCasesResponse, TCase } from '../../api/tcases' import { ParseResult, TestCaseMarker, TestCaseResult } from './types' import { DuplicateTCaseMapping, TCaseTarget, mapResolvedResultsToTCases } from './mapping' import { ResultUploader } from './ResultUploader' @@ -65,8 +67,6 @@ interface TestCaseResultWithSeqAndFile { type PendingTCaseCreations = Record -// Must not exceed the server-side pagination cap of 5000 -const DEFAULT_PAGE_SIZE = 5000 export const DEFAULT_FOLDER_TITLE = 'cli-import' const DEFAULT_TCASE_TAGS = ['cli-import'] const DEFAULT_MAPPING_FILENAME_TEMPLATE = 'qasphere-automapping-{YYYY}{MM}{DD}-{HH}{mm}{ss}.txt' @@ -235,23 +235,14 @@ export class ResultUploadCommandHandler { this.markerParser.formatMarker(projectCode, v) ) - for (let offset = 0; ; offset += DEFAULT_PAGE_SIZE) { - const response = await this.api.testCases.getBySeq(projectCode, { - seqIds: tcaseMarkers, - offset, - limit: DEFAULT_PAGE_SIZE, - }) - - for (const tcase of response.data) { - apiTCasesMap[tcase.seq] = { - id: tcase.id, - seq: tcase.seq, - title: tcase.title, - } - } - - if (response.data.length < DEFAULT_PAGE_SIZE) { - break + const { data } = await fetchAllPages((page) => + this.api.testCases.getBySeq(projectCode, { seqIds: tcaseMarkers, ...page }) + ) + for (const tcase of data) { + apiTCasesMap[tcase.seq] = { + id: tcase.id, + seq: tcase.seq, + title: tcase.title, } } } @@ -381,11 +372,11 @@ export class ResultUploadCommandHandler { // First fetch the default folder ID where we are creating new test cases. // Ideally, there shouldn't be the need to fetch more than one page. let defaultFolderId = null - for (let offset = 0; ; offset += DEFAULT_PAGE_SIZE) { - const response = await this.api.folders.getPaginated(projectCode, { + for (let offset = 0; ; offset += MAX_PAGE_SIZE) { + const response = await this.api.folders.list(projectCode, { search: DEFAULT_FOLDER_TITLE, offset, - limit: DEFAULT_PAGE_SIZE, + limit: MAX_PAGE_SIZE, }) for (const folder of response.data) { @@ -395,7 +386,7 @@ export class ResultUploadCommandHandler { } } - if (defaultFolderId || response.data.length < DEFAULT_PAGE_SIZE) { + if (defaultFolderId || response.data.length < MAX_PAGE_SIZE) { break } } @@ -406,11 +397,11 @@ export class ResultUploadCommandHandler { } const pendingTitles = new Set(tcasesToCreate) - for (let offset = 0; pendingTitles.size > 0; offset += DEFAULT_PAGE_SIZE) { - const response = await this.api.testCases.getPaginated(projectCode, { + for (let offset = 0; pendingTitles.size > 0; offset += MAX_PAGE_SIZE) { + const response = await this.api.testCases.list(projectCode, { folders: [defaultFolderId], offset, - limit: DEFAULT_PAGE_SIZE, + limit: MAX_PAGE_SIZE, }) for (const tcase of response.data) { @@ -421,7 +412,7 @@ export class ResultUploadCommandHandler { pendingTitles.delete(tcase.title) } - if (response.data.length < DEFAULT_PAGE_SIZE) { + if (response.data.length < MAX_PAGE_SIZE) { break } } @@ -429,64 +420,29 @@ export class ResultUploadCommandHandler { return reusableTCases } - private async createNewTCases(projectCode: string, tcasesToCreate: string[]) { + /** Creates test cases with the given titles, returned in the same order. */ + private async createNewTCases(projectCode: string, titles: string[]) { console.log(chalk.blue(`Creating test cases for results with no test case markers`)) - const apiTCasesMap = await this.getReusableTCasesInDefaultFolder(projectCode, tcasesToCreate) - - // Reuse existing test cases with the same title from the default folder - const ret: { id: string; seq: number }[] = [] - const idxToFill: number[] = [] - const finalTCasesToCreate: string[] = [] - for (let i = 0; i < tcasesToCreate.length; i++) { - const existingTcase = apiTCasesMap[tcasesToCreate[i]] - if (existingTcase) { - // TCase with this title already exists, reuse it - ret.push({ id: existingTcase.id, seq: existingTcase.seq }) - continue - } - - // Add a placeholder for the new test case. Will be updated later - ret.push({ id: '', seq: 0 }) - finalTCasesToCreate.push(tcasesToCreate[i]) - idxToFill.push(i) - } - - if (!finalTCasesToCreate.length) { - console.log( - chalk.blue( - `Reusing ${ret.length} test cases with same title from "${DEFAULT_FOLDER_TITLE}" folder, no new test cases created` - ) - ) - return ret + const tcases: CreateTCasesResponse['tcases'] = [] + for (let i = 0; i < titles.length; i += MAX_BULK_ITEMS) { + const response = await this.api.testCases.createBatch(projectCode, { + folderPath: [DEFAULT_FOLDER_TITLE], + tcases: titles + .slice(i, i + MAX_BULK_ITEMS) + .map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), + }) + tcases.push(...response.tcases) } - // Create new test cases and update the placeholders with the actual test case IDs - const { tcases } = await this.api.testCases.createBatch(projectCode, { - folderPath: [DEFAULT_FOLDER_TITLE], - tcases: finalTCasesToCreate.map((title) => ({ title, tags: DEFAULT_TCASE_TAGS })), - }) - console.log( - chalk.green( - `Created ${tcases.length} new test cases in folder "${DEFAULT_FOLDER_TITLE}"${ - ret.length > tcases.length - ? ` and reused ${ret.length - tcases.length} test cases with same title` - : '' - }` - ) + chalk.green(`Created ${tcases.length} new test cases in folder "${DEFAULT_FOLDER_TITLE}"`) ) - for (let i = 0; i < idxToFill.length; i++) { - ret[idxToFill[i]] = tcases[i] - } - try { const mappingFilename = processTemplate(DEFAULT_MAPPING_FILENAME_TEMPLATE) const mappingLines = tcases - .map( - (t, i) => `${this.markerParser.formatMarker(projectCode, t.seq)}: ${tcasesToCreate[i]}` - ) + .map((t, i) => `${this.markerParser.formatMarker(projectCode, t.seq)}: ${titles[i]}`) .join('\n') writeFileSync(mappingFilename, mappingLines) @@ -505,7 +461,7 @@ export class ResultUploadCommandHandler { ) } - return ret + return tcases } private async createNewRun(projectCode: string, tcaseIds: string[]) { diff --git a/src/utils/result-upload/ResultUploader.ts b/src/utils/result-upload/ResultUploader.ts index 26d3624..9f87948 100644 --- a/src/utils/result-upload/ResultUploader.ts +++ b/src/utils/result-upload/ResultUploader.ts @@ -4,6 +4,7 @@ import escapeHtml from 'escape-html' import { RunTCase } from '../../api/runs' import { parseRunUrl, printError, printErrorThenExit, twirlLoader } from '../misc' import { Api, createApi } from '../../api' +import { fetchAllPages } from '../../api/utils' import type { AuthConfig } from '../credentials' import { Attachment, TestCaseResult } from './types' import { ResultUploadCommandArgs, UploadCommandType } from './ResultUploadCommandHandler' @@ -33,7 +34,9 @@ export class ResultUploader { } async handle(results: TestCaseResult[], runFailureLogs?: string) { - const tcases = await this.api.runs.getTCases(this.project, this.run).catch(printErrorThenExit) + const { data: tcases } = await fetchAllPages((page) => + this.api.runs.listTCases(this.project, this.run, page) + ).catch(printErrorThenExit) const { results: mappedResults, missing, duplicates } = this.mapTestCaseResults(results, tcases) this.validateAndPrintMissingTestCases(missing)