# Test Plans API

> Create, manage, and generate runs from project-scoped test plans over the integrator API.

Test plans are project-scoped collections of selected test cases. A plan holds an ordered selection of live cases; from that selection you can generate a test run that snapshots the cases at the moment of generation. Each plan has a server-assigned ULID, a monotonically allocated plan number within the project, and an optional description.

## Create

`POST /api/v1/projects/{projectId}/plans`

`{projectId}` is the project **code** (for example `ACME`).

### Request body

| Field         | Required | Notes                                                                                |
| ------------- | -------- | ------------------------------------------------------------------------------------ |
| `name`        | yes      | Non-empty string, max 200 characters. Must be unique among live plans in the project |
| `description` | no       | Free-text description, max 2000 characters                                           |
| `status`      | no       | Initial status: `draft`, `active`, or `completed`. Defaults to `draft` when omitted  |

Client-supplied `ulid` or `planNumber` fields are rejected. Duplicate live name within the project returns `409 conflict`. Missing or invalid fields return `422 validation_failed`.

### Response

`201` with the created plan object. See [Response fields](#response-fields).

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Sprint 24","description":"Feature release"}' \
  "https://app.probara.net/api/v1/projects/ACME/plans"
```

## List

`GET /api/v1/projects/{projectId}/plans`

Returns the live (non-deleted) plans for the project, ordered by plan number ascending. Page-based pagination.

### Query parameters

| Parameter  | Notes                                    |
| ---------- | ---------------------------------------- |
| `page`     | Page number (1-based, default `1`)       |
| `pageSize` | Items per page (default `20`, max `200`) |

### Response

`200` with `{ items, page, pageSize, total }`. Each item follows the [response shape](#response-fields).

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects/ACME/plans?page=1&pageSize=20"
```

## Get

`GET /api/v1/plans/{planUlid}`

Returns a single live plan scoped to the active organization, including the current case count.

Returns `404 not_found` when the plan is outside the active organization or has been deleted.

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

## Update

`PATCH /api/v1/plans/{planUlid}`

Updates one or more fields of a live plan. At least one field must be provided. Duplicate live name returns `409 conflict`.

### Request body

| Field         | Required | Notes                                                                                                                                                                                                                                                                                                |
| ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | no       | New name (max 200 characters). Must be unique among live plans in the project                                                                                                                                                                                                                        |
| `description` | no       | Updated description (max 2000 characters). Send `null` to clear                                                                                                                                                                                                                                      |
| `status`      | no       | New lifecycle status: `draft`, `active`, or `completed`. Any transition between distinct statuses is valid. Sending the plan's current status is a no-op (accepted, no event emitted). An unknown status literal returns `422 validation_failed`                                                     |
| `milestoneId` | no       | ULID of a live milestone in the plan's project to link, or `null` to clear the existing link. A ULID that does not resolve to a live milestone in the plan's project returns `404 not_found` and leaves the plan unchanged. A milestone from another project or organization is also `404 not_found` |

An empty body (no fields) returns `422 validation_failed`.

### Response

`200` with the updated plan object. See [Response fields](#response-fields).

### Status transition rules

- All transitions between `draft`, `active`, and `completed` are valid in any direction.
- There are no terminal states: a `completed` plan can be moved back to `active` or `draft`.
- A same-value status PATCH (the `status` field equals the plan's current status) is accepted as a no-op — no status change is recorded and the plan returns `200` unchanged.
- An unknown status literal (not one of the three valid values) returns `422 validation_failed`.
- Status is purely manual: the API never auto-advances a plan's status based on its runs.

### Examples

```bash
# Change status to active
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"active"}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

```bash
# Link to a milestone
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"milestoneId":"01HZMILESTONE0000000000000"}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

```bash
# Clear the milestone link
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"milestoneId":null}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

```bash
# Combined: rename and mark completed
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Sprint 25 Final","status":"completed"}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

## Delete

`DELETE /api/v1/plans/{planUlid}`

Soft-deletes the plan. The plan is no longer listed or accessible, and its name becomes available for reuse. Existing runs generated from the plan are not affected.

Returns `204` on success, `404 not_found` when not found.

### Example

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000"
```

## Replace the case selection

`PUT /api/v1/plans/{planUlid}/cases`

Replace-set semantics: the **entire** selection is replaced with the supplied ordered list of case ULIDs. Omitting a case from the list removes it; supplying it in a different position moves it. Send an empty `caseUlids` array to clear the selection.

You can optionally assign a team member to each case in the same call by supplying `caseAssignees`. Assignee entries whose `caseUlid` is not present in `caseUlids` are silently ignored. All assignee resolution happens **before** the selection is replaced — a bad `assigneeUlid` leaves the current selection unchanged.

### Request body

| Field           | Required | Notes                                                                                                                                                                                                                                                                                                                                                     |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `caseUlids`     | yes      | Ordered array of live test case ULIDs, **at most 5000 entries**. Duplicate ULIDs rejected (`422`). Cases must belong to the plan's project                                                                                                                                                                                                                |
| `caseAssignees` | no       | Array of `{ caseUlid, assigneeUlid }` pairs. Each `assigneeUlid` must be a member of the active organization — a cross-org `assigneeUlid` returns `404 not_found` and leaves the selection unchanged. Entries whose `caseUlid` is not in `caseUlids` are ignored. Omitting this field (or omitting an entry for a given case) stores that case unassigned |

### Request limits

`caseUlids` caps at **5000 entries** — an abuse ceiling, not a product ceiling; the server chunks its statements at any accepted size. Submitting more returns `422 validation_failed` naming the `caseUlids` field, and the plan's selection is left unchanged.

Returns `404 not_found` when any supplied ULID is not a live case in the project, or when any `assigneeUlid` is not an org member. Returns `422 validation_failed` for duplicate ULIDs or for a `caseUlids` array carrying more than 5000 entries.

### Response

`200` with `{ items }` — the new selection in position order. See [Selection item fields](#selection-item-fields).

### Example — case selection only

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"caseUlids":["01HZCASEA","01HZCASEB","01HZCASEC"]}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/cases"
```

### Example — case selection with per-case assignees

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "caseUlids": ["01HZCASEA","01HZCASEB","01HZCASEC"],
    "caseAssignees": [
      { "caseUlid": "01HZCASEA", "assigneeUlid": "01HZMEMBER000000000000000001" },
      { "caseUlid": "01HZCASEB", "assigneeUlid": "01HZMEMBER000000000000000002" }
    ]
  }' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/cases"
```

`01HZCASEC` has no entry in `caseAssignees` and is stored unassigned.

## Update a plan case's assignee

`PATCH /api/v1/plans/{planUlid}/cases/{planCaseUlid}`

Updates the **assignee** of a single plan case without modifying the rest of the case selection. This endpoint is the per-case inline update — it does not replace the plan's case list.

**Snapshot semantics**: updating a plan case's assignee does not back-propagate to any runs already generated from the plan. Existing run cases keep the assignee they had at the moment the run was generated.

### Path parameters

| Parameter      | Description                                                                                |
| -------------- | ------------------------------------------------------------------------------------------ |
| `planUlid`     | ULID of the plan                                                                           |
| `planCaseUlid` | ULID of the plan case row (the `ulid` field from a selection item, not the test case ULID) |

### Request body

| Field          | Required | Notes                                                      |
| -------------- | -------- | ---------------------------------------------------------- |
| `assigneeUlid` | yes      | ULID of a live org member, or `null` to clear the assignee |

The body must be exactly one key. Unknown keys return `422 validation_failed`.

### Response

`200` with the updated plan case object (same shape as a selection item). See [Selection item fields](#selection-item-fields).

### Error codes

| Status                     | Code                | When                                                                      |
| -------------------------- | ------------------- | ------------------------------------------------------------------------- |
| `403 Forbidden`            | —                   | Caller has `viewer` role                                                  |
| `404 Not Found`            | `not_found`         | `planUlid` or `planCaseUlid` does not exist or belongs to a different org |
| `404 Not Found`            | `not_found`         | `assigneeUlid` does not resolve to a live org member                      |
| `422 Unprocessable Entity` | `validation_failed` | Missing `assigneeUlid` key, non-ULID value, or unknown key                |

### Example

```bash
# Assign a member to a plan case
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"assigneeUlid":"01HZMEMBER000000000000000001"}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/cases/01HZPLANCASE000000000000000"

