Test Cases

Per-endpoint reference for the Test Cases resource. Covers full CRUD on test cases, a M2M link surface to Tasks, and an append-only version history for every save. Each section below shows the exact path, every parameter, a full request example, and the response shape with a worked example.

Quickstart

Base URL

https://api.tasklife.com/v1/

Auth (all endpoints)

Every request must include a bearer token in the Authorization header. The token is an API key bound to a specific organization — endpoints return only data scoped to that org.

Authorization: Bearer ***
Content-Type: application/json

Response envelope

All successful responses share a common envelope. Error responses are described in Errors below.

{
  "success": true,
  "data":   { /* endpoint-specific payload */ }
}

Note: Test-case endpoints nest the single test case under data.test_case.<field> for get/create/update, and the list under data.test_cases[].<field> for list. The version-history endpoints nest under data.versions[] and data.version.<field>. The link / unlink endpoints return a small data.<verb> object rather than the test case itself. Each card below shows the actual response shape — trust that.

Errors

Errors return { success: false, error: { code, message } } with the appropriate HTTP status. Common ones on this resource:

StatusCodeTypical messageCause
400BAD_REQUESTTest case name is requiredname missing or empty on create/update.
400BAD_REQUESTproduct_id is requiredproduct_id missing on create, or test case no longer has a valid product.
400BAD_REQUESTProduct not foundproduct_id does not resolve in the API key's org.
400BAD_REQUESTsteps must be a markdown numbered list (e.g. "1. First step\n2. Second step")steps contains a non-empty line that is not a numbered list item.
400BAD_REQUESTexpected_results must be a markdown bullet list (e.g. "- First result\n- Second result")expected_results contains a non-empty line that is not a bullet list item.
400BAD_REQUESTTest case belongs to product X but task belongs to product YTried to link a test case and task that don't share a product.
404NOT_FOUNDTest case not found / Version not found / Task not foundThe id doesn't exist or doesn't belong to the API key's org.

Test Cases

A test case is a reusable test specification scoped to exactly one product. Each test case has a name, an optional Markdown description, a steps field (Markdown numbered list) and an expected_results field (Markdown bullet list). Test cases can optionally attach to zero or many epics within their product via epic_ids[]. Every save snapshots the row into test_case_versions — the history is append-only and reversible.

Test cases link to Tasks via the test_case_tasks M2M table. The link surface lives under the Tasks section of this doc and is also documented under /docs/api/tasks. Linking a test case to a task that has no product will auto-assign the task the test case's product (so the constraint propagates forward).

Endpoints at a glance

ActionMethod + PathSummary
List test casesGET /test_casesList every test case in the API key's org, filterable by product, epic, task, team, status, or free-text search. No pagination. Defaults to status=active.
Create a test casePOST /test_casesCreate a test case. Required: name, product_id. Optional: description, steps, expected_results, epic_ids[]. Returns 201 with the full test case.
Get a test caseGET /test_cases/{id}Fetch a single test case including epic_ids[] and product metadata.
Update a test casePUT /test_cases/{id} or PATCH /test_cases/{id}Update one or more fields. On update, omitted fields are left alone. Snapshots a new version.
Archive a test caseDELETE /test_cases/{id}Soft-delete (flips status to archived). Returns the test case with status=archived.
Restore a test casePOST /test_cases/{id}/restoreFlip an archived test case back to status=active. Returns the test case.
List versionsGET /test_cases/{id}/versionsList every version row (one per save), newest first.
Get a versionGET /test_cases/{id}/versions/{versionId}Fetch a single historical version including the snapshot fields (name, description, steps, expected_results, product_id, epic_ids).
Restore a versionPOST /test_cases/{id}/versions/{versionId}/restoreRevert the test case to the content of versionId as a new version (action_type "reverted").
List a task's test casesGET /tasks/{id}/test_casesList every test case linked to a given task. Same shape as list test cases.
Link a test case to a taskPOST /tasks/{id}/test_casesLink an existing test case to a task. Body: {"test_case_id": N}. Idempotent. Auto-assigns the task's product if missing.
Unlink a test case from a taskDELETE /tasks/{id}/test_cases/{testCaseId}Remove a link. Idempotent — returns was_linked: false if the pair was never linked.

GET /test_cases

