From c785480986dedc7da5edd42074d2247a8b7ce2d4 Mon Sep 17 00:00:00 2001 From: dytsou Date: Wed, 1 Jul 2026 01:55:37 +0800 Subject: [PATCH 1/5] feat: add verification for published form in user form list --- .../form-lifecycle/05-form-publishing.http | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scenarios/user-journey/form-lifecycle/05-form-publishing.http b/scenarios/user-journey/form-lifecycle/05-form-publishing.http index 038158a1..8a5a3ef1 100644 --- a/scenarios/user-journey/form-lifecycle/05-form-publishing.http +++ b/scenarios/user-journey/form-lifecycle/05-form-publishing.http @@ -152,11 +152,36 @@ GET {{BASE_URL}}/forms/{{formId}} ### +# ============================================ +# Verify Published Form Appears in User Form List (GET /forms/me) +# ============================================ +# @name verifyFormInUserListing +# @ref getFormAfterPublish +GET {{BASE_URL}}/forms/me + +?? status == 200 +{{ + test.hasResponseBody(); + + const { equal } = require('assert'); + const data = JSON.parse(response.body); + + test('Published form with a future deadline appears in GET /forms/me', () => { + equal(Array.isArray(data), true, 'Response must be a bare array'); + const row = data.find((r) => r.id === formId); + equal(row !== undefined, true, 'Published form should appear in user form list'); + equal(row.deadline !== undefined && row.deadline !== null, true, 'Lifecycle form should have a deadline'); + equal(Array.isArray(row.responseIds), true, 'responseIds must always be an array'); + }); +}} + +### + # ============================================ # Verify Published Form Appears in Listing # ============================================ # @name verifyFormInListing -# @ref getFormAfterPublish +# @ref verifyFormInUserListing GET {{BASE_URL}}/forms {{ From 400baa79e50752cd3f9a426e8db06072dca4cdec Mon Sep 17 00:00:00 2001 From: dytsou Date: Wed, 1 Jul 2026 01:56:13 +0800 Subject: [PATCH 2/5] feat: implement forms me deadline filter test scenario to verify visibility of null-deadline and exclusion of expired forms --- .../05a-forms-me-deadline-filter.http | 255 ++++++++++++++++++ .../user-journey/form-lifecycle/journey.yaml | 5 + 2 files changed, 260 insertions(+) create mode 100644 scenarios/user-journey/form-lifecycle/05a-forms-me-deadline-filter.http diff --git a/scenarios/user-journey/form-lifecycle/05a-forms-me-deadline-filter.http b/scenarios/user-journey/form-lifecycle/05a-forms-me-deadline-filter.http new file mode 100644 index 00000000..29267fbe --- /dev/null +++ b/scenarios/user-journey/form-lifecycle/05a-forms-me-deadline-filter.http @@ -0,0 +1,255 @@ +# ============================================ +# GET /forms/me — deadline filter (CORE-371) +# ============================================ +# Backend excludes expired published forms from the user list while keeping forms +# with no deadline. This file publishes two auxiliary forms and asserts listing behavior. + +# @import ./05-form-publishing.http + +### + +# ============================================ +# Create published form with no deadline +# ============================================ +# @name createNoDeadlineForm +# @ref getFormFinalState +POST {{BASE_URL}}/orgs/{{orgSlug}}/forms +Content-Type: application/json + +{ + "title": "No Deadline Me-List Test Form", + "visibility": "PUBLIC" +} + +?? status <= 201 +{{ + const data = JSON.parse(response.body); + exports.noDeadlineFormId = data.id; +}} + +### + +# @name linkNoDeadlineWorkflow +# @ref createNoDeadlineForm +GET {{BASE_URL}}/forms/{{noDeadlineFormId}}/workflow + +{{ + const { equal } = require('assert'); + const data = JSON.parse(response.body); + const start = data.workflow.find((n) => n.type === 'START' || n.type === 'start'); + const end = data.workflow.find((n) => n.type === 'END' || n.type === 'end'); + equal(start !== undefined, true, 'START node should exist'); + equal(end !== undefined, true, 'END node should exist'); + exports.ndStartId = start.id; + exports.ndEndId = end.id; +}} + +### + +# @name createNoDeadlineSectionNode +# @ref linkNoDeadlineWorkflow +POST {{BASE_URL}}/forms/{{noDeadlineFormId}}/workflow/nodes +Content-Type: application/json + +{ + "type": "SECTION", + "payload": { "x": 1.0, "y": 2.0 } +} + +?? status == 201 +{{ + exports.ndSectionNodeId = JSON.parse(response.body).id; + exports.ndSectionNodeLabel = JSON.parse(response.body).label; +}} + +### + +# @name activateNoDeadlineWorkflow +# @ref createNoDeadlineSectionNode +PUT {{BASE_URL}}/forms/{{noDeadlineFormId}}/workflow +Content-Type: application/json + +[ + { "id": "{{ndStartId}}", "label": "開始表單", "payload": { "x": 0, "y": 0 }, "next": "{{ndSectionNodeId}}" }, + { "id": "{{ndSectionNodeId}}", "label": "{{ndSectionNodeLabel}}", "payload": { "x": 1, "y": 2 }, "next": "{{ndEndId}}" }, + { "id": "{{ndEndId}}", "label": "確認/送出", "payload": { "x": 0, "y": 1 } } +] + +?? status == 200 + +### + +# @name addNoDeadlineQuestion +# @ref activateNoDeadlineWorkflow +GET {{BASE_URL}}/forms/{{noDeadlineFormId}}/sections + +{{ + exports.ndSectionId = JSON.parse(response.body)[0].section.id; +}} + +### + +# @name publishNoDeadlineForm +# @ref addNoDeadlineQuestion +POST {{BASE_URL}}/sections/{{ndSectionId}}/questions +Content-Type: application/json + +{ + "required": true, + "type": "SHORT_TEXT", + "title": "Name", + "order": 1 +} + +?? status == 201 + +### + +# @name publishNoDeadlineFormDone +# @ref publishNoDeadlineForm +POST {{BASE_URL}}/forms/{{noDeadlineFormId}}/publish + +?? status == 200 + +### + +# ============================================ +# Create published form with an expired deadline +# ============================================ +# @name createExpiredForm +# @ref publishNoDeadlineFormDone +POST {{BASE_URL}}/orgs/{{orgSlug}}/forms +Content-Type: application/json + +{ + "title": "Expired Me-List Test Form", + "visibility": "PUBLIC" +} + +?? status <= 201 +{{ + exports.expiredFormId = JSON.parse(response.body).id; +}} + +### + +# @name setExpiredDeadline +# @ref createExpiredForm +PATCH {{BASE_URL}}/forms/{{expiredFormId}} +Content-Type: application/json + +{ + "deadline": "2020-01-01T00:00:00Z" +} + +?? status == 200 + +### + +# @name linkExpiredWorkflow +# @ref setExpiredDeadline +GET {{BASE_URL}}/forms/{{expiredFormId}}/workflow + +{{ + const data = JSON.parse(response.body); + exports.exStartId = data.workflow.find((n) => n.type === 'START' || n.type === 'start').id; + exports.exEndId = data.workflow.find((n) => n.type === 'END' || n.type === 'end').id; +}} + +### + +# @name createExpiredSectionNode +# @ref linkExpiredWorkflow +POST {{BASE_URL}}/forms/{{expiredFormId}}/workflow/nodes +Content-Type: application/json + +{ + "type": "SECTION", + "payload": { "x": 1.0, "y": 2.0 } +} + +?? status == 201 +{{ + const data = JSON.parse(response.body); + exports.exSectionNodeId = data.id; + exports.exSectionNodeLabel = data.label; +}} + +### + +# @name activateExpiredWorkflow +# @ref createExpiredSectionNode +PUT {{BASE_URL}}/forms/{{expiredFormId}}/workflow +Content-Type: application/json + +[ + { "id": "{{exStartId}}", "label": "開始表單", "payload": { "x": 0, "y": 0 }, "next": "{{exSectionNodeId}}" }, + { "id": "{{exSectionNodeId}}", "label": "{{exSectionNodeLabel}}", "payload": { "x": 1, "y": 2 }, "next": "{{exEndId}}" }, + { "id": "{{exEndId}}", "label": "確認/送出", "payload": { "x": 0, "y": 1 } } +] + +?? status == 200 + +### + +# @name addExpiredQuestion +# @ref activateExpiredWorkflow +GET {{BASE_URL}}/forms/{{expiredFormId}}/sections + +{{ + exports.exSectionId = JSON.parse(response.body)[0].section.id; +}} + +### + +# @name publishExpiredForm +# @ref addExpiredQuestion +POST {{BASE_URL}}/sections/{{exSectionId}}/questions +Content-Type: application/json + +{ + "required": true, + "type": "SHORT_TEXT", + "title": "Name", + "order": 1 +} + +?? status == 201 + +### + +# @name publishExpiredFormDone +# @ref publishExpiredForm +POST {{BASE_URL}}/forms/{{expiredFormId}}/publish + +?? status == 200 + +### + +# ============================================ +# GET /forms/me — null-deadline visible, expired hidden +# ============================================ +# @name verifyFormsMeDeadlineFilter +# @ref publishExpiredFormDone +GET {{BASE_URL}}/forms/me + +?? status == 200 +{{ + test.hasResponseBody(); + + const { equal } = require('assert'); + const data = JSON.parse(response.body); + + test('GET /forms/me includes lifecycle and no-deadline forms, excludes expired', () => { + equal(Array.isArray(data), true, 'Response must be a bare array'); + + const lifecycle = data.find((r) => r.id === formId); + const noDeadline = data.find((r) => r.id === noDeadlineFormId); + const expired = data.find((r) => r.id === expiredFormId); + + equal(lifecycle !== undefined, true, 'Published lifecycle form should remain listed'); + equal(noDeadline !== undefined, true, 'Published form without a deadline should remain listed'); + equal(noDeadline.deadline === null || noDeadline.deadline === undefined, true, 'No-deadline form should omit or null deadline'); + equal(expired === undefined, true, 'Published form with a past deadline should be excluded'); + }); +}} diff --git a/scenarios/user-journey/form-lifecycle/journey.yaml b/scenarios/user-journey/form-lifecycle/journey.yaml index 153e08bf..fb72dcb0 100644 --- a/scenarios/user-journey/form-lifecycle/journey.yaml +++ b/scenarios/user-journey/form-lifecycle/journey.yaml @@ -1,6 +1,11 @@ name: "Form Lifecycle" description: "Positive end-to-end coverage for the form lifecycle, including highlight management and archive recovery." cases: + - name: "Forms Me Deadline Filter" + description: "Verify GET /forms/me keeps null-deadline published forms and excludes expired ones (CORE-371)." + path: "05a-forms-me-deadline-filter.http" + test: "verifyFormsMeDeadlineFilter" + - name: "Highlight Lifecycle" description: "Verify highlight configuration, statistics, title updates, and cleanup on a published form." path: "06a-form-highlight.http" From 3dbb6df2321eab7acb91384eb0133a19c8877482 Mon Sep 17 00:00:00 2001 From: dytsou Date: Wed, 1 Jul 2026 01:58:53 +0800 Subject: [PATCH 3/5] refactor: update API documentation for form response operations to clarify draft response creation and improve formatting --- src/form/response/operations.tsp | 33 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/form/response/operations.tsp b/src/form/response/operations.tsp index af8ed404..55565cd7 100644 --- a/src/form/response/operations.tsp +++ b/src/form/response/operations.tsp @@ -3,12 +3,15 @@ using Http; @tag("Responses") namespace CoreSystem.Responses { @summary("Create Form Response") - @doc("Create a new response of a form.") + @doc("Create a new draft response for a form. Each user may have at most one response per form.") @route("/forms/{formId}/responses") @post op createFormResponse(@path formId: uuid): { @statusCode statusCode: 201; @body response: CreateResponse; + } | { + @statusCode statusCode: 400; + @body error: BadRequest; } | { @statusCode statusCode: 404; @body error: NotFound; @@ -107,8 +110,8 @@ namespace CoreSystem.Responses { @post op exportFormResponses(@path formId: uuid, @body req: ExportDownloadRequest): { @statusCode statusCode: 200; - @header ("Content-Type")contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - @header ("Content-Disposition")contentDisposition: string; + @header("Content-Type") contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + @header("Content-Disposition") contentDisposition: string; @body response: bytes; } | { @statusCode statusCode: 404; @@ -117,16 +120,16 @@ namespace CoreSystem.Responses { @summary("Filter And Query Form Responses") @doc("Filter and query form responses based on a list of selected question answers.") - @route("/forms/{formId}/responses/query") - @post - op filterAndQueryFormResponses(@path formId: uuid, @body req: AnswerFilterRequest): { - @statusCode statusCode: 200; - @body response: AnswerFilterResponse; - } | { - @statusCode statusCode: 400; - @body error: BadRequest; - } | { - @statusCode statusCode: 404; - @body error: NotFound; - }; + @route("/forms/{formId}/responses/query") + @post + op filterAndQueryFormResponses(@path formId: uuid, @body req: AnswerFilterRequest): { + @statusCode statusCode: 200; + @body response: AnswerFilterResponse; + } | { + @statusCode statusCode: 400; + @body error: BadRequest; + } | { + @statusCode statusCode: 404; + @body error: NotFound; + }; } From 6d47c7547ea16cd0c620b7962fa620b0aedaa51e Mon Sep 17 00:00:00 2001 From: dytsou Date: Wed, 1 Jul 2026 01:59:51 +0800 Subject: [PATCH 4/5] refactor: clarify scenario response handling by deleting previous responses to ensure single draft per user --- .../03-submit-answers.http | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/scenarios/user-journey/form-rank-from-source-id/03-submit-answers.http b/scenarios/user-journey/form-rank-from-source-id/03-submit-answers.http index 7be8d170..d4201f44 100644 --- a/scenarios/user-journey/form-rank-from-source-id/03-submit-answers.http +++ b/scenarios/user-journey/form-rank-from-source-id/03-submit-answers.http @@ -12,7 +12,8 @@ # with only RANKING. The backend resolves the Ranking choices from the # stored DMC answer (DB fallback path). # -# Each scenario uses its own independent response so the tests are isolated. +# Each scenario uses its own response on the same form. Scenario B runs after +# deleting scenario A's response so only one draft exists per user at a time. # Import form creation from Phase 2 # @import ./02-form-creation.http @@ -172,6 +173,16 @@ Content-Type: application/json }); }} +### +# ============================================ +# Delete response A so scenario B can create a fresh draft (one response per user) +# ============================================ +# @name deleteResponseA +# @ref submitBatchA +DELETE {{BASE_URL}}/forms/{{formId}}/responses/{{responseIdA}} + +?? status == 204 + ### # ============================================ # SCENARIO B: Two-step submission @@ -181,7 +192,7 @@ Content-Type: application/json # [B] Create Response # ============================================ # @name createResponseB -# @ref verifyFormPublished +# @ref deleteResponseA POST {{BASE_URL}}/forms/{{formId}}/responses {{ @@ -202,7 +213,7 @@ POST {{BASE_URL}}/forms/{{formId}}/responses ### # ============================================ -# GET /forms/me after response B — responseIds lists both responses for this form +# GET /forms/me after response B — responseIds tracks the new draft only # ============================================ # @name listUserFormsAfterResponseB # @ref createResponseB @@ -215,14 +226,14 @@ GET {{BASE_URL}}/forms/me const { equal } = require('assert'); const data = JSON.parse(response.body); - test('GET /forms/me includes both response ids for this form', () => { + test('GET /forms/me includes only response B after scenario A was deleted', () => { equal(Array.isArray(data), true, 'Response must be a bare array'); const row = data.find((r) => r.id === formId); equal(row !== undefined, true, 'User form list should include this form'); equal(Array.isArray(row.responseIds), true, 'responseIds must always be an array'); - equal(row.responseIds.includes(responseIdA), true, 'responseIds should include response A'); equal(row.responseIds.includes(responseIdB), true, 'responseIds should include response B'); - equal(row.responseIds.length >= 2, true, 'responseIds should list at least both responses'); + equal(row.responseIds.includes(responseIdA), false, 'responseIds should not include deleted response A'); + equal(row.responseIds.length, 1, 'Only one response per user per form'); }); }} From 0cd9ae603d3eaf731f7386849af24591a23fa5d3 Mon Sep 17 00:00:00 2001 From: dytsou Date: Wed, 1 Jul 2026 02:00:15 +0800 Subject: [PATCH 5/5] refactor: update negative response creation tests to handle duplicate responses and improve validation checks --- .../06-response-creation-negative.http | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/scenarios/user-journey/negative-form-lifecycle/06-response-creation-negative.http b/scenarios/user-journey/negative-form-lifecycle/06-response-creation-negative.http index 5037bd36..c8450313 100644 --- a/scenarios/user-journey/negative-form-lifecycle/06-response-creation-negative.http +++ b/scenarios/user-journey/negative-form-lifecycle/06-response-creation-negative.http @@ -599,26 +599,46 @@ GET {{BASE_URL}}/forms/00000000-0000-0000-0000-000000000000/responses ### # ============================================ -# Create draft response for cancel validation +# Reuse existing draft response for cancel validation (one response per user) # ============================================ -# @name createDraftResponseForCancel +# @name prepareDraftResponseForCancel # @ref listResponsesNonExistentForm +GET {{BASE_URL}}/forms/{{formId}}/responses/{{responseId}} + +?? status == 200 +{{ + test.hasResponseBody(); + + const { equal } = require('assert'); + const data = JSON.parse(response.body); + + test('Positive flow left a single draft response to reuse', () => { + equal(data.progress, 'DRAFT', 'Existing response should still be draft'); + }); + + exports.draftResponseId = responseId; +}} + +### + +# ============================================ +# Create second response while draft exists (expect 400) +# ============================================ +# @name createDuplicateResponse +# @ref prepareDraftResponseForCancel POST {{BASE_URL}}/forms/{{formId}}/responses -?? status == 201 +?? status == 400 {{ test.hasResponseBody(); const { equal } = require('assert'); - test('Draft response created successfully', () => { + test('Error body indicates duplicate response', () => { const data = JSON.parse(response.body); - equal(data.id !== undefined, true, 'Response ID should exist'); - console.log('Created draft response:', data.id); + equal(data.title, 'Validation Problem', 'Response title should be Validation Problem'); + equal(data.status, 400, 'Status in body should be 400'); }); - - const data = JSON.parse(response.body); - exports.draftResponseId = data.id; }} ### @@ -627,7 +647,7 @@ POST {{BASE_URL}}/forms/{{formId}}/responses # Cancel draft response submission (expect 400) # ============================================ # @name cancelDraftResponseSubmission -# @ref createDraftResponseForCancel +# @ref createDuplicateResponse POST {{BASE_URL}}/responses/{{draftResponseId}}/cancel ?? status == 400