openapi: 3.0.3
info:
  title: TestDino Public API
  version: 1.0.0
  description: |
    Public API for TestDino: read access to test analytics, manual tests, and project data, plus
    manual-testing write endpoints.

    **Read (GET) endpoints** cover analytics, runs, cases, and project data. **Write endpoints
    (POST/PATCH)** create and update manual-testing entities: suites, cases, releases, sessions, and
    manual runs (including per-case verdicts). Query parameters are **endpoint-specific**; see each
    operation for supported filters, enums, and defaults.

    Writes require a PAT whose owner holds a writer role (owner/admin/member) on the project's
    organization; a viewer-role token gets `403`.

    ## Authentication

    All endpoints require a Bearer token in the `Authorization` header:
    ```
    Authorization: Bearer td_pat_<token>
    ```

    Use a user PAT (`td_pat_` prefix) scoped to the organization/project.

    ## Rate Limiting

    - **Reads (per-token):** 100 requests/minute
    - **Writes (per-token):** 60 requests/minute
    - **Manual run creation (per-token):** 10 requests/minute
    - **Per-IP (pre-auth):** 200 requests/minute
    - **PDF generation:** 1 request/minute per token

    Standard headers: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`

    ## Response Format

    **Success:**
    ```json
    { "success": true, "data": { ... }, "pagination": { ... } }
    ```

    **Error:**
    ```json
    { "success": false, "error": { "code": "ERROR_CODE", "message": "Human-readable message" } }
    ```

    ## Time windows and date filters

    There is **no global date-filter convention**. Each endpoint documents its own parameters:

    | Endpoint family | Parameter | Allowed values |
    |-----------------|-----------|------------------|
    | Test runs list | `start_date`, `end_date` | RFC3339 timestamps (both required together) |
    | Explorer, specs, analytics | `days` | Integer snapped to **7, 30, or 90** (see each endpoint) |
    | Test runs list | — | No preset `period` / `dateRange` params |
    | Dashboard, filters | — | **No query params** (fixed snapshot) |

    ## Served by

  contact:
    name: TestDino Support
    url: https://docs.testdino.com
servers:
  - url: https://api.testdino.com/api/v1/public
    description: Production

security:
  - BearerAuth: []

tags:
  - name: Token Info
    description: Token introspection and project metadata
  - name: Test Runs
    description: List, inspect, and drill into test runs
  - name: Test Cases
    description: Automated test case details and history
  - name: Specs
    description: Project-level spec file health
  - name: Manual Tests
    description: Manual test suites and cases (read-only)
  - name: Test Case Explorer
    description: Aggregated test case metrics explorer
  - name: Debug Bundle
    description: "Debugging payload at 3 scopes (run, suite, test): error, steps, failure window, attempt history, artifacts, trace, deep links, and a copy-paste retry command. Designed for AI agents and CI scripts."
  - name: Dashboard
    description: "Project health overview"
  - name: Filters
    description: Available filter values (environments, branches, developers, tags)
  - name: Reports
    description: PDF report generation and download
  - name: Analytics
    description: "Consolidated analytics summary and test case execution performance"
  - name: Webhooks
    description: "Outbound webhook subscriptions and delivery history"
  - name: Usage
    description: Subscription usage and limits

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: "User PAT (td_pat_) scoped to the target project"

  schemas:
    Pagination:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer
        hasNext:
          type: boolean
        hasPrev:
          type: boolean

    SuccessEnvelope:
      type: object
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          type: object
        pagination:
          $ref: '#/components/schemas/Pagination'

    ErrorEnvelope:
      type: object
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string

    # ── Manual Test Schemas ──────────────────────────────────────────────

    Classification:
      type: object
      properties:
        status:
          type: string
          description: "Test case status"
          example: "active"
          enum: [active, draft, deprecated]
        severity:
          type: string
          description: "Severity level"
          example: "critical"
          enum: [blocker, critical, major, normal, minor, trivial, not_set]
        priority:
          type: string
          description: "Priority level"
          example: "high"
          enum: [critical, high, medium, low, not_set]
        type:
          type: string
          description: "Test type"
          example: "functional"
          enum: [smoke, regression, functional, integration, e2e, api, unit, performance, security, accessibility, usability, compatibility, acceptance, exploratory, other]
        layer:
          type: string
          description: "Test layer"
          example: "e2e"
          enum: [e2e, api, unit, not_set]
        behavior:
          type: string
          description: "Expected behavior type"
          example: "positive"
          enum: [positive, negative, destructive, not_set]

    Automation:
      type: object
      properties:
        status:
          type: string
          description: "Automation status"
          example: "manual"
          enum: [manual, automated]
        isFlaky:
          type: boolean
        isMuted:
          type: boolean

    ClassicStep:
      type: object
      properties:
        step:
          type: integer
          description: "Step number"
        action:
          type: string
          description: "Action to perform"
          maxLength: 5000
        data:
          type: string
          description: "Test data for this step"
          maxLength: 5000
        expectedResult:
          type: string
          description: "Expected result after performing the action"
          maxLength: 5000
        subSteps:
          type: array
          description: "Nested sub-steps (up to 5 levels deep)"
          items:
            $ref: '#/components/schemas/ClassicStep'

    GherkinStep:
      type: object
      properties:
        step:
          type: integer
          description: "Step number"
        keyword:
          type: string
          enum: [Given, When, Then, And, But]
        text:
          type: string
          description: "Step description"
          maxLength: 5000
        subSteps:
          type: array
          description: "Nested sub-steps (up to 5 levels deep)"
          items:
            $ref: '#/components/schemas/GherkinStep'

    Steps:
      type: object
      properties:
        type:
          type: string
          enum: [classic, gherkin]
          description: "Step format — `classic` uses action/data/expectedResult, `gherkin` uses Given/When/Then keywords."
        classic:
          type: array
          items:
            $ref: '#/components/schemas/ClassicStep'
        gherkin:
          type: array
          items:
            $ref: '#/components/schemas/GherkinStep'

    LinkedTest:
      type: object
      properties:
        _id:
          type: string
        fullTitle:
          type: string
          description: "Full title of the linked automated test case"
        displayTitle:
          type: string
          nullable: true
        linkedAt:
          type: string
          format: date-time
        source:
          type: string
          enum: [manual_link, auto_created]

    Attachment:
      type: object
      properties:
        _id:
          type: string
        fileName:
          type: string
        originalFileName:
          type: string
        fileSize:
          type: integer
        mimeType:
          type: string
        blobUrl:
          type: string
        uploadedAt:
          type: string
          format: date-time
        stepRef:
          type: string
          nullable: true
          description: "Dot-separated index path linking the attachment to a step (e.g. `0`, `1.2`)"

    PopulatedUser:
      type: object
      properties:
        _id:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string

    CollapsedUser:
      type: object
      nullable: true
      description: "User reference collapsed to display name and email for public responses."
      properties:
        name:
          type: string
        email:
          type: string

    ManualTestCaseListItem:
      type: object
      properties:
        _id:
          type: string
          example: "tcm_tc_6641a1b2c3d4e5f6a7b8c9d0"
        caseId:
          type: string
          example: "TC-1"
        title:
          type: string
          example: "Verify login with valid credentials"
        description:
          type: string
        preconditions:
          type: string
        postconditions:
          type: string
        metadata:
          type: object
          description: "Public-shaped: internal `project` and `createdBy` are stripped; `lastModifiedBy` is collapsed to name + email."
          properties:
            suite:
              type: object
              nullable: true
              description: "Populated suite (name only)"
              properties:
                _id:
                  type: string
                name:
                  type: string
            lastModifiedBy:
              $ref: '#/components/schemas/CollapsedUser'
        classification:
          $ref: '#/components/schemas/Classification'
        automation:
          $ref: '#/components/schemas/Automation'
        steps:
          $ref: '#/components/schemas/Steps'
        tags:
          type: array
          items:
            type: string
        customFields:
          type: object
          description: "Key-value pairs defined by project-level custom field definitions"
          additionalProperties: true
        linkedTests:
          type: array
          items:
            $ref: '#/components/schemas/LinkedTest'
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    ManualTestSuiteListItem:
      type: object
      properties:
        _id:
          type: string
          example: "tcm_suite_6641a1b2c3d4e5f6a7b8c9d0"
        name:
          type: string
          example: "Login Tests"
        description:
          type: string
        metadata:
          type: object
          description: "Public-shaped: internal `project`, `createdBy`, and `createdAt` are stripped; `lastModifiedBy` is collapsed to name + email."
          properties:
            lastModifiedBy:
              $ref: '#/components/schemas/CollapsedUser'
        hierarchy:
          type: object
          properties:
            parentSuite:
              type: object
              nullable: true
              description: "Populated parent suite (name only), null for root suites"
              properties:
                _id:
                  type: string
                name:
                  type: string
            order:
              type: integer
              description: "Display order within the parent"
            depth:
              type: integer
              description: "Nesting depth (1 = root, max 6)"
        childSuitesCount:
          type: integer
          description: "Number of direct child suites"
        children:
          type: array
          description: "Nested child suites (same shape, recursively public-shaped)."
          items:
            $ref: '#/components/schemas/ManualTestSuiteListItem'
        updatedAt:
          type: string
          format: date-time

    # ── Automated Test Schemas ─────────────────────────────────────────────

    TestStats:
      type: object
      properties:
        total:
          type: integer
        passed:
          type: integer
        failed:
          type: integer
        skipped:
          type: integer
        flaky:
          type: integer
        timedOut:
          type: integer
        totalAttempts:
          type: integer
        retriedTests:
          type: integer

    GitMetadata:
      type: object
      properties:
        branch:
          type: string
        environment:
          type: string
        commit:
          type: object
          properties:
            hash:
              type: string
            message:
              type: string
            author:
              type: string

    TestRunListItem:
      type: object
      properties:
        id:
          type: string
        counter:
          type: integer
        status:
          type: string
          enum: [passed, failed, interrupted, incomplete, running]
        startTime:
          type: string
          format: date-time
        endTime:
          type: string
          format: date-time
        duration:
          type: integer
        testStats:
          $ref: '#/components/schemas/TestStats'
        metadata:
          type: object
          properties:
            git:
              $ref: '#/components/schemas/GitMetadata'
        url:
          type: string
          description: Deep-link to the test run in the TestDino UI

    TestRunDetail:
      allOf:
        - $ref: '#/components/schemas/TestRunListItem'
        - type: object
          properties:
            errorCategory:
              type: object
            liveStats:
              type: object
            tagStats:
              type: array
              items:
                type: object
            errorGroups:
              type: array
              description: "Grouped error failures, when present."
              items:
                type: object
            coverage:
              type: object
              nullable: true
              description: "Code coverage metrics, when present."
            specs:
              type: array
              description: "Spec files with flattened test cases, when present."
              items:
                type: object
            artifacts:
              type: object
              nullable: true
              description: "Artifact download URLs, when present."
    CreateManualTestSuiteRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 500
        description:
          type: string
          maxLength: 2000
        parentSuiteId:
          type: string
          description: "Nest under an existing suite. Omit for a top-level suite."
    CreateManualTestCaseRequest:
      type: object
      required: [title]
      additionalProperties: false
      description: "Target the suite by either `suiteName` or `suiteId`. At least one is required."
      properties:
        title: { type: string, maxLength: 500 }
        suiteName:
          type: string
          maxLength: 500
          description: "Target suite name, matched case-sensitively (`checkout` does not match a suite named `Checkout`). Prefer `suiteId` to avoid the case-sensitivity pitfall. If both are sent, `suiteId` wins."
        suiteId:
          type: string
          maxLength: 128
          description: "Target suite `_id`, as returned by create suite. Alternative to `suiteName`."
        description: { type: string }
        status: { type: string, description: "Project-configured classification status." }
        testStepsDeclarationType: { type: string, enum: [Classic, Gherkin] }
        preconditions: { type: string }
        postconditions: { type: string }
        steps:
          type: array
          maxItems: 100
          description: "Classic ({action, expectedResult, data}) or Gherkin ({event, stepDescription}) steps. Per-step attachments are not supported in v1."
          items: { type: object }
        priority: { type: string }
        severity: { type: string }
        type: { type: string }
        layer: { type: string }
        behavior: { type: string }
        automationStatus: { type: string }
        tags: { type: string, description: "Comma-separated tags." }
        flags:
          type: array
          items: { type: string, enum: ['To be Automated', 'Is flaky', 'Muted'] }
        customFields:
          type: object
          additionalProperties: { type: string }
          description: "Key-value strings. Keys must not start with `$` or contain `.`."
    UpdateManualTestCaseRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: "Send only the fields to change. At least one is required."
      properties:
        title: { type: string, maxLength: 500 }
        name: { type: string, maxLength: 500, description: "Alias for title." }
        description: { type: string }
        status: { type: string }
        testStepsDeclarationType: { type: string, enum: [Classic, Gherkin] }
        preconditions: { type: string }
        postconditions: { type: string }
        steps: { type: array, maxItems: 100, items: { type: object }, description: "Full replacement of the case's steps." }
        priority: { type: string }
        severity: { type: string }
        type: { type: string }
        layer: { type: string }
        behavior: { type: string }
        automationStatus: { type: string }
        tags: { type: string }
        flags: { type: array, items: { type: string, enum: ['To be Automated', 'Is flaky', 'Muted'] } }
        customFields: { type: object, additionalProperties: { type: string } }
        comments: { type: array, maxItems: 20, items: { type: string }, description: "Each string is appended as a new comment." }
        issues: { type: array, maxItems: 50, items: { type: string }, description: "Jira ticket keys to link (e.g. PROJ-123)." }
    CreateReleaseRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 255 }
        description: { type: string }
        note: { type: string, description: "Rich HTML note." }
        type: { type: string, description: "Project-configured release type (e.g. release, sprint)." }
        parentReleaseId: { type: string, description: "Nest under a parent release (max 3 levels)." }
        startDate: { type: string, description: "ISO date." }
        endDate: { type: string, description: "ISO date." }
        branch: { type: string }
        environment: { type: string }
        linkedIssues: { type: array, items: { type: object } }
        buildTarget:
          type: object
          description: "{ platform: web|ios|android|api, version, buildNumber, source, deployUrl }."
        testers: { type: array, items: { type: string }, description: "User _ids (must be org members)." }
    UpdateReleaseRequest:
      allOf:
        - $ref: '#/components/schemas/CreateReleaseRequest'
        - type: object
          minProperties: 1
          description: "Send only the fields to change. `name` is optional here. The 4 lifecycle fields below are accepted here only, not on create."
          properties:
            startedAt: { type: string, description: "ISO datetime." }
            completedAt: { type: string, description: "ISO datetime." }
            isStarted: { type: boolean }
            isCompleted: { type: boolean }
    CreateSessionRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 255 }
        mission: { type: string, description: "Rich HTML charter." }
        sessionType: { type: string }
        config: { type: string }
        environment: { type: string }
        releaseId: { type: string }
        assigneeUserId: { type: string, description: "User _id or email." }
        state: { type: string, description: "Project-configured workflow state." }
        estimate: { type: integer, minimum: 0, description: "Estimate in minutes." }
        tags: { type: array, items: { type: string }, description: "JSON array of strings." }
        linkedIssues: { type: array, items: { type: object } }
    UpdateSessionRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: "Send only the fields to change. `status: closed` closes the session."
      properties:
        name: { type: string, maxLength: 255 }
        mission: { type: string }
        sessionType: { type: string }
        config: { type: string }
        environment: { type: string }
        releaseId: { type: string }
        assigneeUserId: { type: string }
        state: { type: string }
        estimate: { type: integer, minimum: 0 }
        tags: { type: array, items: { type: string } }
        linkedIssues: { type: array, items: { type: object } }
        status: { type: string, description: "Set to `closed` to close the session." }
    CreateManualRunRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 255 }
        note: { type: string, description: "Rich HTML note." }
        environment: { type: string }
        releaseId: { type: string }
        state: { type: string }
        selectionMode: { type: string, enum: [all, selected], description: "`all` (default) includes every case; `selected` uses testCaseIds/suiteIds." }
        testCaseIds: { type: array, items: { type: string } }
        suiteIds: { type: array, items: { type: string } }
        includeUnsorted: { type: boolean }
        forecast: { type: number }
        tags: { type: array, items: { type: string }, description: "JSON array of strings." }
        linkedIssues: { type: array, items: { type: object } }
        links: { type: array, items: { type: object }, description: "{ title, url } link objects." }
    UpdateManualRunRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: "Send only the fields to change. `status: closed` closes the run."
      properties:
        name: { type: string, maxLength: 255 }
        note: { type: string }
        environment: { type: string }
        releaseId: { type: string }
        state: { type: string }
        selectionMode: { type: string, enum: [all, selected] }
        forecast: { type: number }
        tags: { type: array, items: { type: string } }
        linkedIssues: { type: array, items: { type: object } }
        links: { type: array, items: { type: object } }
        status: { type: string, description: "Set to `closed` to close the run." }
    UpdateRunTestCaseRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: |
        Two mutually exclusive modes (mixing them is rejected):
        quick verdict (assigneeUserId and/or result/status/elapsed) or detailed
        result (comment/linkedIssues/stepResults).
      properties:
        assigneeUserId: { type: string, nullable: true, description: "User _id or email; `null` unassigns." }
        result: { type: string, description: "untested | passed | failed | blocked | skipped | retest (display or canonical)." }
        status: { type: string, description: "Alias for result." }
        elapsed: { type: number, minimum: 0, description: "Seconds." }
        comment: { type: string, description: "Rich HTML." }
        linkedIssues: { type: array, items: { type: object } }
        stepResults:
          type: array
          items: { type: object }
          description: "e.g. [{ order: 1, status: passed, comment: '' }]."

    WebhookSubscription:
      type: object
      description: "An outbound webhook subscription. The signing secret is never included."
      properties:
        _id: { type: string, example: "wh_8f2c" }
        projectId: { type: string }
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          description: "Stored form. Unchanged while the delivered event names are deprecated."
          items: { type: string, enum: [RUN_STARTED, RUN_FINISHED] }
        conditions:
          type: object
          properties:
            outcome:
              type: string
              enum: [any, failed, passed]
              description: "run.finished filter"
        active: { type: boolean }
        description: { type: string, maxLength: 200 }
        source: { type: string, enum: [api, n8n, zapier] }
        consecutiveFailures: { type: integer, description: "Consecutive failed delivery attempts" }
        disabledReason: { type: string, nullable: true }
        lastDeliveryAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          minItems: 1
          description: >-
            Either spelling is accepted while the old names are deprecated. Both
            normalize to the RUN_STARTED / RUN_FINISHED form that reads return.
          items:
            type: string
            enum: [RUN_STARTED, RUN_FINISHED, run.started, run.finished]
        conditions:
          type: object
          properties:
            outcome: { type: string, enum: [any, failed, passed] }
        description: { type: string, maxLength: 200 }
        source: { type: string, enum: [api, n8n, zapier] }

    WebhookUpdate:
      type: object
      description: "Partial update. At least one field is required."
      minProperties: 1
      properties:
        url: { type: string, format: uri, maxLength: 2048 }
        events:
          type: array
          minItems: 1
          description: >-
            Either spelling is accepted while the old names are deprecated. Both
            normalize to the RUN_STARTED / RUN_FINISHED form that reads return.
          items:
            type: string
            enum: [RUN_STARTED, RUN_FINISHED, run.started, run.finished]
        conditions:
          type: object
          properties:
            outcome: { type: string, enum: [any, failed, passed] }
        active: { type: boolean }
        description: { type: string, maxLength: 200 }

    WebhookDelivery:
      type: object
      description: "A delivery attempt record. The request payload is excluded from list views."
      properties:
        _id: { type: string, description: "Delivery identifier, sent as the X-TestDino-Delivery header" }
        webhookId: { type: string }
        event:
          type: string
          enum: [RUN_STARTED, RUN_FINISHED]
          description: "Stored form. Also what the delivered payload's `event` carries until 11 December 2026; the new name ships alongside as `eventName`."
        runId: { type: string }
        status: { type: string, enum: [pending, success, failed] }
        attempt: { type: integer }
        nextAttemptAt: { type: string, format: date-time, nullable: true }
        statusCode: { type: integer, nullable: true }
        responseMs: { type: integer, nullable: true }
        error: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WebhookPagination:
      type: object
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        hasMore: { type: boolean }

  parameters:
    projectId:
      name: projectId
      in: path
      required: true
      schema:
        type: string
      description: "The project identifier (e.g. `project_abc123`). Must match the project associated with your PAT."
    page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
      description: "Page number for paginated results. Starts at 1."
    limit:
      name: limit
      in: query
      schema:
        type: integer
        enum: [10, 25, 50, 100]
        default: 10
      description: "Page size. Must be one of `10`, `25`, `50`, or `100` (default `10`). Any other value returns `400 INVALID_LIMIT`."
    include:
      name: include
      in: query
      schema:
        type: string
      description: "Comma-separated list of extra data to include (e.g. `errors,coverage`). Use `all` for everything available on this endpoint."
    dateRange:
      name: dateRange
      in: query
      schema:
        type: string
        enum: ['7d', '30d', '90d']
      description: "Predefined date range filter. **Takes precedence** over `startDate`/`endDate` if both are provided. For custom ranges, omit this and use `startDate`+`endDate` instead."
    environment:
      name: environment
      in: query
      schema:
        type: string
      description: "Filter results by environment name (e.g. `production`, `staging`). Use the `/filters` endpoint to discover available environments."
    startDate:
      name: startDate
      in: query
      schema:
        type: string
        format: date
      description: "Start of a custom date range (ISO 8601 date, e.g. `2026-03-01`). Must be used together with `endDate`. Ignored if `dateRange` is also provided."
    endDate:
      name: endDate
      in: query
      schema:
        type: string
        format: date
      description: "End of a custom date range (ISO 8601 date, e.g. `2026-03-31`). Must be used together with `startDate`. Ignored if `dateRange` is also provided."
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: |
        Optional but recommended on create (`POST`) requests. Send a unique value (e.g. a UUID) per
        logical create. A retry with the same key within 24h replays the original response instead of
        creating a duplicate. Same key with a different body returns `422 IDEMPOTENCY_KEY_REUSE`; a key
        for a request still in flight returns `409`. Omitting the header is allowed (no deduplication).

  responses:
    Unauthorized:
      description: Missing, invalid, expired, or revoked token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: UNAUTHORIZED
              message: "Invalid or unknown user PAT"
    Forbidden:
      description: Token not authorized for this project
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
    RateLimited:
      description: Rate limit exceeded
      headers:
        RateLimit-Limit:
          description: Max requests allowed in the current window
          schema: { type: integer, example: 100 }
        RateLimit-Remaining:
          description: Requests remaining in the current window
          schema: { type: integer, example: 0 }
        RateLimit-Reset:
          description: Seconds until the current window resets
          schema: { type: integer, example: 42 }
        Retry-After:
          description: Seconds to wait before retrying
          schema: { type: integer, example: 60 }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            success: false
            error:
              code: RATE_LIMIT_EXCEEDED
              message: "Too many requests"
    ValidationError:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'

paths:
  # ==========================================================================
  # TOKEN INFO
  # ==========================================================================
  /{projectId}/token-info:
    get:
      operationId: verifyToken
      tags: [Token Info]
      summary: Verify token and retrieve token metadata
      description: |
        Returns metadata about the authenticated PAT token, including its scopes, expiry, and current
        rate limit quotas. User PATs can be scoped to multiple organization/project pairs, so `scopes`
        is the source of truth for project access.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Token metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                        enum: [pat, api_key]
                      scopes:
                        description: >
                          A permission string (e.g. `runs:read`) or an org scope.
                          Each org scope lists its projects as `{ id, name }`
                          objects (the Token Registry resolves names server-side);
                          a wildcard scope uses the literal `*` and carries no
                          project names.
                        type: array
                        items:
                          oneOf:
                            - type: string
                            - type: object
                              properties:
                                orgId:
                                  type: string
                                projects:
                                  oneOf:
                                    - type: string
                                      enum: ['*']
                                    - type: array
                                      items:
                                        type: object
                                        properties:
                                          id:
                                            type: string
                                          name:
                                            type: string
                                            nullable: true
                                        required: [id, name]
                              required: [orgId, projects]
                      projectName:
                        description: >
                          Human name of the path `projectId`, resolved from the
                          token's scopes. Null for a wildcard scope or a project
                          the token is not scoped to.
                        type: string
                        nullable: true
                      expiresAt:
                        type: string
                        format: date-time
                        nullable: true
                      createdAt:
                        type: string
                        format: date-time
                        nullable: true
                      lastUsed:
                        type: string
                        format: date-time
                        nullable: true
                      usageCount:
                        type: integer
                        nullable: true
                      rateLimit:
                        type: object
                        properties:
                          limit:
                            type: integer
                          window:
                            type: string
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # TEST RUNS — List
  # ==========================================================================
  /{projectId}/test-runs:
    get:
      operationId: listTestRuns
      tags: [Test Runs]
      summary: List test runs with filtering and pagination
      description: |
        Returns a paginated list of test runs for the project. Default sort is **`counter_desc`**
        (newest counter first). Query parameter names are listed below
        (snake_case).

        **Date range:** pass **`start_date`** and **`end_date`** together as RFC3339 timestamps
        (e.g. `2026-03-01T00:00:00Z`). There is no `period` or camelCase date alias.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - name: status
          in: query
          schema:
            type: string
            enum: [passed, failed, interrupted, incomplete, running]
          description: "Filter by run status. CSV allowed (e.g. `failed,interrupted`)."
        - name: branch
          in: query
          schema:
            type: string
          description: "Filter by git branch (CSV allowed)."
        - name: environment
          in: query
          schema:
            type: string
          description: "Filter by environment name (must exist for this project)."
        - name: author
          in: query
          schema:
            type: string
          description: "Filter by git commit author (CSV allowed)."
        - name: run_tags
          in: query
          schema:
            type: string
          description: "Comma-separated run-level tags (e.g. `smoke,prod`)."
        - name: test_case_tags
          in: query
          schema:
            type: string
          description: "Comma-separated test-case tags that must appear on cases in the run."
        - name: tag_match
          in: query
          schema:
            type: string
            enum: [exact]
          description: "When set to `exact`, tag filters require an exact tag match."
        - name: search
          in: query
          schema:
            type: string
          description: "Free-text search across commit messages and run counter numbers."
        - name: start_date
          in: query
          schema:
            type: string
            format: date-time
          description: "Start of run time window (RFC3339). Must be used with `end_date`."
        - name: end_date
          in: query
          schema:
            type: string
            format: date-time
          description: "End of run time window (RFC3339). Must be used with `start_date`."
        - name: sort
          in: query
          schema:
            type: string
            enum: [counter_desc, counter_asc, duration_asc, duration_desc]
            default: counter_desc
          description: "Sort order for the run list."
      responses:
        '200':
          description: Paginated list of test runs
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TestRunListItem'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # TEST RUNS — Detail (consolidated)
  # ==========================================================================
  /{projectId}/test-runs/{runId}:
    get:
      operationId: getTestRun
      tags: [Test Runs]
      summary: Get test run details
      description: |
        Returns detailed information about a single test run, including test stats (pass/fail/skip/flaky counts),
        git metadata (branch, commit, PR), tag stats, error categorization, and a deep-link URL to the TestDino UI.

        **No query parameters.** Optional `include` sections from the previous API are not supported on this route.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: runId
          in: path
          required: true
          schema:
            type: string
          description: "The test run identifier (e.g. `test_run_abc123`)."
      responses:
        '200':
          description: Test run detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/TestRunDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ==========================================================================
  # TEST CASES — Detail (consolidated)
  # ==========================================================================
  /{projectId}/test-cases/{caseId}:
    get:
      operationId: getTestCase
      tags: [Test Cases]
      summary: Get latest test case details
      description: |
        Returns the latest detailed execution payload for a single automated test case.
        `caseId` is the Playwright `pw_test_id`. It resolves to the latest
        matching run, optionally scoped by either `branch` or `environment`, and returns
        per-attempt errors, attachments, logs, and recursive steps.

        `branch` and `environment` are mutually exclusive. If both are supplied, Data Handler
        returns a validation error.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: caseId
          in: path
          required: true
          schema:
            type: string
          description: "The Playwright test identifier / `pw_test_id` (e.g. `8dfe...-a2e2...`)."
        - name: branch
          in: query
          schema:
            type: string
          description: "Resolve the latest execution of this test on a specific git branch."
        - name: environment
          in: query
          schema:
            type: string
          description: "Resolve the latest execution of this test in a configured environment. Mutually exclusive with `branch`."
      responses:
        '200':
          description: Test case detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      pw_test_id:
                        type: string
                      suite_id:
                        type: string
                      title:
                        type: string
                      title_path:
                        type: array
                        items:
                          type: string
                      status:
                        type: string
                      platform:
                        type: string
                      attempts:
                        type: array
                        items:
                          type: object
                      resolved_run_id:
                        type: string
                      resolved_run_start_time:
                        type: string
                        format: date-time
                      scope_used:
                        type: string
                        enum: [latest, branch, environment]
                      scope_value:
                        type: string
                        nullable: true
                      url:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # ==========================================================================
  # TEST CASES — History (by title)
  # ==========================================================================
  /{projectId}/test-cases/history:
    get:
      operationId: getTestCaseHistory
      tags: [Test Cases]
      summary: Get execution history for a test case by title
      description: |
        Returns the execution history of a test case identified by its visible `title` (the name
        shown in the UI). Internally, the title is resolved to the canonical full title
        (`path/to/spec > describe > title`) by matching the most recently executed test case.
        Each entry represents one execution with its status, duration, retries, platform, branch,
        and associated test run counter.

        Also includes pre-computed summary metrics (execution count, pass/fail/flaky rates, average
        duration), unique errors encountered, and per-platform and per-environment breakdowns.

        Results are ordered by test run start time (newest first). Use `offset` and `limit` for pagination.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: title
          in: query
          required: true
          schema:
            type: string
          description: "The test case title as shown in the UI (e.g. `should display login form`). URL-encode if it contains special characters. If multiple tests share the same title, the most recently executed one is used — inspect `data.testCase.fullTitle` in the response to see which was resolved."
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: "Number of history entries to return (1–100, default 20). Upstream fetch limit may snap to 50, 100, 200, or 500 before client-side pagination."
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: "Number of entries to skip (for pagination). Default 0."
      responses:
        '200':
          description: Execution history with summary and breakdown metrics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      testCase:
                        type: object
                        properties:
                          title:
                            type: string
                            description: "Short test case title"
                          fullTitle:
                            type: string
                            description: "Full title including parent suites"
                          specName:
                            type: string
                            description: "Spec file path"
                      summary:
                        type: object
                        description: "Pre-computed aggregate metrics"
                        properties:
                          executions:
                            type: integer
                          passedRuns:
                            type: integer
                          failedRuns:
                            type: integer
                          flakyRuns:
                            type: integer
                          skippedRuns:
                            type: integer
                          successRate:
                            type: number
                          failureRate:
                            type: number
                          flakyRate:
                            type: number
                          avgDuration:
                            type: number
                          minDuration:
                            type: number
                          maxDuration:
                            type: number
                          platforms:
                            type: array
                            items:
                              type: string
                      history:
                        type: array
                        description: "Paginated list of executions (newest first)"
                        items:
                          type: object
                          properties:
                            testRunId:
                              type: string
                            testCaseId:
                              type: string
                            counter:
                              type: integer
                            branch:
                              type: string
                            platform:
                              type: string
                            status:
                              type: string
                              enum: [passed, failed, flaky, skipped]
                            duration:
                              type: number
                            retries:
                              type: integer
                            timestamp:
                              type: string
                              format: date-time
                            attemptsCount:
                              type: integer
                      uniqueErrors:
                        type: array
                        description: "Distinct error messages encountered (max 20)"
                        items:
                          type: object
                      platformMetrics:
                        type: array
                        description: "Per-platform execution stats"
                        items:
                          type: object
                          properties:
                            platform:
                              type: string
                            executions:
                              type: integer
                            failureCount:
                              type: integer
                            failureRate:
                              type: number
                            flakyCount:
                              type: integer
                            flakyRate:
                              type: number
                            avgDuration:
                              type: number
                      environmentMetrics:
                        type: array
                        description: "Per-environment execution stats"
                        items:
                          type: object
                          properties:
                            environment:
                              type: string
                            executions:
                              type: integer
                            failureRate:
                              type: number
                            flakyRate:
                              type: number
                            avgDuration:
                              type: number
        '400':
          description: "Missing or invalid title parameter"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: "No test case found with the given title"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  # ==========================================================================
  # DEBUG BUNDLE — Debugging payload for run / suite / test
  # ==========================================================================
  /{projectId}/debug-bundle:
    get:
      operationId: getDebugBundle
      tags: [Debug Bundle]
      summary: Get a debug bundle for a run, suite, or test case
      description: |
        Returns a debugging payload at run, suite, or test scope.
        Pass `runId`, `suiteId` + `runId`, or `caseId` (+ optional `runId`).
        Use `as=markdown` for Markdown output and `budgetChars` to cap its size.

        This endpoint replaces `GET /{projectId}/context`, which now returns `404`. The retired
        query parameters `format`, `detail`, and `maxLength` return `400 VALIDATION_ERROR`
        instead of being ignored.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: runId
          in: query
          schema:
            type: string
          description: "Run ID. Required for run scope and required with `suiteId`."
        - name: suiteId
          in: query
          schema:
            type: string
          description: "Suite ID. Requires `runId`."
        - name: caseId
          in: query
          schema:
            type: string
          description: "Test case ID. `runId` is optional; when omitted, the latest run is resolved and echoed in `data.meta.runId`."
        - name: attempt
          in: query
          schema:
            type: integer
            minimum: 0
          description: "Zero-indexed attempt number. Requires `caseId`; sending it at run or suite scope returns `400`. Defaults to the latest attempt."
        - name: as
          in: query
          schema:
            type: string
            enum: [json, markdown]
            default: json
          description: "Response format: `json` (default) or `markdown` (`text/markdown` body)."
        - name: view
          in: query
          schema:
            type: string
            enum: [full, brief, headline]
            default: full
          description: "Payload size mode. Test scope: `brief` drops `steps[]`; `headline` also drops `stdout` and `stderr` and caps `artifacts.*` arrays at 5 with a `totals` field. Suite scope: `brief` and `headline` set each item's `failureWindow` to `null`. Run scope: only `full` is accepted; any other value returns `400`."
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
            maximum: 1000000
          description: "Pagination (run and suite scopes only). A non-default value with `caseId` returns `400`. Out-of-range or non-integer values return `400`."
        - name: limit
          in: query
          schema:
            type: integer
            default: 10
            minimum: 1
            maximum: 50
          description: "Pagination size (run and suite scopes only), 1 to 50. A non-default value with `caseId` returns `400`. Out-of-range or non-integer values return `400`."
        - name: budgetChars
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 128000
          description: "Truncate the Markdown response to N characters, 1 to 128000. Requires `as=markdown`; sending it with a JSON response returns `400`."
      responses:
        '200':
          description: |
            Standard envelope: `{ success, data }`. `data` is discriminated by `data.scope`:
            - `"run"`: `{ scope, runId, runStatus, suites[], items[] (each with alsoFailedIn[]), meta, environment, links }`
            - `"suite"`: `{ scope, spec, suiteStatus, items[] (each with failureWindow; null under view=brief or headline), meta, environment, links }`
            - `"test"`: `{ scope, spec, test, error, attemptHistory[] (each with its own 0-based attempt index; the selected attempt is excluded), traceLink, failureWindow, steps[], stdout, stderr, artifacts, meta, environment, links }`

            The run and suite scopes also return the standard `pagination` object
            (`page`, `limit`, `total`, `hasNext`, `hasPrev`) beside `data`, not inside it.
            The test scope is not paginated.

            `failureWindow` has `before`, `failed`, and `after` steps.

            With `?as=markdown` the body is a Markdown document whose sections depend on scope:
            run: Failing suites, Failing tests, Actions. Suite: Failing tests (each with Error and
            Failure window), Actions. Test: Error, Failure window, Steps, stdout, stderr, Attempt
            history, Artifacts, Actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: "Shape varies by `scope`. See the response description for per-scope fields."
                    properties:
                      scope:
                        type: string
                        enum: [run, suite, test]
                        description: "Discriminator: tells the caller which shape they received."
                      links:
                        type: object
                        description: "Present at every scope."
                        properties:
                          uiUrl:
                            type: string
                            description: "Deep-link to the corresponding page in the TestDino UI."
                          traceViewerUrl:
                            type: string
                            nullable: true
                            description: "Test scope only. Pre-loaded `trace.playwright.dev` URL. `null` when no trace zip is attached."
                          retryCommand:
                            type: string
                            description: "Copy-paste `npx playwright test ...` command to reproduce locally."
            text/markdown:
              schema:
                type: string
                description: "Markdown document. Returned when `?as=markdown`."
        '400':
          description: |
            Validation failure. Codes: `VALIDATION_ERROR` (none of `runId`, `suiteId`, or `caseId` provided; bad `as`, `view`, or `budgetChars`; a retired `format`, `detail`, or `maxLength` parameter; a parameter that cannot apply to the resolved scope; or any parameter given more than once), `INVALID_ATTEMPT` (attempt index out of range).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: "Codes: `TEST_RUN_NOT_FOUND`, `TEST_SUITE_NOT_FOUND`, `TEST_CASE_NOT_FOUND`."
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ==========================================================================
  # SPECS
  # ==========================================================================
  /{projectId}/specs:
    get:
      operationId: listSpecs
      tags: [Specs]
      summary: List spec file health metrics
      description: |
        Returns a paginated list of spec files (test files) with aggregated health metrics such as
        pass/fail counts, flakiness rate, and average duration. Useful for identifying problematic
        spec files across your test suite over a configurable lookback period.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
          description: "Page size after spec-file aggregation (1–100, default 10). Not the shared runs-list enum."
        - name: status
          in: query
          schema:
            type: string
            enum: [passed, failed, flaky, skipped]
          description: "Filter by aggregated spec status."
        - $ref: '#/components/parameters/environment'
        - name: search
          in: query
          schema:
            type: string
          description: "Search by spec file path or name."
        - name: days
          in: query
          schema:
            type: integer
            enum: [7, 30, 90]
            default: 30
          description: "Lookback window in days. Values other than 7, 30, or 90 are snapped to the nearest allowed window (default 30 when absent/invalid)."
        - name: sortBy
          in: query
          schema:
            type: string
            enum: [fileName, fullTitle, executions, failureRate, flakyRate, avgDuration, lastExecution]
            default: fileName
          description: "Field to sort aggregated spec rows by. `fullTitle` falls back to `fileName` (no per-spec title grain)."
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: asc
          description: "Sort direction: `asc` or `desc` (default `asc`)."
      responses:
        '200':
          description: Paginated spec health list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      type: object
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # MANUAL TESTS — Suites
  # ==========================================================================
  /{projectId}/manual-test-suites:
    get:
      operationId: listManualTestSuites
      tags: [Manual Tests]
      summary: List manual test suites
      description: |
        Returns a flat list of manual test suites for one hierarchy level. Use `parentSuiteId`
        to navigate into child suites; omit it for top-level suites. Response is `{ data, count }`
        (not a paginated envelope).
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: parentSuiteId
          in: query
          schema:
            type: string
          description: "Filter to children of this suite. Omit for top-level suites. Pass the literal string `null` to scope to root-level suites explicitly."
      responses:
        '200':
          description: Suite list with count
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ManualTestSuiteListItem'
                  count:
                    type: integer
                    description: "Total suites returned in `data`."
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createManualTestSuite
      tags: [Manual Tests]
      summary: Create a manual test suite
      description: |
        Creates a manual test suite. Requires a PAT whose owner has a writer role
        (owner/admin/member) on the project's organization; a viewer-role token gets `403`.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateManualTestSuiteRequest'
      responses:
        '201':
          description: Suite created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ==========================================================================
  # MANUAL TESTS — List
  # ==========================================================================
  /{projectId}/manual-test-cases:
    get:
      operationId: listManualTestCases
      tags: [Manual Tests]
      summary: List manual test cases with filtering
      description: |
        Returns a filterable list of manual test cases (single page). Supports classification
        and automation filters, suite, and tags. **No `page` or sort params**: the response is one
        page ordered by `createdAt` desc.

        Search matches title and caseId only (case-insensitive).
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 1000
          description: "Max cases to return (1–1000, default 100)."
        - name: search
          in: query
          schema:
            type: string
          description: "Free-text search across title and caseId."
        - name: status
          in: query
          schema:
            type: string
            enum: [active, draft, deprecated]
          description: "Filter by classification status."
        - name: severity
          in: query
          schema:
            type: string
            enum: [blocker, critical, major, normal, minor, trivial, not_set]
          description: "Filter by severity level."
        - name: priority
          in: query
          schema:
            type: string
            enum: [critical, high, medium, low, not_set]
          description: "Filter by priority."
        - name: type
          in: query
          schema:
            type: string
            enum: [smoke, regression, functional, integration, e2e, api, unit, performance, security, accessibility, usability, compatibility, acceptance, exploratory, other]
          description: "Filter by test type."
        - name: layer
          in: query
          schema:
            type: string
            enum: [e2e, api, unit, not_set]
          description: "Filter by test layer."
        - name: behavior
          in: query
          schema:
            type: string
            enum: [positive, negative, destructive, not_set]
          description: "Filter by expected behavior type."
        - name: automationStatus
          in: query
          schema:
            type: string
            enum: [manual, automated]
          description: "Filter by automation status."
        - name: suiteId
          in: query
          schema:
            type: string
          description: "Filter to test cases belonging to this suite. Use `null` for unassigned cases."
        - name: tags
          in: query
          schema:
            type: string
          description: "Comma-separated tags to filter by (e.g. `smoke,login`). Returns cases matching any of the tags."
      responses:
        '200':
          description: Manual test cases
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ManualTestCaseListItem'
                  count:
                    type: integer
                    description: "Total cases returned in `data`."
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createManualTestCase
      tags: [Manual Tests]
      summary: Create a manual test case
      description: |
        Creates a manual test case in the named suite. Requires a writer-role PAT (viewer → `403`).
        Enum-like fields are validated against the project's configured options. Attachments are not
        supported in v1.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateManualTestCaseRequest'
      responses:
        '201':
          description: Test case created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ==========================================================================
  # MANUAL TESTS — Detail
  # ==========================================================================
  /{projectId}/manual-test-cases/{caseId}:
    get:
      operationId: getManualTestCase
      tags: [Manual Tests]
      summary: Get manual test case details
      description: |
        Returns the full details of a single manual test case, including title, description,
        preconditions, postconditions, steps (classic or gherkin with nested sub-steps),
        classification, automation status, tags, custom fields, linked automated tests,
        and attachments. Internal `metadata.project` and `metadata.createdBy` are stripped;
        `metadata.lastModifiedBy` is collapsed to name and email.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: caseId
          in: path
          required: true
          schema:
            type: string
          description: "The manual test case identifier (e.g. `tcm_tc_...`)."
      responses:
        '200':
          description: Manual test case detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/ManualTestCaseListItem'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateManualTestCase
      tags: [Manual Tests]
      summary: Update a manual test case
      description: |
        Updates a manual test case. Send only the fields to change. `steps` is a full replacement.
        `comments` appends each string as a new comment (retries duplicate — send an Idempotency-Key
        or avoid retrying). Requires a writer-role PAT (viewer → `403`). Attachments not supported in v1.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: caseId
          in: path
          required: true
          schema:
            type: string
          description: "Internal `_id` or a counter-style ID like `TC-123`."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateManualTestCaseRequest'
      responses:
        '200':
          description: Test case updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  # ==========================================================================
  # TEST CASE EXPLORER
  # ==========================================================================
  /{projectId}/test-case-explorer:
    get:
      operationId: exploreTestCases
      tags: [Test Case Explorer]
      summary: Explore test cases with aggregated metrics
      description: |
        Returns a paginated list of test cases with aggregated metrics across multiple runs,
        including pass rate, fail rate, flakiness rate, average duration, and last execution time.
        Useful for identifying consistently failing, slow, or flaky tests over a configurable
        lookback period. Filter by spec file, platform, environment, status, or tags.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - name: limit
          in: query
          schema:
            type: integer
            enum: [10, 25, 50]
            default: 25
          description: "Page size. Must be one of `10`, `25`, or `50` (default `25`). Any other value returns `400 INVALID_LIMIT`."
        - name: status
          in: query
          schema:
            type: string
            enum: [flaky, chronic, stable]
          description: "Filter by behavioral status class. `stable` requires `days=90`."
        - name: specFilePath
          in: query
          schema:
            type: string
          description: "Filter to test cases from a specific spec file path."
        - name: platform
          in: query
          schema:
            type: string
          description: "Filter by test platform (e.g. `chromium`, `firefox`, `webkit`)."
        - $ref: '#/components/parameters/environment'
        - name: tags
          in: query
          schema:
            type: string
          description: "Single tag string. Not comma-separated multi-tag."
        - name: days
          in: query
          schema:
            type: integer
            enum: [7, 30, 90]
            default: 30
          description: "Rolling window in days. Must be 7, 30, or 90 (default 30). Other values return 400."
        - name: search
          in: query
          schema:
            type: string
          description: "Free-text search across test case names."
        - name: sortBy
          in: query
          schema:
            type: string
            enum: [suite_file_path, title, flaky_rate, failure_rate, reliability_score, total_runs, p95_duration_ms, duration_trend_slope, consecutive_failure_streak, last_seen_at, last_duration_ms, avg_duration_ms]
            default: suite_file_path
          description: "Sort field. Default `suite_file_path`. Sort-dependent default direction applies when `order` is omitted."
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
          description: "Sort direction. Default depends on `sortBy`: text sorts default `asc`, metrics default `desc`."
      responses:
        '200':
          description: Aggregated test case data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # DASHBOARD (consolidated)
  # ==========================================================================
  /{projectId}/dashboard:
    get:
      operationId: getDashboard
      tags: [Dashboard]
      summary: Project health dashboard
      description: |
        Returns a fixed dashboard snapshot over a **30-day** window. **No query parameters**: date and
        environment filters are not supported on this route.

        The response typically contains `{ snapshot, active_runs, data_freshness }`.
        Older response blocks such as `mostFlakyTests` may be absent.
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Dashboard data
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: "Dashboard snapshot."
                    properties:
                      snapshot:
                        type: object
                        description: "Project snapshot metrics."
                      active_runs:
                        type: array
                        description: "Currently running test runs overlay."
                        items:
                          type: object
                      data_freshness:
                        type: object
                        description: "Timestamps indicating how fresh the snapshot is."
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # FILTERS
  # ==========================================================================
  /{projectId}/filters:
    get:
      operationId: getFilterValues
      tags: [Filters]
      summary: Get available filter values and tags
      description: |
        Returns facet values for populating filter dropdowns: branches, authors, platforms, tags,
        run tags, statuses, and environments. **No query parameters**: the response uses a fixed
        **90-day** window (widest facet set).
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Filter values
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: "Facet arrays of available filter values."
                    properties:
                      branches:
                        type: array
                        items:
                          type: string
                      last_branches:
                        type: array
                        items:
                          type: string
                      authors:
                        type: array
                        items:
                          type: string
                      platforms:
                        type: array
                        items:
                          type: string
                      tags:
                        type: array
                        items:
                          type: string
                      run_tags:
                        type: array
                        items:
                          type: string
                      statuses:
                        type: array
                        items:
                          type: string
                      environments:
                        type: array
                        items:
                          type: string
                      ci_provider:
                        type: array
                        items:
                          type: string
                      git_repo:
                        type: array
                        items:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # REPORTS — PDF
  # ==========================================================================
  /{projectId}/reports/pdf:
    get:
      operationId: generatePdfReport
      tags: [Reports]
      summary: Generate and download a project report PDF
      description: |
        Generates a PDF report summarizing test results for the project over a configurable lookback
        period. The response is a binary PDF stream. Rate limited to **1 request per minute** per token.

        The report includes test run summaries, pass/fail trends, flaky tests, and environment breakdowns.
        Optionally filter by branch, environment, or tags to scope the report.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 30
            default: 7
          description: "Lookback period in days (1–30, default 7). Determines how far back the report covers."
        - name: branch
          in: query
          schema:
            type: string
          description: "Filter report data to a specific git branch."
        - $ref: '#/components/parameters/environment'
        - name: tags
          in: query
          schema:
            type: string
          description: "Comma-separated run tags to filter report data (e.g. `smoke,regression`)."
      responses:
        '200':
          description: PDF binary stream
          content:
            application/pdf:
              schema:
                type: string
                format: binary
          headers:
            Content-Disposition:
              schema:
                type: string
                example: 'attachment; filename="TestDino-Report-MyProject-7d-2026-04-10.pdf"'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: PDF rate limit exceeded (1 per minute)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
          headers:
            Retry-After:
              schema:
                type: integer
                example: 60

  # ==========================================================================
  # ANALYTICS — Summary (consolidated)
  # ==========================================================================
  /{projectId}/analytics/summary:
    get:
      operationId: getAnalyticsSummary
      tags: [Analytics]
      summary: Consolidated analytics summary
      description: |
        Returns a composed analytics summary: top failing
        tests, flaky tests, slowest test cases, and tag stats for one rolling window. Chart payloads
        are stripped.

        **Only `days` is supported.** It is snapped to **7, 30, or 90** (default **30** when absent or invalid).
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: days
          in: query
          schema:
            type: integer
            enum: [7, 30, 90]
            default: 30
          description: "Rolling window in days. Snapped to 7, 30, or 90 (default 30)."
      responses:
        '200':
          description: Analytics summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      window_days:
                        type: integer
                        enum: [7, 30, 90]
                        description: "Effective rolling window used for composition."
                      topFailingTests:
                        type: array
                        description: "Top failing tests for the window."
                        items:
                          type: object
                      flakyTests:
                        type: array
                        description: "Top flaky tests (capped to 5)."
                        maxItems: 5
                        items:
                          type: object
                      slowestTestCases:
                        type: array
                        description: "Slowest test cases (capped to 5)."
                        maxItems: 5
                        items:
                          type: object
                      tags:
                        type: array
                        description: "Tag statistics for the window."
                        items:
                          type: object
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # ANALYTICS — Test case execution performance
  # ==========================================================================
  /{projectId}/analytics/test-cases/performance:
    get:
      operationId: getTestCasePerformance
      tags: [Analytics]
      summary: Test case execution performance trends
      description: |
        Returns per-test performance rows composed from slow + failure intelligence endpoints
        (duration percentiles merged with failure stats by `pw_test_id`).

        **Only `days` is supported.** Snapped to **7, 30, or 90** (default **30**).
      parameters:
        - $ref: '#/components/parameters/projectId'
        - name: days
          in: query
          schema:
            type: integer
            enum: [7, 30, 90]
            default: 30
          description: "Rolling window in days. Snapped to 7, 30, or 90 (default 30)."
      responses:
        '200':
          description: Performance trend data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ==========================================================================
  # USAGE
  # ==========================================================================
  /{projectId}/usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Subscription usage and per-project breakdown
      description: |
        Returns the organization's subscription plan, overall test case usage (limit, used, remaining),
        this project's individual allocation, and a per-project usage breakdown across all projects
        in the organization. Also includes the billing period start/end and next reset date.

        If no subscription exists, free-tier defaults are returned (5,000 test cases/month, 1 project).
      parameters:
        - $ref: '#/components/parameters/projectId'
      responses:
        '200':
          description: Usage statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      planName:
                        type: string
                      orgName:
                        type: string
                        nullable: true
                        description: Organization name
                      projects:
                        type: array
                        description: Active projects in this organization
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            name:
                              type: string
                      orgLimit:
                        type: integer
                      orgUsed:
                        type: integer
                      orgRemaining:
                        type: integer
                      projectUsed:
                        type: integer
                        description: Executions this project has consumed in the current billing window
                      projectUsage:
                        type: array
                        description: Per-project executions breakdown across every project the caller can access in this org
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                            name:
                              type: string
                              nullable: true
                            used:
                              type: integer
                      periodStart:
                        type: string
                        format: date-time
                        nullable: true
                      periodEnd:
                        type: string
                        format: date-time
                        nullable: true
                      resetDate:
                        type: string
                        format: date-time
                        nullable: true
        '401':
          $ref: '#/components/responses/Unauthorized'

  /{projectId}/releases:
    get:
      operationId: listReleases
      tags: [Releases]
      summary: List releases
      description: "Browse releases (milestones). Filter by type, completion, parent, status, or name."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: search, in: query, schema: { type: string }, description: "Match by name." }
        - { name: type, in: query, schema: { type: string } }
        - { name: isCompleted, in: query, schema: { type: boolean } }
        - { name: parentReleaseId, in: query, schema: { type: string }, description: "Direct children of this release." }
        - { name: status, in: query, schema: { type: string } }
        - { name: sortBy, in: query, schema: { type: string, enum: [createdAt, startDate, endDate, name] } }
        - { name: sortOrder, in: query, schema: { type: string, enum: [asc, desc] } }
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 }, description: "Default 25." }
      responses:
        '200':
          description: Release list
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      operationId: createRelease
      tags: [Releases]
      summary: Create a release
      description: "Creates a release. Requires a writer-role PAT (viewer → `403`)."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateReleaseRequest' }
      responses:
        '201':
          description: Release created
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/releases/{releaseId}:
    get:
      operationId: getRelease
      description: "Retrieve a single release by internal `_id` or counter-style ID such as `MS-12`."
      tags: [Releases]
      summary: Get a release
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: releaseId, in: path, required: true, schema: { type: string }, description: "Internal `_id` or counter-style ID like `MS-12`." }
      responses:
        '200':
          description: Release detail
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateRelease
      tags: [Releases]
      summary: Update a release
      description: "Send only the fields to change. Requires a writer-role PAT (viewer → `403`)."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: releaseId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateReleaseRequest' }
      responses:
        '200':
          description: Release updated
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ==========================================================================
  # SESSIONS
  # ==========================================================================
  /{projectId}/sessions:
    get:
      operationId: listSessions
      description: "List exploratory testing sessions in a project, with filters for status, state, session type, assignee, release, and tags."
      tags: [Sessions]
      summary: List exploratory sessions
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: search, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [active, closed] } }
        - { name: state, in: query, schema: { type: string } }
        - { name: sessionType, in: query, schema: { type: string } }
        - { name: assigneeUserId, in: query, schema: { type: string }, description: "User _id or email." }
        - { name: releaseId, in: query, schema: { type: string }, description: "Sessions in this release; `none` for unlinked." }
        - { name: tags, in: query, schema: { type: string }, description: "Comma-separated tags." }
        - { name: sortBy, in: query, schema: { type: string, enum: [createdAt, updatedAt, name] } }
        - { name: sortOrder, in: query, schema: { type: string, enum: [asc, desc] } }
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 }, description: "Default 25." }
      responses:
        '200':
          description: Session list
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createSession
      tags: [Sessions]
      summary: Create an exploratory session
      description: "Creates a session. Requires a writer-role PAT (viewer → `403`). Attachments not supported in v1."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSessionRequest' }
      responses:
        '201':
          description: Session created
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/sessions/{sessionId}:
    get:
      operationId: getSession
      description: "Retrieve a single exploratory session by internal `_id` or counter-style ID such as `SES-12`."
      tags: [Sessions]
      summary: Get an exploratory session
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: sessionId, in: path, required: true, schema: { type: string }, description: "Internal `_id` or counter-style ID like `SES-12`." }
      responses:
        '200':
          description: Session detail
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateSession
      tags: [Sessions]
      summary: Update an exploratory session
      description: "Send only the fields to change. `status: closed` closes the session. Requires a writer-role PAT."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: sessionId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateSessionRequest' }
      responses:
        '200':
          description: Session updated
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ==========================================================================
  # MANUAL RUNS
  # ==========================================================================
  /{projectId}/manual-runs:
    get:
      operationId: listManualRuns
      description: "List manual test runs in a project, with filters for status, state, environment, release, and tags."
      tags: [Manual Runs]
      summary: List manual test runs
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: search, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [active, closed] } }
        - { name: state, in: query, schema: { type: string } }
        - { name: environment, in: query, schema: { type: string } }
        - { name: releaseId, in: query, schema: { type: string }, description: "Runs in this release; `none` for unlinked." }
        - { name: tags, in: query, schema: { type: string }, description: "Comma-separated tags." }
        - { name: sortBy, in: query, schema: { type: string, enum: [createdAt, updatedAt, name] } }
        - { name: sortOrder, in: query, schema: { type: string, enum: [asc, desc] } }
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 }, description: "Default 25." }
      responses:
        '200':
          description: Run list
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createManualRun
      tags: [Manual Runs]
      summary: Create a manual test run
      description: |
        Creates a run. `selectionMode: all` (default) includes every case in the project and can
        create thousands of per-case records — this endpoint has a tighter rate limit than other
        writes. Requires a writer-role PAT (viewer → `403`). Attachments not supported in v1.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/idempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateManualRunRequest' }
      responses:
        '201':
          description: Run created
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/manual-runs/{runId}:
    get:
      operationId: getManualRun
      description: "Retrieve a single manual test run by internal `_id` or counter-style ID such as `RUN-12`."
      tags: [Manual Runs]
      summary: Get a manual test run
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: runId, in: path, required: true, schema: { type: string }, description: "Internal `_id` or counter-style ID like `RUN-12`." }
      responses:
        '200':
          description: Run detail
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateManualRun
      tags: [Manual Runs]
      summary: Update a manual test run
      description: "Send only the fields to change. `status: closed` closes the run. Requires a writer-role PAT."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: runId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateManualRunRequest' }
      responses:
        '200':
          description: Run updated
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/manual-runs/{runId}/test-cases:
    get:
      operationId: listManualRunTestCases
      tags: [Manual Runs]
      summary: List per-case records in a run
      description: "The rows in a run's test-case table — case identity, assignee, and current result."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: runId, in: path, required: true, schema: { type: string } }
        - { name: search, in: query, schema: { type: string }, description: "Match by case title or caseKey." }
        - { name: assignee, in: query, schema: { type: string }, description: "User _id or email." }
        - { name: result, in: query, schema: { type: string }, description: "Filter by result/status." }
        - { name: status, in: query, schema: { type: string }, description: "Alias for result." }
        - { name: sortBy, in: query, schema: { type: string, enum: [createdAt, updatedAt, status, caseKey] } }
        - { name: sortOrder, in: query, schema: { type: string, enum: [asc, desc] } }
        - { name: page, in: query, schema: { type: integer, minimum: 1 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 }, description: "Default 25." }
      responses:
        '200':
          description: Per-case record list
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /{projectId}/manual-runs/{runId}/test-cases/{runCaseId}:
    patch:
      operationId: updateManualRunTestCase
      tags: [Manual Runs]
      summary: Record a verdict or detailed result for a case in a run
      description: |
        Quick verdict (assignee and/or result) or detailed result (comment/linkedIssues/stepResults) —
        the two modes cannot be combined in one call. Requires a writer-role PAT. Attachments not
        supported in v1. Closed runs reject result writes.
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: runId, in: path, required: true, schema: { type: string } }
        - { name: runCaseId, in: path, required: true, schema: { type: string }, description: "Per-case reference: `tcm_rtc_...` ID, caseKey (`TC-156`), or the case `_id`." }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateRunTestCaseRequest' }
      responses:
        '200':
          description: Per-case record updated
          content: { application/json: { schema: { $ref: '#/components/schemas/SuccessEnvelope' } } }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ==========================================================================
  # WEBHOOKS
  # ==========================================================================
  /{projectId}/webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook subscriptions
      description: "Paginated list of outbound webhook subscriptions for the project. Signing secrets are never returned."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: url, in: query, schema: { type: string }, description: "Exact-URL filter." }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Paginated subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      webhooks:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookSubscription' }
                      pagination: { $ref: '#/components/schemas/WebhookPagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: "Creates an outbound webhook. The signing `secret` is returned in cleartext exactly once, in this response. Store it immediately: later reads never include it. Requires an owner or admin role."
      parameters:
        - $ref: '#/components/parameters/projectId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '201':
          description: Created. `secret` is returned once alongside the subscription.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      webhook: { $ref: '#/components/schemas/WebhookSubscription' }
                      secret: { type: string, example: "whsec_3f9a" }
        '400':
          description: Invalid body, or the destination URL is not accepted
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Per-project webhook limit reached
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/webhooks/{webhookId}:
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Get a webhook subscription
      description: "Retrieve a single subscription. The signing secret is not included."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The subscription
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      webhook: { $ref: '#/components/schemas/WebhookSubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      operationId: updateWebhook
      tags: [Webhooks]
      summary: Update a webhook subscription
      description: "Partial update of a subscription. Setting `active` to true clears the failure count. Requires an owner or admin role."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookUpdate' }
      responses:
        '200':
          description: Updated subscription
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      webhook: { $ref: '#/components/schemas/WebhookSubscription' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook subscription
      description: "Permanently deletes the subscription. Requires an owner or admin role."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      deleted: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /{projectId}/webhooks/{webhookId}/deliveries:
    get:
      operationId: listWebhookDeliveries
      tags: [Webhooks]
      summary: List delivery attempts for a webhook
      description: "Paginated delivery history for a subscription, newest first. The request payload is excluded."
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: webhookId, in: path, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: status, in: query, schema: { type: string, enum: [pending, success, failed] } }
        - { name: startDate, in: query, schema: { type: string, format: date-time } }
        - { name: endDate, in: query, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Paginated delivery history
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, enum: [true] }
                  data:
                    type: object
                    properties:
                      deliveries:
                        type: array
                        items: { $ref: '#/components/schemas/WebhookDelivery' }
                      pagination: { $ref: '#/components/schemas/WebhookPagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