List every test case that belongs to the API key's org, ordered by product_name ASC, updated_at DESC. Optional filters narrow by product, epic, task, team, status, or free-text search. There is no pagination — the router does not pass limit / offset. By default, archived test cases are filtered out; pass status=archived to surface them.

Query parameters

FieldTypeRequiredDescription
product_id integer no Restrict to test cases whose product_id equals this value.
epic_id integer no Restrict to test cases linked to this epic via test_case_epics.
task_id integer no Restrict to test cases that share an epic with the given task (via tasks.epic_id → test_case_epics.epic_id).
team_id integer no Restrict to test cases whose denormalised team_id equals this value.
status string no Either "active" or "archived". Default: active — omitting status implicitly filters archived rows out.
search string no Case-insensitive LIKE %search% against name, description, steps, and expected_results.

Request example

curl -sS 'https://api.tasklife.com/v1/test_cases?product_id=7&status=active' \
  -H "Authorization: Bearer ***"

Response 200 OK

The array is nested under data.test_cases[]:

FieldTypeDescription
data.test_cases[].idintegerTest case id.
data.test_cases[].org_idintegerOwning org id.
data.test_cases[].product_idintegerOwning product id.
data.test_cases[].product_namestring | nullDisplay name of the product (joined from project_products).
data.test_cases[].product_prefixstring | nullProduct short prefix (e.g. "WEB").
data.test_cases[].product_colorstring | nullProduct badge color (e.g. "#5a8dee").
data.test_cases[].team_idinteger | nullDenormalised team id (joined from project_products.team_id). null for products without a team.
data.test_cases[].namestringTest case name.
data.test_cases[].descriptionstring | nullMarkdown description, or null.
data.test_cases[].stepsstring | nullMarkdown numbered list of steps, or null.
data.test_cases[].expected_resultsstring | nullMarkdown bullet list of expected results, or null.
data.test_cases[].statusstringEither "active" or "archived".
data.test_cases[].author_user_idintegerUser id who originally authored the test case.
data.test_cases[].author_namestring | nullAuthor display name.
data.test_cases[].epic_idsinteger[]Epic ids the test case is attached to. Always present; may be empty.
data.test_cases[].epicsobject[]Resolved epic metadata ({id, name}) for each entry in epic_ids. Always present; may be empty.
data.test_cases[].created_atstringMySQL DATETIME (e.g. "2026-08-11 14:33:02").
data.test_cases[].updated_atstringMySQL DATETIME; bumped on every save.
data.test_cases[].archived_atstring | nullMySQL DATETIME when archived, or null.
data.test_cases[].archived_by_user_idinteger | nullUser id who archived the test case, or null.
{
  "success": true,
  "data": {
    "test_cases": [
      {
        "id":                   124,
        "org_id":               7,
        "product_id":           7,
        "product_name":         "Website Relaunch",
        "product_prefix":       "WEB",
        "product_color":        "#5a8dee",
        "team_id":              3,
        "name":                 "Signup form rejects invalid email",
        "description":          "Verifies the new validator blocks free-mail addresses per spec.",
        "steps":                "1. Navigate to /signup\n2. Enter \"foo@gmail.com\"\n3. Click Submit",
        "expected_results":     "- Field shows \"Please use your work email\"\n- Form does not submit",
        "status":               "active",
        "author_user_id":       42,
        "author_name":          "Priya Shah",
        "epic_ids":             [902],
        "epics":                [ { "id": 902, "name": "Auth flows" } ],
        "created_at":           "2026-08-11 14:33:02",
        "updated_at":           "2026-08-11 14:33:02",
        "archived_at":          null,
        "archived_by_user_id":  null
      }
    ]
  }
}

POST /test_cases

Create a test case. name and product_id are required. description, steps, expected_results, and epic_ids[] are optional. Returns 201 Created with the full test case (same shape as get). The author is the bearer-token user.

Body fields

FieldTypeRequiredDescription
namestringyesTest case name. Trimmed; empty after trim fails with 400.
product_idintegeryesOwning product id. Must resolve in the API key's org.
descriptionstring | nullnoMarkdown description. Empty string is coerced to null.
stepsstring | nullnoMarkdown numbered list. Each non-empty line must match /^\s*\d+\.\s+\S/. Empty or null is accepted.
expected_resultsstring | nullnoMarkdown bullet list. Each non-empty line must match /^\s*[-*]\s+\S/. Empty or null is accepted.
epic_idsinteger[]noEpic ids to attach. Epics that don't belong to product_id are silently dropped. Duplicates are de-duplicated.

