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:
| Status | Code | Typical message | Cause |
|---|---|---|---|
400 | BAD_REQUEST | Test case name is required | name missing or empty on create/update. |
400 | BAD_REQUEST | product_id is required | product_id missing on create, or test case no longer has a valid product. |
400 | BAD_REQUEST | Product not found | product_id does not resolve in the API key's org. |
400 | BAD_REQUEST | steps 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. |
400 | BAD_REQUEST | expected_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. |
400 | BAD_REQUEST | Test case belongs to product X but task belongs to product Y | Tried to link a test case and task that don't share a product. |
404 | NOT_FOUND | Test case not found / Version not found / Task not found | The 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).
⚠️ Test-case-specific gotchas
product_idis required on create and cannot be cleared on update —TestCasesService::createTestCase()throws400 — product_id is requiredwhen the field is missing or 0. On update, an absentproduct_idkeeps the existing one; a present value must resolve to a valid product in the API key's org or the request fails with400 — Product not found.stepsmust be a markdown numbered list — the service runs every non-empty line through/^\s*\d+\.\s+\S/. Lines like"Type the email"or"- Type the email"are rejected with a 400. Blank lines between items are allowed. Empty or nullstepsis accepted (no list required).expected_resultsmust be a markdown bullet list — the service runs every non-empty line through/^\s*[-*]\s+\S/. Numbered items are rejected. Empty or nullexpected_resultsis accepted.epic_ids[]is the only M2M attachment surface — epics are pinned to a product. If you POST an epic id that doesn't belong to the test case'sproduct_id, the service drops it (it does not error). Re-POSTing the same set is a no-op.- Every save creates a new version — the service calls
recordVersion()after every successful create / update / archive / restore / version-restore. The current row is alwaysversion_number = N; the version list is orderedversion_number DESC. - Archive is soft-delete —
DELETE /v1/test_cases/{id}flipsstatusto"archived"and stampsarchived_at+archived_by_user_id. The row stays in the DB. The list endpoint filters archived rows out by default; passstatus=archivedto surface them. - Linking a test case to a task will auto-set the task's product — if the task has no product_id, the link endpoint sets it to the test case's product_id so the org-wide constraint holds going forward. The link response includes
task_product_auto_assigned: true | false. - List endpoint has no pagination — the router does not pass
limit/offsettoTestCasesService::listTestCases(). Plan pagination client-side (e.g. paginate by last-seenidor by last-seenupdated_at). - Default list status is
"active"— omittingstatusimplicitly filters out archived rows. To get archived rows, passstatus=archived. (There is no documented value for "all".)
Endpoints at a glance
| Action | Method + Path | Summary |
|---|---|---|
| List test cases | GET /test_cases | List 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 case | POST /test_cases | Create a test case. Required: name, product_id. Optional: description, steps, expected_results, epic_ids[]. Returns 201 with the full test case. |
| Get a test case | GET /test_cases/{id} | Fetch a single test case including epic_ids[] and product metadata. |
| Update a test case | PUT /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 case | DELETE /test_cases/{id} | Soft-delete (flips status to archived). Returns the test case with status=archived. |
| Restore a test case | POST /test_cases/{id}/restore | Flip an archived test case back to status=active. Returns the test case. |
| List versions | GET /test_cases/{id}/versions | List every version row (one per save), newest first. |
| Get a version | GET /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 version | POST /test_cases/{id}/versions/{versionId}/restore | Revert the test case to the content of versionId as a new version (action_type "reverted"). |
| List a task's test cases | GET /tasks/{id}/test_cases | List every test case linked to a given task. Same shape as list test cases. |
| Link a test case to a task | POST /tasks/{id}/test_cases | Link 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 task | DELETE /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
| Field | Type | Required | Description |
|---|---|---|---|
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[]:
| Field | Type | Description |
|---|---|---|
data.test_cases[].id | integer | Test case id. |
data.test_cases[].org_id | integer | Owning org id. |
data.test_cases[].product_id | integer | Owning product id. |
data.test_cases[].product_name | string | null | Display name of the product (joined from project_products). |
data.test_cases[].product_prefix | string | null | Product short prefix (e.g. "WEB"). |
data.test_cases[].product_color | string | null | Product badge color (e.g. "#5a8dee"). |
data.test_cases[].team_id | integer | null | Denormalised team id (joined from project_products.team_id). null for products without a team. |
data.test_cases[].name | string | Test case name. |
data.test_cases[].description | string | null | Markdown description, or null. |
data.test_cases[].steps | string | null | Markdown numbered list of steps, or null. |
data.test_cases[].expected_results | string | null | Markdown bullet list of expected results, or null. |
data.test_cases[].status | string | Either "active" or "archived". |
data.test_cases[].author_user_id | integer | User id who originally authored the test case. |
data.test_cases[].author_name | string | null | Author display name. |
data.test_cases[].epic_ids | integer[] | Epic ids the test case is attached to. Always present; may be empty. |
data.test_cases[].epics | object[] | Resolved epic metadata ({id, name}) for each entry in epic_ids. Always present; may be empty. |
data.test_cases[].created_at | string | MySQL DATETIME (e.g. "2026-08-11 14:33:02"). |
data.test_cases[].updated_at | string | MySQL DATETIME; bumped on every save. |
data.test_cases[].archived_at | string | null | MySQL DATETIME when archived, or null. |
data.test_cases[].archived_by_user_id | integer | null | User 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Test case name. Trimmed; empty after trim fails with 400. |
product_id | integer | yes | Owning product id. Must resolve in the API key's org. |
description | string | null | no | Markdown description. Empty string is coerced to null. |
steps | string | null | no | Markdown numbered list. Each non-empty line must match /^\s*\d+\.\s+\S/. Empty or null is accepted. |
expected_results | string | null | no | Markdown bullet list. Each non-empty line must match /^\s*[-*]\s+\S/. Empty or null is accepted. |
epic_ids | integer[] | no | Epic 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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Test 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no (but cannot be cleared) | Replacement name. Empty string fails with 400. |
product_id | integer | no (but cannot be cleared) | Replacement product id. 0 / missing fails with 400. |
description | string | null | no | Markdown description. Empty string is coerced to null. |
steps | string | null | no | Markdown numbered list. Same validation as create. Empty string is coerced to null. |
expected_results | string | null | no | Markdown bullet list. Same validation as create. Empty string is coerced to null. |
epic_ids | integer[] | no | Replace 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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Test case id. |
Request example
curl -sS https://api.tasklife.com/v1/test_cases/124/versions \
-H "Authorization: Bearer ***"
Response 200 OK
| Field | Type | Description |
|---|---|---|
data.versions[].id | integer | Version row id. |
data.versions[].test_case_id | integer | The owning test case id. |
data.versions[].version_number | integer | Monotonically increasing per test case, starting at 1. |
data.versions[].name | string | Snapshot of test_cases.name at the time of the save. |
data.versions[].action_type | string | One of "created", "updated", "archived", "restored", "reverted". |
data.versions[].reverted_from_version_id | integer | null | Set only when action_type = "reverted"; otherwise null. |
data.versions[].created_by | integer | User id who triggered the save. |
data.versions[].created_by_name | string | Display name of created_by. Falls back to "Unknown" if the user has been deleted. |
data.versions[].created_at | string | MySQL 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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Test case id. |
versionId | integer | yes | Version 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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Test case id. |
versionId | integer | yes | Version 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
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Task 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.
POST /tasks/{id}/test_cases
Link an existing test case to a task. Idempotent: re-linking an existing pair is a no-op (returns success). If the task has no product_id, the endpoint sets it to the test case's product_id so the org-wide constraint holds going forward; the response surfaces this with task_product_auto_assigned: true.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Task id. |
Body fields
| Field | Type | Required | Description |
|---|---|---|---|
test_case_id | integer | yes | Test case id to link. Must resolve in the API key's org. |
Request example
curl -sS -X POST https://api.tasklife.com/v1/tasks/8412/test_cases \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{ "test_case_id": 124 }'
Response 200 OK
{
"success": true,
"data": {
"linked": true,
"test_case_id": 124,
"task_id": 8412,
"task_product_auto_assigned": true
}
}
DELETE /tasks/{id}/test_cases/{testCaseId}
Remove the link between a test case and a task. Idempotent: unlinking a pair that was never linked returns 200 with was_linked: false. Returns 404 only if the test case or task doesn't exist in the API key's org.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Task id. |
testCaseId | integer | yes | Test case id to unlink. |
Request example
curl -sS -X DELETE https://api.tasklife.com/v1/tasks/8412/test_cases/124 \
-H "Authorization: Bearer ***"
Response 200 OK
{
"success": true,
"data": {
"unlinked": true,
"was_linked": true,
"test_case_id": 124,
"task_id": 8412
}
}