# Clear the assignee (send null)
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"assigneeUlid":null}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/cases/01HZPLANCASE000000000000000"
```

## List the case selection

`GET /api/v1/plans/{planUlid}/cases`

Returns the current case selection for the plan, ordered by position ascending.

### Response

`200` with `{ items }`. See [Selection item fields](#selection-item-fields).

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/cases"
```

## Replace the configuration selection

`PUT /api/v1/plans/{planUlid}/configurations`

Replace-set semantics: the plan's **entire** configuration selection is replaced with the supplied list of configuration value ULIDs. Send an empty `configurationUlids` array to clear the selection.

Each supplied ULID must resolve to a live (non-deleted) configuration value in the plan's project. An unresolved, soft-deleted, or cross-project ULID returns `404 not_found` and leaves the selection unchanged. Duplicate ULIDs in the request return `422 validation_failed`.

### Request body

| Field                | Required | Notes                                                                                                          |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `configurationUlids` | yes      | Array of live configuration value ULIDs. Duplicates rejected (`422`). Values must belong to the plan's project |

### Response

`200` with `{ items }` — the new configuration selection. See [Configuration selection item fields](#configuration-selection-item-fields).

### Example

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"configurationUlids":["01HZCHROME000000000000000000","01HZWINDOWS00000000000000000"]}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/configurations"
```

## List the configuration selection

`GET /api/v1/plans/{planUlid}/configurations`

Returns the plan's current configuration selection — the set of configuration value ULIDs the plan uses to compute its combination matrix.

### Response

`200` with `{ items }`. See [Configuration selection item fields](#configuration-selection-item-fields).

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/configurations"
```

