Update a test case
const url = 'https://beta-api.plune.ai/v1/test-cases/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0';const options = { method: 'PATCH', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"priority":"critical","tags":["smoke","checkout","regression"],"precondition":"A signed-in customer with an empty cart."}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request PATCH \ --url https://beta-api.plune.ai/v1/test-cases/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0 \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "priority": "critical", "tags": [ "smoke", "checkout", "regression" ], "precondition": "A signed-in customer with an empty cart." }'The case form on save, “Move to…” and the lifecycle buttons (api.ts → updateTestCase): any subset of the writable fields, merged and re-validated whole. state moves the case through its lifecycle and is refused with the allowed moves when the matrix does not permit it for this caller — a token cannot retire a case, a person can. stateReason is kept on the audit entry, so the “why” survives the move.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Request Body required
Section titled “Request Body required ”Any subset of the writable fields. The merged record is re-validated against the contract, so a patch that would leave the case invalid is rejected with 400.
object
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
When it last moved. Absent on a case that has not moved.
Three states: omit to keep the current suite, a suite id to re-file the case, or null to take it out of its suite.
Same three states: omit to keep, an eval id to link future runs to this case, null to unlink. Past results stay attached — they are already recorded.
[] strips every tag from the title; a list rewrites its tail. When both title and tags are sent, tags wins over the title’s tail (AC-08).
null clears it.
null clears it.
null clears the table; a missing key keeps it.
object
Who is to run it (B8-team T4): a member of this project — anyone else is refused (400 user … is not a member of this project); null takes the case back; omit to keep. Metadata: the version does not move.
The new slot among the siblings; the others close ranks. A move to another suite without it lands last.
object
One assertion: a type plus whatever that type needs (ADR 0012). The remaining fields depend on type (e.g. values for contains-all, criteria for llm-judge).
object
Examples
Rank, tags and a precondition
{ "priority": "critical", "tags": [ "smoke", "checkout", "regression" ], "precondition": "A signed-in customer with an empty cart."}Hand the case to a member (B8-team T4); `null` takes it back
{ "assigneeId": "6f1c2b3a-0000-4000-8000-0000000000a2"}Responses
Section titled “ Responses ”Updated
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
object
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
A stored test case — the metadata plus the body of its type.
object
Accepted: 1 or 2. Responses are always 2 — a v1 record is upgraded on read.
Carries its @tag tail — the one source of tags (ADR 0032). The dashboard shows the clean title and the tags as chips.
The case’s place in its lifecycle. in_review is a case a proposal produced and nobody has confirmed yet; detached is one whose source stopped being reported. Who may move it where depends on whether the caller is a session or a token.
Derived from state for clients on v1, never stored. in_review answers as draft and detached as active — v1 has no word for either.
Why the case last moved.
Only when reading ONE case: the states the caller may move it to from here, per the lifecycle table and who the caller is - a session is a person, a bearer token is automation. A screen renders exactly these as actions and never lists moves itself. The current state is not included.
When it last moved. Absent on a case that has not moved.
The suite this case is filed under, if any. Always a suite, never a folder (AC-03).
Five levels in rank order — low < normal < important < high < critical. Metadata: changing it does not bump the text version.
Order among the cases of its suite (or the unfiled ones) — dense, 0-based, renumbered on every write that places a case (AC-05).
Read from the title by the server, each once, in first-seen order. Derived: sent back, never taken on the way in as a field of its own — tags on a write rewrites the title.
Free text beside the steps. Part of what a result was produced against, so a change bumps the text version (AC-09). Absent when never set. 16 384 bytes of UTF-8 at most.
What must hold before the steps. Same rules as description.
The parameters table (D7). Content like the two texts: a change bumps the version.
object
The identifiers this case has elsewhere (D2) — attached on every read; absent when it has none.
An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.
object
The id of this case’s eval in plune.yaml. An ingested run attaches its results to the case carrying the same id (ADR 0010); absent for cases the CLI does not run.
Only when reading ONE case: the source text of the one test() call an automated case references — the call itself, header and body, exactly as written in the file. Absent on a case that has never had one; present-and-unchanged when the file most recently failed to parse (the last known snapshot, not empty). 16 384 bytes of UTF-8 at most, the same ceiling description/precondition already carry.
SHA-256 hex digest of codeSnapshot, recomputed by the server on every write that touches it (ADR-0005) — never accepted from a client. Present whenever codeSnapshot is, and on the list too even though the text itself is not: a re-importer hashes its own freshly parsed text the same way and compares against this to decide changed vs unchanged without a second request per case.
Who wrote it (B8-team T3): the userId of the caller of POST /v1/test-cases or of a duplicate. Set by the server, never taken from a body, never changed by a PATCH. Absent on a case from before names were kept and on one the review queue created. Resolve it through GET /v1/members.
Who is to run it (B8-team T4): the userId of a member of the project. Set by PATCH {assigneeId} or the bulk assign action, never on create; null on a PATCH takes it away; leaving the project takes it away too. Absent means unassigned. GET /v1/test-cases?assignee=me|none|<userId> filters on it.
Where the case came from, present only on one an approved proposal created (B-1). Read through that proposal rather than stored on the case, so it answers for every case ever created this way and can never disagree with the queue entry beside it. Absent on a case a person wrote — and absent, too, when the proposal named neither field: both mean nothing was reported.
object
object
The test-design technique the supplier named, as they named it.
Where a field’s text came from, once it was accepted from an AI draft (automated-case-description-draft, AC-17): per field, the model, the date of the draft, who asked, who accepted and when, and — if a person edited the text afterwards — who and when. A field has an entry only while its text is the one a draft produced: the next accepted draft replaces it and clearing the field drops it. The key is absent when no field was ever accepted from a draft. Written only by the case store’s single write path — never taken from a client body, on a PATCH or on Markdown import alike.
object
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
One field’s provenance (AC-17). The same facts the dashboard shows under the field; the hash the server compares the code against is never part of it.
object
The model that drafted this text, as recorded on the call at the time — not the project’s current model: a later change must not relabel old text.
When the draft that produced this text was requested — “the date of the draft”.
userId of the member who asked for the draft — resolve through GET /v1/members like TestCaseMeta.createdBy.
userId of the member who accepted it into the case.
Present when a person edited this field after accepting it (AC-17); absent otherwise.
When editedBy last edited it. Present exactly when editedBy is.
Whether the case’s code snapshot changed, beyond whitespace, since this text was drafted (AC-16, AC-04b) — read again every time the case is read, not frozen when the text was accepted.
Stored with the case and returned unchanged. It links to nothing: golden sets were removed in ADR 0013, so this is a label the platform keeps, not a reference it resolves.
One assertion: a type plus whatever that type needs (ADR 0012). The remaining fields depend on type (e.g. values for contains-all, criteria for llm-judge).
object
Example
{ "schemaVersion": 2, "id": "6f1c2b3a-0000-4000-8000-0000000000c1", "type": "automated", "specRef": "e2e/checkout.spec.ts#empty-cart", "projectId": "u-shop::default", "title": "Checkout — empty cart @smoke @checkout @regression", "state": "draft", "suiteId": "6f1c2b3a-0000-4000-8000-0000000000a1", "description": "An empty cart must not reach the payment step.", "priority": "critical", "position": 0, "createdBy": "u-shop", "createdAt": "2026-09-16T09:12:04.151Z", "updatedAt": "2026-09-16T09:12:04.151Z", "externalKeys": [ { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — empty cart" } ], "status": "draft", "tags": [ "smoke", "checkout", "regression" ], "precondition": "A signed-in customer with an empty cart."}The merged record is invalid, the suite is missing / not yours / a folder, a title of tags only, an invalid tag or an unknown priority — the same words as on create
object
Example generated
{ "error": "example"}No / invalid token or session
Not found or not yours
object
Example generated
{ "error": "example"}A key in externalKeys already belongs to another case — the body then also carries kind, value and ownerCaseId, and nothing was written; OR the case was saved by someone else again and again while this save was being made, and the case itself was not saved (the keys the request named, if it named any, are already saved) — read it again and retry. One save in that window is not a refusal: the case is read again and the change is made on top of it. In a bulk call the same refusal is an entry in refused.
object
Example generated
{ "error": "example"}Rate-limited: either the per-route throttle or a tenant quota. Retry-After carries the seconds until the window rolls over.
object
Example generated
{ "error": "example"}Headers
Section titled “Headers ”Seconds until the window rolls over