Request example

curl -sS https://api.tasklife.com/v1/test_cases \
  -X POST \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Signup form rejects invalid email",
    "product_id": 7,
    "description": "Verifies the new validator blocks free-mail addresses per spec.",
    "steps": "1. Navigate to /signup\n2. Enter \"foo@gmail.com\"\n3. Click Submit",
    "expected_results": "- Field shows \"Please use your work email\"\n- Form does not submit",
    "epic_ids": [902]
  }'

Response 201 Created

Same shape as get. data.test_case.<field> mirrors the list-row shape above (a single object instead of an array).

GET /test_cases/{id}

Fetch a single test case by id, scoped to the API key's org. Returns 404 if the id doesn't exist or belongs to another org.

Path parameters

FieldTypeRequiredDescription
idintegeryesTest case id.

Request example

curl -sS https://api.tasklife.com/v1/test_cases/124 \
  -H "Authorization: Bearer ***"

Response 200 OK

{
  "success": true,
  "data": {
    "test_case": {
      "id":                   124,
      "org_id":               7,
      "product_id":           7,
      "product_name":         "Website Relaunch",
      "product_prefix":       "WEB",
      "product_color":        "#5a8dee",
      "team_id":              3,
      "name":                 "Signup form rejects invalid email",
      "description":          "Verifies the new validator blocks free-mail addresses per spec.",
      "steps":                "1. Navigate to /signup\n2. Enter \"foo@gmail.com\"\n3. Click Submit",
      "expected_results":     "- Field shows \"Please use your work email\"\n- Form does not submit",
      "status":               "active",
      "author_user_id":       42,
      "author_name":          "Priya Shah",
      "epic_ids":             [902],
      "epics":                [ { "id": 902, "name": "Auth flows" } ],
      "created_at":           "2026-08-11 14:33:02",
      "updated_at":           "2026-08-11 14:33:02",
      "archived_at":          null,
      "archived_by_user_id":  null
    }
  }
}

PUT /test_cases/{id} (PATCH is also accepted)

Update one or more fields on a test case. Field presence matters: a field that is omitted from the request body is left unchanged, but a field that is present (even with an empty value) is applied. name and product_id cannot be cleared — empty / 0 / missing triggers a 400. Every successful save snapshots a new version into test_case_versions.

Body fields

FieldTypeRequiredDescription
namestringno (but cannot be cleared)Replacement name. Empty string fails with 400.
product_idintegerno (but cannot be cleared)Replacement product id. 0 / missing fails with 400.
descriptionstring | nullnoMarkdown description. Empty string is coerced to null.
stepsstring | nullnoMarkdown numbered list. Same validation as create. Empty string is coerced to null.
expected_resultsstring | nullnoMarkdown bullet list. Same validation as create. Empty string is coerced to null.
epic_idsinteger[]noReplace the current attachment set with this array. Pass [] to detach all. Same drop-on-product-mismatch rule as create.

Request example (PATCH one field)

curl -sS https://api.tasklife.com/v1/test_cases/124 \
  -X PATCH \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Signup form rejects free-mail email addresses" }'

Response 200 OK

Same shape as get — the updated test case.

DELETE /test_cases/{id}

Soft-delete the test case. Flips status to "archived", stamps archived_at + archived_by_user_id, and snapshots a new version (action_type "archived"). The row stays in the DB; restore it with POST /test_cases/{id}/restore. Already-archived test cases return 200 with the test case (no error).

Request example

curl -sS -X DELETE https://api.tasklife.com/v1/test_cases/124 \
  -H "Authorization: Bearer ***"

Response 200 OK

The test case (same shape as get) with status: "archived", archived_at populated, and archived_by_user_id set to the bearer-token user.

POST /test_cases/{id}/restore

Flip an archived test case back to status=active. Clears archived_at and archived_by_user_id. Snapshots a new version (action_type "restored"). Calling this on an already-active test case is a no-op that returns the current row.

Request example

curl -sS -X POST https://api.tasklife.com/v1/test_cases/124/restore \
  -H "Authorization: Bearer ***"

Response 200 OK

The test case (same shape as get) with status: "active" and archived_at: null.

GET /test_cases/{id}/versions

List every version row for the test case, ordered by version_number DESC (newest first). One row per save: the initial create, every update, every archive / restore, and every version-restore.

Path parameters

