From e157c37802a43c10bdd247d588787e9b541f8035 Mon Sep 17 00:00:00 2001 From: eyelidlessness Date: Thu, 26 Sep 2024 15:59:41 -0700 Subject: [PATCH] =?UTF-8?q?scenario:=20test=20prepared=20single-request=20?= =?UTF-8?q?submission=20(`SubmissionResult<=E2=80=98monolithic=E2=80=99>`)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/scenario/test/submission.test.ts | 222 +++++++++++++++++++++- 1 file changed, 213 insertions(+), 9 deletions(-) diff --git a/packages/scenario/test/submission.test.ts b/packages/scenario/test/submission.test.ts index 727d1130b..780740bbd 100644 --- a/packages/scenario/test/submission.test.ts +++ b/packages/scenario/test/submission.test.ts @@ -14,9 +14,12 @@ import { t, title, } from '@getodk/common/test/fixtures/xform-dsl/index.ts'; -import { createEffect } from 'solid-js'; +import { TagXFormsElement } from '@getodk/common/test/fixtures/xform-dsl/TagXFormsElement.ts'; +import type { XFormsElement } from '@getodk/common/test/fixtures/xform-dsl/XFormsElement.ts'; +import { createEffect, createUniqueId } from 'solid-js'; import { beforeEach, describe, expect, it } from 'vitest'; import { Scenario } from '../src/jr/Scenario.ts'; +import { ANSWER_OK, ANSWER_REQUIRED_BUT_EMPTY } from '../src/jr/validation/ValidateOutcome.ts'; describe('Form submission', () => { describe('XFormSerializingVisitorTest.java', () => { @@ -135,7 +138,7 @@ describe('Form submission', () => { ) )); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('grp', @@ -196,7 +199,7 @@ describe('Form submission', () => { it('normalizes combining characters in a default value to their composed form', async () => { const scenario = await getUnicodeScenario(decomposed); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('rep', @@ -211,7 +214,7 @@ describe('Form submission', () => { scenario.answer('/data/rep[1]/inp', decomposed); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('rep', @@ -249,7 +252,7 @@ describe('Form submission', () => { }); it('does not serialize an element for a repeat range', () => { - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('meta', @@ -263,7 +266,7 @@ describe('Form submission', () => { scenario.createNewRepeat('/data/rep'); scenario.answer('/data/rep[2]/inp', 'b'); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('rep', @@ -276,7 +279,7 @@ describe('Form submission', () => { scenario.removeRepeat('/data/rep[1]'); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('rep', @@ -327,7 +330,7 @@ describe('Form submission', () => { it('omits non-relevant leaf nodes', () => { scenario.answer('/data/inp-rel', '0'); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('grp-rel', '1'), @@ -341,7 +344,7 @@ describe('Form submission', () => { it('omits non-relevant subtree nodes', () => { scenario.answer('/data/grp-rel', '0'); - expect(scenario).toHaveSubmissionXML( + expect(scenario).toHaveSerializedSubmissionXML( // prettier-ignore t('data', t('grp-rel', '0'), @@ -590,4 +593,205 @@ describe('Form submission', () => { }); }); }); + + describe('submission payload', () => { + const DEFAULT_INSTANCE_ID = 'uuid:TODO-mock-xpath-functions'; + + // prettier-ignore + type SubmissionFixtureElements = + | readonly [] + | readonly [XFormsElement]; + + interface BuildSubmissionPayloadScenario { + readonly submissionElements?: SubmissionFixtureElements; + } + + const buildSubmissionPayloadScenario = async ( + options?: BuildSubmissionPayloadScenario + ): Promise => { + const scenario = await Scenario.init( + 'Prepare for submission', + html( + head( + title('Prepare for submission'), + model( + mainInstance( + t( + 'data id="prepare-for-submission"', + t('rep', t('inp')), + t('meta', t('instanceID')) + ) + ), + ...(options?.submissionElements ?? []), + bind('/data/rep/inp').required(), + bind('/data/meta/instanceID').calculate(`'${DEFAULT_INSTANCE_ID}'`) + ) + ), + body(repeat('/data/rep', label('rep'), input('/data/rep/inp', label('inp')))) + ) + ); + + return scenario; + }; + + describe('submission definition', () => { + it('includes a default submission definition', async () => { + const scenario = await buildSubmissionPayloadScenario(); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionAction: null, + submissionMethod: 'post', + encryptionKey: null, + }); + }); + + it('includes a form-specified submission definition URL', async () => { + const submissionAction = 'https://example.org'; + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t(`submission action="${submissionAction}"`)], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionAction: new URL(submissionAction), + }); + }); + + it('accepts an explicit method="post" as post', async () => { + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t('submission method="post"')], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionMethod: 'post', + }); + }); + + it('treats method="form-data-post" as method="post"', async () => { + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [t('submission method="form-data-post"')], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + submissionMethod: 'post', + }); + }); + + it.each(['nope', 'not-this-either', 'poast'])( + 'fails to load when form specifies unsupported submission method', + async (otherMethod) => { + const init = async () => { + await buildSubmissionPayloadScenario({ + submissionElements: [t(`submission method="${otherMethod}"`)], + }); + }; + + await expect(init).rejects.toThrow(); + } + ); + + it('includes a form-specified `base64RsaPublicKey` as encryptionKey', async () => { + const base64RsaPublicKey = btoa(createUniqueId()); + const scenario = await buildSubmissionPayloadScenario({ + submissionElements: [ + // Note: `t()` fails here, presumably because the ported JavaRosa + // `parseAttributes` doesn't expect equals signs as produced in + // the trailing base64 value. + new TagXFormsElement( + 'submission', + new Map([['base64RsaPublicKey', base64RsaPublicKey]]), + [] + ), + ], + }); + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult.definition).toMatchObject({ + encryptionKey: base64RsaPublicKey, + }); + }); + }); + + describe('for a single (monolithic) request', () => { + describe('valid submission state', () => { + let scenario: Scenario; + let validSubmissionXML: string; + + beforeEach(async () => { + scenario = await buildSubmissionPayloadScenario(); + + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.createNewRepeat('/data/rep'); + scenario.answer('/data/rep[2]/inp', 'rep 2 inp'); + + // Check assumption: form state is valid + expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_OK); + + // prettier-ignore + validSubmissionXML = t('data', + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp', 'rep 2 inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID)) + ).asXml(); + }); + + it('is ready for submission when instance state is valid', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult).toBeReadyForSubmission(); + }); + + it('includes submission instance XML file data', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + await expect(submissionResult).toHavePreparedSubmissionXML(validSubmissionXML); + }); + }); + + describe('invalid submission state', () => { + let scenario: Scenario; + let invalidSubmissionXML: string; + + beforeEach(async () => { + scenario = await buildSubmissionPayloadScenario(); + + scenario.answer('/data/rep[1]/inp', 'rep 1 inp'); + scenario.createNewRepeat('/data/rep'); + + // Check assumption: form state is valid + expect(scenario.getValidationOutcome().outcome).toBe(ANSWER_REQUIRED_BUT_EMPTY); + + // prettier-ignore + invalidSubmissionXML = t('data', + t('rep', + t('inp', 'rep 1 inp')), + t('rep', + t('inp')), + t('meta', + t('instanceID', DEFAULT_INSTANCE_ID)) + ).asXml(); + }); + + it('is pending submission with violations', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + expect(submissionResult).toBePendingSubmissionWithViolations(); + }); + + it('produces submission instance XML file data even when current instance state is invalid', async () => { + const submissionResult = await scenario.prepareWebFormsSubmission(); + + await expect(submissionResult).toHavePreparedSubmissionXML(invalidSubmissionXML); + }); + }); + }); + + describe.todo('for multiple requests, chunked by maximum size'); + }); });