# Test case comments API

> CRUD, rich-text bodies, threaded replies, pinning, and mentions for test case comments — cursor pagination, RBAC, and cascade delete.

Test case comments are attached to a specific test case using a `(entity_type='test_case', entity_id)` generic comments store, shared with [defect comments](https://docs.probara.net/en/developers/api/defects/) — both surfaces are structurally identical, differing only in the entity path. Comments are scoped to the organization via the test case ULID.

Comments support one level of threaded replies, pinning, and `@mention` resolution. A body is [rich text](https://docs.probara.net/en/developers/api-reference/#rich-text-fields): send it as Markdown in `body`, or as a document in `bodyDocument`. Images sit inline in the body, staged with `POST /api/v1/images:stage`; a comment has no attachment list.

## Endpoints

| Method   | Path                                                       | Description                                 |
| -------- | ---------------------------------------------------------- | ------------------------------------------- |
| `GET`    | `/api/v1/test-cases/{caseUlid}/comments`                   | List top-level comments (cursor pagination) |
| `POST`   | `/api/v1/test-cases/{caseUlid}/comments`                   | Add a comment, or a reply via `parentUlid`  |
| `PATCH`  | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}`     | Edit a comment or reply                     |
| `DELETE` | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}`     | Delete a comment (cascades to its replies)  |
| `POST`   | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin` | Pin a top-level comment                     |
| `DELETE` | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin` | Unpin a top-level comment                   |

`{caseUlid}` must belong to the active organization. Cross-tenant access returns `404 not_found`.

## Authorization

All endpoints require an authenticated session or a Bearer API token with at least `viewer` access.

| Operation                 | Minimum role                      |
| ------------------------- | --------------------------------- |
| `GET`                     | Viewer                            |
| `POST` (comment or reply) | Member                            |
| `PATCH`                   | Member (own comment only)         |
| `DELETE`                  | Member (own) or Admin/Owner (any) |
| `POST` / `DELETE .../pin` | Member (any top-level comment)    |

## GET — list comments

`GET /api/v1/test-cases/{caseUlid}/comments`

Returns **top-level** comments (`parentUlid: null`) with cursor pagination.

### Query parameters

| Parameter | Type                 | Description                                                                                                                                     |
| --------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `cursor`  | string               | ULID cursor from the previous page (`nextCursor`).                                                                                              |
| `limit`   | integer              | Page size. Default `50`, max `200`.                                                                                                             |
| `sort`    | `oldest` \| `newest` | Top-level ordering. Default `oldest` (unchanged public default). Replies always stay chronological under their parent regardless of this value. |

### Response (`200 OK`)

```json
{
  "items": [
    {
      "ulid": "01J…",
      "body": "Reproduced on staging with a fresh browser session.",
      "bodyDocument": {
        "type": "doc",
        "attrs": { "version": 1 },
        "content": [
          {
            "type": "paragraph",
            "content": [
              { "type": "text", "text": "Reproduced on staging with a fresh browser session." }
            ]
          }
        ]
      },
      "author": {
        "ulid": "01J…",
        "displayName": "Ada Lovelace",
        "avatarKey": null,
        "avatarVersion": 0
      },
      "createdAt": 1700000000000,
      "editedAt": null,
      "attachments": [],
      "parentUlid": null,
      "pinnedAt": null,
      "pinnedBy": null,
      "mentions": [],
      "replyCount": 1,
      "replies": [
        {
          "ulid": "01J…RPLY",
          "body": "Confirmed — same result on my end. @[01J…GRACE]",
          "bodyDocument": {
            "type": "doc",
            "attrs": { "version": 1 },
            "content": [
              {
                "type": "paragraph",
                "content": [
                  { "type": "text", "text": "Confirmed — same result on my end. " },
                  { "type": "mention", "attrs": { "id": "01J…GRACE" } }
                ]
              }
            ]
          },
          "author": {
            "ulid": "01J…",
            "displayName": "Grace Hopper",
            "avatarKey": null,
            "avatarVersion": 0
          },
          "createdAt": 1700000100000,
          "editedAt": null,
          "attachments": [],
          "parentUlid": "01J…",
          "pinnedAt": null,
          "pinnedBy": null,
          "mentions": [{ "ulid": "01J…GRACE", "displayName": "Grace Hopper" }]
        }
      ]
    }
  ],
  "nextCursor": "01J…",
  "pinned": []
}
```

- `body` (Markdown) and `bodyDocument` (the document) hold the same text. Send either one back on edit, never both.
- `attachments` is always `[]`. A comment's images are inline in its body.
- `author` is `null` when the member was removed from the organization.
- `editedAt` is `null` when the comment has never been edited.
- `nextCursor` is `null` on the last page.
- `pinned` is the **complete** pinned top-level set for this test case, ordered by `pinnedAt` ascending, independent of the paginated `items` cursor — render it above the feed. It is identical regardless of `sort`.
- A reply object never carries `replyCount` or `replies` — thread depth is capped at one level.
- A top-level item's `replies` array is chronological ascending and capped at 100 inline entries.

**Important:** replies do **not** appear in the flat `items[]` enumeration — they are only reachable via their parent's `replies[]`. This is additive-compatible but not byte-compatible with clients that assumed `items[]` enumerated every comment: iterate `replies` on each item if you need reply content, and use `replyCount` if you only need a count.

## POST — add a comment or reply

`POST /api/v1/test-cases/{caseUlid}/comments`

### Request body

| Field          | Required | Notes                                                                                                                                                                        |
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `body`         | one of   | Markdown. Trimmed, at most 5000 characters, and it must name content: whitespace-only values are rejected. May contain `@[ulid]` mention tokens (see [Mentions](#mentions)). |
| `bodyDocument` | one of   | The versioned document of the body (see [Rich text fields](https://docs.probara.net/en/developers/api-reference/#rich-text-fields)). Send `body` or `bodyDocument`, never both.                      |
| `parentUlid`   | no       | ULID of an existing **top-level** comment on the same test case. Creates a depth-1 reply. Omit to create a top-level comment (unchanged behavior).                           |

**Errors:**

- `422 validation_failed` — both `body` and `bodyDocument` are sent, neither names content, `body` exceeds 5000 characters, the request names a key this endpoint does not accept (such as `attachments`), or `parentUlid` points to a comment that is itself a reply (reply-to-reply is rejected). The message names the problem; `details` holds the field that failed.
- `422 validation_failed` with `details.field` set to `body` — a rich-text refusal. `details.code` names the rule the body breaks, for example `too_large` for a document over the 256 KiB cap (see [Rich text fields](https://docs.probara.net/en/developers/api-reference/#rich-text-fields)).
- `422 validation_failed` with `details.code` `unknown_image` — the document names an image that was never staged or has expired. `details.ulids` lists those images.
- `404 not_found` — `parentUlid` does not resolve to a top-level comment on this test case (wrong entity or wrong organization).
- `403` — viewer role.

### Response (`201 Created`)

```json
{
  "ulid": "01J…",
  "body": "Looks good on mobile too.",
  "bodyDocument": {
    "type": "doc",
    "attrs": { "version": 1 },
    "content": [
      { "type": "paragraph", "content": [{ "type": "text", "text": "Looks good on mobile too." }] }
    ]
  },
  "author": { "ulid": "01J…", "displayName": "Ada", "avatarKey": null, "avatarVersion": 0 },
  "createdAt": 1700000000000,
  "editedAt": null,
  "attachments": [],
  "parentUlid": null,
  "pinnedAt": null,
  "pinnedBy": null,
  "mentions": [],
  "replyCount": 0,
  "replies": []
}
```

**Audit:** emits `test_case.commented` with `metadata.excerpt` (at most 140 characters of the plain text of the body).

## PATCH — edit a comment

`PATCH /api/v1/test-cases/{caseUlid}/comments/{commentUlid}`

Only the comment's original author may edit it (author-only, regardless of top-level or reply). Admins and owners cannot edit another member's comment. Mentions are re-resolved from the new body on every edit.

### Request body

| Field          | Required | Notes                                                                                     |
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `body`         | one of   | Replacement Markdown body, same validation as POST.                                       |
| `bodyDocument` | one of   | Replacement document, same validation as POST. Send `body` or `bodyDocument`, never both. |

### Response (`200 OK`)

Updated comment with `editedAt` set to the current timestamp and `mentions[]` reflecting the new body.

**Errors:** `403` if not the author. `404` if the comment does not exist on this case (wrong `caseUlid` / wrong org). `422 validation_failed` for the same refusals as POST.

**Audit:** emits `test_case.comment_updated` with `metadata.excerpt`.

## DELETE — delete a comment

`DELETE /api/v1/test-cases/{caseUlid}/comments/{commentUlid}`

Authors may delete their own comment. Admins and owners may delete any comment. Deleting a **top-level** comment cascades to delete all of its replies too. Each deleted comment releases the inline images its body holds, so an image no other text or stored revision uses is removed with it.

### Response (`204 No Content`)

**Errors:** `403` if the caller is a member trying to delete another member's comment.

**Audit:** emits `test_case.comment_deleted` for the deleted comment, plus one additional `test_case.comment_deleted` per cascaded reply.

## POST — pin a comment

`POST /api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin`

Pins a **top-level** comment so it always appears in the `pinned[]` array of the list response, rendered ahead of the paginated feed regardless of `sort`. Any member or above may pin any top-level comment (not just their own). There is no cap on the number of pinned comments per test case.

**Errors:** `422 validation_failed` if `commentUlid` refers to a reply — only top-level comments can be pinned. `403` for viewer role. `404` if the comment does not exist on this case.

### Response (`200 OK`)

Updated comment with `pinnedAt` (epoch ms) and `pinnedBy` (actor summary of who pinned it) set.

**Audit:** emits `test_case.comment_pinned`.

## DELETE — unpin a comment

`DELETE /api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin`

Clears `pinnedAt`/`pinnedBy`. Any member or above may unpin.

### Response (`200 OK`)

Updated comment with `pinnedAt: null`, `pinnedBy: null`.

**Errors:** `403` for viewer role. `404` if the comment does not exist on this case.

**Audit:** emits `test_case.comment_unpinned`.

## Mentions

Comment bodies may embed `@[ulid]` tokens referencing an organization member by ULID (the ULID form, not a display-name string, so renames never break a mention). In `bodyDocument`, the same mention is a `mention` node whose `attrs.id` is that ULID. On create/edit, the server:

1. Extracts every mention from the body, the `@[ulid]` token in Markdown or the `mention` node in the document.
2. Resolves each mention against the **active organization's** membership only — a ulid from another organization, or one that doesn't resolve to a member, is silently left as plain, unstyled text and creates no mention row.
3. Persists one row per distinct valid mention, and reconciles the set on every edit (removed mentions are cleared, new ones are added).

The response's `mentions[]` array always reflects the mentioned member's **current** display name — renaming a member updates every past mention that references them. Mentions never trigger a notification or email.

## Cascade delete

When a test case is deleted (`DELETE /api/v1/test-cases/{caseUlid}`), all comments and replies for that test case are deleted along with the inline images they hold, in the same transaction as the case. No additional API call is required; if the delete is refused, the comments are kept.

## Examples

```bash
# List the newest-first page of comments on a test case
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments?sort=newest"

# Add a top-level comment
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Verified on Safari 17 — passes."}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"

# Reply to a top-level comment, mentioning a teammate
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Thanks @[01J...GRACE] — confirmed here too.","parentUlid":"01J...CMT"}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"

# Pin a comment
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT/pin"

# Unpin a comment
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT/pin"

# Edit a comment
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Updated note after re-testing."}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"

# Delete a comment
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"
```