## Set excluded combinations

`PUT /api/v1/plans/{planUlid}/excluded-combinations`

Replace-set semantics: the plan's excluded combination keys are replaced with exactly the supplied set. A `comboKey` that does not match any current candidate combination is accepted and stored as an inert exclusion — it will not cause an error.

### Request body

| Field       | Required | Notes                                                                                                                                                                                                                                         |
| ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `comboKeys` | yes      | Array of combination key strings. A combination key is the sorted, colon-joined list of the combination's configuration value ULIDs (e.g. `01HZCHROME:01HZWINDOWS`). Duplicates rejected (`422`). Send an empty array to clear all exclusions |

### Response

`200` with `{ comboKeys }` — the plan's new excluded combination key set.

### Example

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"comboKeys":["01HZFIREFOX0000000000000000:01HZWINDOWS00000000000000000"]}' \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/excluded-combinations"
```

## Combinations preview

`GET /api/v1/plans/{planUlid}/combinations`

Returns the candidate combinations computed as the cartesian product of the plan's current configuration selection (one value per participating group). Each combination carries its `comboKey`, the ordered per-group values, and an `included` flag (`false` when the `comboKey` is in the plan's excluded set). The response also returns the total cartesian count and the count of included combinations.

A plan with no configuration selection returns exactly one candidate combination (the empty combination, `totalCount = 1`, `includedCount = 1`).

### Response

`200` with:

| Field           | Type   | Notes                                                                                  |
| --------------- | ------ | -------------------------------------------------------------------------------------- |
| `items`         | array  | Candidate combination objects. See [Combination item fields](#combination-item-fields) |
| `totalCount`    | number | Total number of candidate combinations (including excluded)                            |
| `includedCount` | number | Number of combinations with `included = true`                                          |

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/plans/01HZPLANULID00000000000000/combinations"
```

