# API de comentarios en casos de prueba

> CRUD, cuerpos de texto enriquecido, respuestas en hilo, fijado y menciones para comentarios en un caso de prueba — paginación por cursor, RBAC y eliminación en cascada.

Los comentarios de casos de prueba se adjuntan a un caso específico mediante un almacén genérico de comentarios `(entity_type='test_case', entity_id)`, compartido con los [comentarios de defectos](https://docs.probara.net/es/developers/api/defects/). Ambas superficies son estructuralmente idénticas y solo difieren en la ruta de la entidad. Los comentarios se limitan a la organización mediante el ULID del caso de prueba.

Los comentarios admiten un nivel de respuestas en hilo, fijado y resolución de menciones `@`. El cuerpo es [texto enriquecido](https://docs.probara.net/es/developers/api-reference/#campos-de-texto-enriquecido): envíalo como Markdown en `body`, o como documento en `bodyDocument`. Las imágenes van insertadas en el cuerpo, preparadas con `POST /api/v1/images:stage`; un comentario no tiene lista de adjuntos.

## Endpoints

| Método   | Ruta                                                       | Descripción                                                |
| -------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| `GET`    | `/api/v1/test-cases/{caseUlid}/comments`                   | Listar comentarios de primer nivel (paginación por cursor) |
| `POST`   | `/api/v1/test-cases/{caseUlid}/comments`                   | Agregar un comentario, o una respuesta usando `parentUlid` |
| `PATCH`  | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}`     | Editar un comentario o respuesta                           |
| `DELETE` | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}`     | Eliminar un comentario (elimina en cascada sus respuestas) |
| `POST`   | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin` | Fijar un comentario de primer nivel                        |
| `DELETE` | `/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin` | Desfijar un comentario de primer nivel                     |

`{caseUlid}` debe pertenecer a la organización activa. El acceso cruzado entre organizaciones devuelve `404 not_found`.

## Autorización

Todos los endpoints requieren una sesión autenticada o un token Bearer con al menos acceso `viewer`.

| Operación                       | Rol mínimo                                                |
| ------------------------------- | --------------------------------------------------------- |
| `GET`                           | Visualizador                                              |
| `POST` (comentario o respuesta) | Miembro                                                   |
| `PATCH`                         | Miembro (propio comentario únicamente)                    |
| `DELETE`                        | Miembro (propio) o Administrador/Propietario (cualquiera) |
| `POST` / `DELETE .../pin`       | Miembro (cualquier comentario de primer nivel)            |

## GET — listar comentarios

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

Devuelve los comentarios de **primer nivel** (`parentUlid: null`) con paginación por cursor.

### Parámetros de consulta

| Parámetro | Tipo                 | Descripción                                                                                                                                                                                                                 |
| --------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cursor`  | string               | Cursor ULID de la página anterior (`nextCursor`).                                                                                                                                                                           |
| `limit`   | integer              | Tamaño de página. Predeterminado `50`, máximo `200`.                                                                                                                                                                        |
| `sort`    | `oldest` \| `newest` | Orden de los comentarios de primer nivel. Predeterminado `oldest` (sin cambios respecto al comportamiento público previo). Las respuestas siempre se mantienen en orden cronológico bajo su padre, sin importar este valor. |

### Respuesta (`200 OK`)

```json
{
  "items": [
    {
      "ulid": "01J…",
      "body": "Reproducido en staging con una sesión nueva del navegador.",
      "bodyDocument": {
        "type": "doc",
        "attrs": { "version": 1 },
        "content": [
          {
            "type": "paragraph",
            "content": [
              {
                "type": "text",
                "text": "Reproducido en staging con una sesión nueva del navegador."
              }
            ]
          }
        ]
      },
      "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": "Confirmado — mismo resultado de mi lado. @[01J…GRACE]",
          "bodyDocument": {
            "type": "doc",
            "attrs": { "version": 1 },
            "content": [
              {
                "type": "paragraph",
                "content": [
                  { "type": "text", "text": "Confirmado — mismo resultado de mi lado. " },
                  { "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) y `bodyDocument` (el documento) contienen el mismo texto. Al editar, devuelve uno de los dos, nunca ambos.
- `attachments` siempre es `[]`. Las imágenes de un comentario van insertadas en su cuerpo.
- `author` es `null` cuando el miembro fue removido de la organización.
- `editedAt` es `null` cuando el comentario nunca se ha editado.
- `nextCursor` es `null` en la última página.
- `pinned` es el conjunto **completo** de comentarios de primer nivel fijados para este caso de prueba, ordenado por `pinnedAt` ascendente, independiente del cursor paginado de `items` — muéstralo encima del feed. Es idéntico sin importar `sort`.
- Un objeto de respuesta nunca lleva `replyCount` ni `replies` — la profundidad del hilo está limitada a un nivel.
- El arreglo `replies` de un elemento de primer nivel está en orden cronológico ascendente y limitado a 100 entradas inline.

**Importante:** las respuestas **no** aparecen en la enumeración plana de `items[]` — solo son accesibles a través de `replies[]` de su comentario padre. Este es un cambio aditivo pero no compatible byte a byte con los clientes que asumían que `items[]` enumeraba todos los comentarios: recorre `replies` de cada elemento si necesitas el contenido de las respuestas, y usa `replyCount` si solo necesitas el conteo.

## POST — agregar un comentario o respuesta

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

### Cuerpo de la solicitud

| Campo          | Requerido      | Notas                                                                                                                                                                                                                  |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `body`         | uno de los dos | Markdown. Sin espacios sobrantes, máximo 5000 caracteres, y debe contener contenido: los valores que solo contienen espacios son rechazados. Puede contener tokens de mención `@[ulid]` (ver [Menciones](#menciones)). |
| `bodyDocument` | uno de los dos | El documento versionado del cuerpo (ver [Campos de texto enriquecido](https://docs.probara.net/es/developers/api-reference/#campos-de-texto-enriquecido)). Envía `body` o `bodyDocument`, nunca ambos.                                         |
| `parentUlid`   | no             | ULID de un comentario **de primer nivel** existente en el mismo caso de prueba. Crea una respuesta de profundidad 1. Omítelo para crear un comentario de primer nivel (comportamiento sin cambios).                    |

**Errores:**

- `422 validation_failed` — se envían `body` y `bodyDocument` a la vez, ninguno contiene contenido, `body` supera los 5000 caracteres, la solicitud incluye una clave que este endpoint no acepta (como `attachments`), o `parentUlid` apunta a un comentario que es en sí mismo una respuesta (responder a una respuesta está rechazado). El mensaje describe el problema; `details` contiene el campo que falló.
- `422 validation_failed` con `details.field` igual a `body` — un rechazo de texto enriquecido. `details.code` indica la regla que rompe el cuerpo, por ejemplo `too_large` para un documento que supera el límite de 256 KiB (ver [Campos de texto enriquecido](https://docs.probara.net/es/developers/api-reference/#campos-de-texto-enriquecido)).
- `422 validation_failed` con `details.code` igual a `unknown_image` — el documento menciona una imagen que nunca se preparó o que expiró. `details.ulids` enumera esas imágenes.
- `404 not_found` — `parentUlid` no corresponde a un comentario de primer nivel de este caso de prueba (entidad u organización incorrecta).
- `403` — rol visualizador.

### Respuesta (`201 Created`)

```json
{
  "ulid": "01J…",
  "body": "También se ve bien en móvil.",
  "bodyDocument": {
    "type": "doc",
    "attrs": { "version": 1 },
    "content": [
      {
        "type": "paragraph",
        "content": [{ "type": "text", "text": "También se ve bien en móvil." }]
      }
    ]
  },
  "author": { "ulid": "01J…", "displayName": "Ada", "avatarKey": null, "avatarVersion": 0 },
  "createdAt": 1700000000000,
  "editedAt": null,
  "attachments": [],
  "parentUlid": null,
  "pinnedAt": null,
  "pinnedBy": null,
  "mentions": [],
  "replyCount": 0,
  "replies": []
}
```

**Auditoría:** emite `test_case.commented` con `metadata.excerpt` (como máximo 140 caracteres del texto plano del cuerpo).

## PATCH — editar un comentario

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

Solo el autor original del comentario puede editarlo (exclusivo del autor, ya sea de primer nivel o respuesta). Los administradores y propietarios no pueden editar el comentario de otro miembro. Las menciones se vuelven a resolver a partir del nuevo cuerpo en cada edición.

### Cuerpo de la solicitud

| Campo          | Requerido      | Notas                                                                                                 |
| -------------- | -------------- | ----------------------------------------------------------------------------------------------------- |
| `body`         | uno de los dos | Cuerpo Markdown de reemplazo, con la misma validación que POST.                                       |
| `bodyDocument` | uno de los dos | Documento de reemplazo, con la misma validación que POST. Envía `body` o `bodyDocument`, nunca ambos. |

### Respuesta (`200 OK`)

Comentario actualizado con `editedAt` establecido en el momento actual y `mentions[]` reflejando el nuevo cuerpo.

**Errores:** `403` si no eres el autor. `404` si el comentario no existe en este caso (`caseUlid` o organización incorrectos). `422 validation_failed` para los mismos rechazos que POST.

**Auditoría:** emite `test_case.comment_updated` con `metadata.excerpt`.

## DELETE — eliminar un comentario

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

Los autores pueden eliminar su propio comentario. Los administradores y propietarios pueden eliminar cualquier comentario. Eliminar un comentario de **primer nivel** elimina en cascada todas sus respuestas. Cada comentario eliminado libera las imágenes insertadas que contiene su cuerpo, de modo que una imagen que ningún otro texto ni revisión almacenada usa se elimina junto con él.

### Respuesta (`204 No Content`)

**Errores:** `403` si el miembro intenta eliminar el comentario de otro miembro.

**Auditoría:** emite `test_case.comment_deleted` por el comentario eliminado, más un `test_case.comment_deleted` adicional por cada respuesta eliminada en cascada.

## POST — fijar un comentario

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

Fija un comentario de **primer nivel** para que siempre aparezca en el arreglo `pinned[]` de la respuesta de listado, mostrado antes del feed paginado sin importar `sort`. Cualquier miembro o rol superior puede fijar cualquier comentario de primer nivel (no solo los propios). No hay límite en la cantidad de comentarios fijados por caso de prueba.

**Errores:** `422 validation_failed` si `commentUlid` corresponde a una respuesta — solo los comentarios de primer nivel pueden fijarse. `403` para el rol visualizador. `404` si el comentario no existe en este caso.

### Respuesta (`200 OK`)

Comentario actualizado con `pinnedAt` (epoch ms) y `pinnedBy` (resumen del actor que lo fijó) establecidos.

**Auditoría:** emite `test_case.comment_pinned`.

## DELETE — desfijar un comentario

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

Limpia `pinnedAt`/`pinnedBy`. Cualquier miembro o rol superior puede desfijar.

### Respuesta (`200 OK`)

Comentario actualizado con `pinnedAt: null`, `pinnedBy: null`.

**Errores:** `403` para el rol visualizador. `404` si el comentario no existe en este caso.

**Auditoría:** emite `test_case.comment_unpinned`.

## Menciones

El cuerpo de un comentario puede incluir tokens `@[ulid]` que referencian a un miembro de la organización por su ULID (la forma ULID, no un nombre visible, para que los cambios de nombre nunca rompan una mención). En `bodyDocument`, la misma mención es un nodo `mention` cuyo `attrs.id` es ese ULID. Al crear o editar, el servidor:

1. Extrae cada mención del cuerpo: el token `@[ulid]` en Markdown o el nodo `mention` en el documento.
2. Resuelve cada mención solo contra los miembros de la **organización activa** — un ULID de otra organización, o uno que no corresponde a un miembro, se deja como texto plano sin formato y no crea ninguna fila de mención.
3. Persiste una fila por cada mención válida distinta, y reconcilia el conjunto en cada edición (las menciones eliminadas se borran y se agregan las nuevas).

El arreglo `mentions[]` de la respuesta siempre refleja el nombre visible **actual** del miembro mencionado — renombrar a un miembro actualiza cada mención pasada que lo referencia. Las menciones nunca generan una notificación ni un correo.

## Eliminación en cascada

Cuando se elimina un caso de prueba (`DELETE /api/v1/test-cases/{caseUlid}`), todos sus comentarios y respuestas se eliminan junto con las imágenes insertadas que contienen, en la misma transacción que el caso. No se requiere ninguna llamada adicional a la API; si la eliminación se rechaza, los comentarios se conservan.

## Ejemplos

```bash
# Listar la página de comentarios más recientes primero
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments?sort=newest"

# Agregar un comentario de primer nivel
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Verificado en Safari 17 — pasa."}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"

# Responder a un comentario de primer nivel, mencionando a un compañero
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Gracias @[01J...GRACE] — confirmado también aquí.","parentUlid":"01J...CMT"}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"

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

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

# Editar un comentario
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Nota actualizada tras volver a probar."}' \
  "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"

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