FieldTypeRequiredDescription
idintegeryesTest case id.

Request example

curl -sS https://api.tasklife.com/v1/test_cases/124/versions \
  -H "Authorization: Bearer ***"

Response 200 OK

FieldTypeDescription
data.versions[].idintegerVersion row id.
data.versions[].test_case_idintegerThe owning test case id.
data.versions[].version_numberintegerMonotonically increasing per test case, starting at 1.
data.versions[].namestringSnapshot of test_cases.name at the time of the save.
data.versions[].action_typestringOne of "created", "updated", "archived", "restored", "reverted".
data.versions[].reverted_from_version_idinteger | nullSet only when action_type = "reverted"; otherwise null.
data.versions[].created_byintegerUser id who triggered the save.
data.versions[].created_by_namestringDisplay name of created_by. Falls back to "Unknown" if the user has been deleted.
data.versions[].created_atstringMySQL DATETIME.
{
  "success": true,
  "data": {
    "versions": [
      {
        "id":                          381,
        "test_case_id":                124,
        "version_number":              3,
        "name":                        "Signup form rejects free-mail email addresses",
        "action_type":                 "updated",
        "reverted_from_version_id":    null,
        "created_by":                  42,
        "created_by_name":             "Priya Shah",
        "created_at":                  "2026-08-11 15:02:11"
      },
      {
        "id":                          380,
        "test_case_id":                124,
        "version_number":              2,
        "name":                        "Signup form rejects invalid email",
        "action_type":                 "updated",
        "reverted_from_version_id":    null,
        "created_by":                  42,
        "created_by_name":             "Priya Shah",
        "created_at":                  "2026-08-11 14:55:08"
      },
      {
        "id":                          379,
        "test_case_id":                124,
        "version_number":              1,
        "name":                        "Signup form rejects invalid email",
        "action_type":                 "created",
        "reverted_from_version_id":    null,
        "created_by":                  42,
        "created_by_name":             "Priya Shah",
        "created_at":                  "2026-08-11 14:33:02"
      }
    ]
  }
}

GET /test_cases/{id}/versions/{versionId}

Fetch a single historical version, including the snapshot fields that the list endpoint omits: description, steps, expected_results, product_id, and the epic_ids that were attached at the time of the save.

Path parameters

FieldTypeRequiredDescription
idintegeryesTest case id.
versionIdintegeryesVersion row id (from list versions).

Request example

curl -sS https://api.tasklife.com/v1/test_cases/124/versions/380 \
  -H "Authorization: Bearer ***"

Response 200 OK

{
  "success": true,
  "data": {
    "version": {
      "id":                          380,
      "test_case_id":                124,
      "version_number":              2,
      "name":                        "Signup form rejects invalid email",
      "description":                 "Verifies the new validator blocks free-mail addresses per spec.",
      "steps":                       "1. Navigate to /signup\n2. Enter \"foo@gmail.com\"\n3. Click Submit",
      "expected_results":            "- Field shows \"Please use your work email\"\n- Form does not submit",
      "product_id":                  7,
      "epic_ids":                    [902],
      "action_type":                 "updated",
      "reverted_from_version_id":    null,
      "created_by":                  42,
      "created_by_name":             "Priya Shah",
      "created_at":                  "2026-08-11 14:55:08"
    }
  }
}

POST /test_cases/{id}/versions/{versionId}/restore

Revert the test case to the content of versionId. This is implemented as a new save with action_type = "reverted" and reverted_from_version_id = {versionId} — the history is append-only, no version row is deleted or overwritten. After the call, the test case's updated_at is bumped and version_number increments.

Path parameters

FieldTypeRequiredDescription
idintegeryesTest case id.
versionIdintegeryesVersion row id to revert to.

Request example

curl -sS -X POST https://api.tasklife.com/v1/test_cases/124/versions/380/restore \
  -H "Authorization: Bearer ***"

Response 200 OK

The test case (same shape as get) after the revert.

GET /tasks/{id}/test_cases

List every test case linked to the given task. Same response shape as list test cases. The router verifies the task belongs to the API key's org first and returns 404 otherwise.

Path parameters

FieldTypeRequiredDescription
idintegeryesTask id.

Request example

curl -sS https://api.tasklife.com/v1/tasks/8412/test_cases \
  -H "Authorization: Bearer ***"

Response 200 OK

data.test_cases[] — same shape as list test cases.