## Creating a run for a plan

There is no `POST /api/v1/plans/{planUlid}/runs`. Create a run for a plan with
`POST /api/v1/projects/{projectId}/runs`, passing `planUlid` and, for a configured
plan, the combination's `configurationUlids`. This call always creates **exactly
one** run — link a plan, seed its cases, and tag one combination, all in a single
request. To cover several combinations, call it once per combination.

When `planUlid` is supplied and `caseUlids` is omitted, the run's case selection and
per-case assignees are seeded from the plan's current selection, following this
precedence (first match wins):

1. An explicit per-case entry in `caseAssignees` (the request body)
2. The plan's stored per-case assignee for that case
3. `defaultAssigneeUlid` from the request body
4. Unassigned (`null`)

A plan assignee who is no longer a member of the organization at request time seeds
to `null` (unassigned) without failing the request.

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Smoke — Chrome","planUlid":"01HZPLANULID00000000000000","configurationUlids":["01HZCFGVALCHROME0000000000"]}' \
  "https://app.probara.net/api/v1/projects/PROJ/runs"
```

The plan's runs are listed most-recent first via `GET /api/v1/plans/{planUlid}/runs`.

## Response fields

The plan detail (`GET /api/v1/plans/{planUlid}`) and the `PATCH` response return the full plan object. The plan list (`GET /api/v1/projects/{projectId}/plans`) returns the same shape per item but omits `progress`.

| Field           | Type           | Notes                                                                                |
| --------------- | -------------- | ------------------------------------------------------------------------------------ |
| `ulid`          | string         | Server-assigned ULID for the plan                                                    |
| `projectUlid`   | string         | ULID of the project the plan belongs to                                              |
| `planNumber`    | number         | Monotonically allocated number within the project                                    |
| `name`          | string         | Plan name                                                                            |
| `description`   | string \| null | Optional description                                                                 |
| `author`        | object \| null | Authorship snapshot (see below). `null` for legacy rows                              |
| `caseCount`     | number         | Current count of live cases in the selection                                         |
| `status`        | string         | Current lifecycle status: `draft`, `active`, or `completed`                          |
| `milestoneUlid` | string \| null | ULID of the linked milestone, or `null` when no milestone is linked                  |
| `milestone`     | object \| null | Linked milestone reference (`{ ulid, name }`), or `null` when no milestone is linked |
| `progress`      | object         | Computed progress rollup (detail and PATCH responses only — see below)               |
| `createdAt`     | number         | Creation timestamp (Unix epoch milliseconds)                                         |
| `updatedAt`     | number         | Last-update timestamp (Unix epoch milliseconds)                                      |

### Author fields

| Field         | Type                                                                                         | Notes                                                                                                                            |
| ------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `kind`        | `"user"` \| `"api_token"`                                                                    | How the plan was created                                                                                                         |
| `ulid`        | string                                                                                       | ULID of the user or API token                                                                                                    |
| `displayName` | string                                                                                       | Display name (user kind only)                                                                                                    |
| `avatarKey`   | string \| null                                                                               | Avatar object key (user kind only)                                                                                               |
| `name`        | string                                                                                       | Token name (api_token kind only)                                                                                                 |
| `app`         | `"junit"` \| `"playwright"` \| `"jest"` \| `"cypress"` \| `"vitest"` \| `"cucumber"` \| null | The Integrations app of an [app token](https://docs.probara.net/en/developers/authentication/#app-tokens); `null` for an API token (api_token kind only) |

### Progress fields

`progress` is computed at read time from the plan's runs. It is present on the plan detail (`GET /api/v1/plans/{planUlid}`) and the `PATCH` response. The plan **list** does not carry `progress`.

| Field             | Type   | Notes                                                                         |
| ----------------- | ------ | ----------------------------------------------------------------------------- |
| `totalRuns`       | number | Total number of runs generated from this plan                                 |
| `closedRuns`      | number | Number of those runs that are in `closed` state                               |
| `counts`          | object | Overall outcome counts summed across all the plan's runs (see below)          |
| `byConfiguration` | array  | Per-configuration breakdown rows (see below). Empty when the plan has no runs |

#### Outcome counts (`counts` and per-breakdown-row `counts`)

| Field      | Type   | Notes                                  |
| ---------- | ------ | -------------------------------------- |
| `passed`   | number | Sum of passed test results             |
| `failed`   | number | Sum of failed test results             |
| `blocked`  | number | Sum of blocked test results            |
| `skipped`  | number | Sum of skipped test results            |
| `untested` | number | Sum of untested (pending) test results |

These are summed from the denormalized per-status counters maintained on each `test_run` row. No re-scanning of individual results occurs at read time.

#### Per-configuration breakdown (`byConfiguration` items)

Each item in `byConfiguration` corresponds to one configuration combination (or the unconfigured bucket):

| Field        | Type   | Notes                                                                                                                                                                        |
| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `comboKey`   | string | Combination key: the run's configuration value ULIDs sorted ascending and joined with `:`. Empty string (`""`) for the unconfigured bucket (runs with no configuration tags) |
| `values`     | array  | Ordered per-group value entries (`{ groupName, valueName }`). Empty array for the unconfigured bucket                                                                        |
| `totalRuns`  | number | Number of runs in this combination                                                                                                                                           |
| `closedRuns` | number | Number of those runs that are closed                                                                                                                                         |
| `counts`     | object | Outcome counts for this combination (same shape as the top-level `counts`)                                                                                                   |

The sum of all `byConfiguration[*].counts` equals the top-level `counts`. Runs with no configuration tags appear in a single unconfigured bucket (empty `comboKey`, empty `values`).

## Selection item fields

Returned by `GET /plans/{planUlid}/cases` and `PUT /plans/{planUlid}/cases`.

| Field          | Type           | Notes                                                                                       |
| -------------- | -------------- | ------------------------------------------------------------------------------------------- |
| `ulid`         | string         | Server-assigned ULID of the selection row                                                   |
| `caseUlid`     | string         | ULID of the referenced test case                                                            |
| `displayId`    | string         | Display ID of the case (e.g. `TC-42`)                                                       |
| `title`        | string         | Current title of the test case                                                              |
| `suiteName`    | string \| null | Suite name for grouping, or `null`                                                          |
| `position`     | number         | 0-based position within the plan's selection                                                |
| `assigneeUlid` | string \| null | ULID of the member assigned to this case in the plan, or `null` when the case is unassigned |

## Configuration selection item fields

Each item in the `GET /plans/{planUlid}/configurations` and `PUT /plans/{planUlid}/configurations` responses:

| Field               | Type   | Notes                                                 |
| ------------------- | ------ | ----------------------------------------------------- |
| `configurationUlid` | string | ULID of the configuration value                       |
| `groupUlid`         | string | ULID of the configuration group this value belongs to |
| `groupName`         | string | Display name of the group (e.g. `Browser`)            |
| `valueName`         | string | Display name of the value (e.g. `Chrome`)             |

## Combination item fields

Each item in the `GET /plans/{planUlid}/combinations` response:

| Field      | Type    | Notes                                                                                                                                                                                    |
| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `comboKey` | string  | Deterministic combination key: the combination's configuration value ULIDs sorted ascending and joined with `:`. Use this key in `PUT /excluded-combinations` to exclude the combination |
| `values`   | array   | Per-group value entries, ordered by group position. Each entry has `groupUlid`, `groupName`, `configurationUlid`, and `valueName`                                                        |
| `included` | boolean | `true` when this combination is not in the plan's excluded set; `false` when excluded                                                                                                    |
