From 369fe757beb7b7e5893ad420327f3b787f692e94 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2026 00:35:31 +0100 Subject: [PATCH 1/3] feat(api): add opening date for forms Add an `opens` timestamp to forms, the counterpart to `expires`. Before that time a form does not accept responses and the public link shows a "not yet open" page instead of the questions. An opening date must be before the expiration date. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- docs/API_v3.md | 4 ++ docs/DataStructure.md | 2 + lib/Constants.php | 1 + lib/Controller/ApiController.php | 28 ++++++++ lib/Controller/PageController.php | 5 ++ lib/Db/Form.php | 6 ++ lib/FormsMigrator.php | 1 + .../Version050500Date20261030000000.php | 41 ++++++++++++ lib/ResponseDefinitions.php | 2 + lib/Service/FormsService.php | 12 ++++ openapi.json | 10 +++ tests/Integration/Api/ApiV3Test.php | 49 ++++++++++++++ .../Api/RespectAdminSettingsTest.php | 1 + tests/Unit/Controller/ApiControllerTest.php | 67 +++++++++++++++++++ tests/Unit/Controller/PageControllerTest.php | 46 +++++++++++++ tests/Unit/FormsMigratorTest.php | 14 +++- tests/Unit/Service/FormsServiceTest.php | 61 +++++++++++++++++ 17 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 lib/Migration/Version050500Date20261030000000.php diff --git a/docs/API_v3.md b/docs/API_v3.md index 9ce0f136a..511a6a30f 100644 --- a/docs/API_v3.md +++ b/docs/API_v3.md @@ -88,6 +88,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "yWeMwcwCwoqRs8T2", "title": "Form 2", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -103,6 +104,7 @@ Returns condensed objects of all Forms beeing owned by the authenticated user. "hash": "em4djk8B9BpXnkYG", "title": "Form 1", "expires": 0, + "opens": 0, "permissions": [ "edit", "results", @@ -166,6 +168,7 @@ Returns the full-depth object of the requested form (without submissions). "showToAllUsers": false }, "expires": 0, + "opens": 0, "fileFormat": "csv", "fileId": 157, "filePath": "foo/bar", @@ -281,6 +284,7 @@ Update a single or multiple properties of a form-object. Concerns **only** the F - To transfer the ownership of a form to another user, you must only send a single _keyValuePair_ containing the key `ownerId` and the user id of the new owner. - To link a file for submissions, the _keyValuePairs_ need to contain the keys `path` and `fileFormat` - To unlink a file for submissions, the _keyValuePairs_ need to contain the keys `fileId` and `fileFormat` need to contain the value `null` + - If both `opens` and `expires` are set (not `0`), `opens` must be before `expires` - Response: **Status-Code OK**, as well as the id of the updated form. ``` diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 570057b98..d0e9acfa3 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -28,6 +28,7 @@ This document describes the Object-Structure, that is used within the Forms App | created | unix timestamp | | When the form has been created | | access | [Access-Object](#access-object) | | Describing access-settings of the form | | expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ | +| opens | unix-timestamp | | From when the form accepts responses. Timestamp `0` indicates _immediately_ | | isAnonymous | Boolean | | If Answers will be stored anonymously | | state | Integer | [Form state](#form-state) | The state of the form | | lockedBy | String | | The user ID for who has exclusive edit access at the moment | @@ -57,6 +58,7 @@ This document describes the Object-Structure, that is used within the Forms App "created": 1611240961, "access": {}, "expires": 0, + "opens": 0, "isAnonymous": false, "submitMultiple": true, "allowEditSubmissions": false, diff --git a/lib/Constants.php b/lib/Constants.php index 21bed641a..3e465cd0c 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -275,6 +275,7 @@ class Constants { */ public const EMPTY_EXPIRED = 'expired'; public const EMPTY_NOTFOUND = 'notfound'; + public const EMPTY_NOTYETOPEN = 'notyetopen'; /** * Constants related to extra settings for questions diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index c6a4e9107..752a03b89 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -178,6 +178,7 @@ public function newForm(?int $fromId = null): DataResponse { $form->setAllowEditSubmissions(false); $form->setShowExpiration(false); $form->setExpires(0); + $form->setOpens(0); $form->setIsAnonymous(false); $this->formMapper->insert($form); @@ -212,6 +213,7 @@ public function newForm(?int $fromId = null): DataResponse { $formData['allowEditSubmissions'] = false; $formData['showExpiration'] = false; $formData['expires'] = 0; + $formData['opens'] = 0; $formData['isAnonymous'] = false; $form = Form::fromParams($formData); @@ -282,6 +284,7 @@ public function getForm(int $formId): DataResponse { * @param int $formId FormId of form to update * @param array $keyValuePairs Array of key=>value pairs to update. * @return DataResponse + * @throws OCSBadRequestException Invalid opening date or opening date not before expiration date * @throws OCSBadRequestException Could not find new form owner * @throws OCSForbiddenException Empty keyValuePairs provided * @throws OCSForbiddenException Not allowed to update id, hash, created, fileId or lastUpdated. OwnerId only allowed if no other key provided. @@ -336,6 +339,8 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse { // Do not allow changing showToAllUsers or permitAllUsers if disabled $this->checkAccessUpdate($keyValuePairs); + $this->checkOpeningDateUpdate($form, $keyValuePairs); + $previousAccess = $form->getAccess(); $previousState = $form->getState(); $previousSettings = $this->getResponseSettings($form); @@ -1950,6 +1955,28 @@ private function checkForbiddenKeys(array $keyValuePairs): void { } } + /** + * Throws if the opening date is invalid or not before the expiration date + */ + private function checkOpeningDateUpdate(Form $form, array $keyValuePairs): void { + if (!array_key_exists('opens', $keyValuePairs) && !array_key_exists('expires', $keyValuePairs)) { + return; + } + + if (array_key_exists('opens', $keyValuePairs) + && (!is_int($keyValuePairs['opens']) || $keyValuePairs['opens'] < 0)) { + $this->logger->info('Invalid opening date'); + throw new OCSBadRequestException('Invalid opening date'); + } + + $opens = $keyValuePairs['opens'] ?? $form->getOpens(); + $expires = $keyValuePairs['expires'] ?? $form->getExpires(); + if ($opens > 0 && $expires > 0 && $opens >= $expires) { + $this->logger->info('Opening date must be before expiration date'); + throw new OCSBadRequestException('Opening date must be before expiration date'); + } + } + /** * Throws if fileId is present in update */ @@ -2103,6 +2130,7 @@ private function getResponseSettings(Form $form): array { 'submitMultiple' => $form->getSubmitMultiple(), 'allowEditSubmissions' => $form->getAllowEditSubmissions(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'maxSubmissions' => $form->getMaxSubmissions(), ]; } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 457cb9b3a..ef0b54e5b 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -203,6 +203,11 @@ protected function createPublicSubmitView(Form $form, string $hash): TemplateRes return $this->provideEmptyContent(Constants::EMPTY_EXPIRED, $form); } + if (!$this->formsService->hasFormOpened($form)) { + $this->initialState->provideInitialState('opens', $form->getOpens()); + return $this->provideEmptyContent(Constants::EMPTY_NOTYETOPEN, $form); + } + $this->insertHeaderOnIos(); // Inject style on all templates diff --git a/lib/Db/Form.php b/lib/Db/Form.php index bd071e03d..0bd26232a 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -31,6 +31,8 @@ * @method void setCreated(int $value) * @method int getExpires() * @method void setExpires(int $value) + * @method int getOpens() + * @method void setOpens(int $value) * @method int getIsAnonymous() * @method void setIsAnonymous(bool $value) * @method int getSubmitMultiple() @@ -74,6 +76,7 @@ class Form extends Entity { protected $accessEnum; protected $created; protected $expires; + protected $opens; protected $isAnonymous; protected $submitMultiple; protected $allowEditSubmissions; @@ -96,6 +99,7 @@ class Form extends Entity { public function __construct() { $this->addType('created', 'integer'); $this->addType('expires', 'integer'); + $this->addType('opens', 'integer'); $this->addType('isAnonymous', 'boolean'); $this->addType('submitMultiple', 'boolean'); $this->addType('allowEditSubmissions', 'boolean'); @@ -172,6 +176,7 @@ public function setAccess(array $access): void { * created: int, * access: FormsAccess, * expires: int, + * opens: int, * isAnonymous: bool, * submitMultiple: bool, * allowEditSubmissions: bool, @@ -201,6 +206,7 @@ public function read() { 'created' => $this->getCreated(), 'access' => $this->getAccess(), 'expires' => (int)$this->getExpires(), + 'opens' => (int)$this->getOpens(), 'isAnonymous' => (bool)$this->getIsAnonymous(), 'submitMultiple' => (bool)$this->getSubmitMultiple(), 'allowEditSubmissions' => (bool)$this->getAllowEditSubmissions(), diff --git a/lib/FormsMigrator.php b/lib/FormsMigrator.php index 91b4ad64b..45fe1c836 100644 --- a/lib/FormsMigrator.php +++ b/lib/FormsMigrator.php @@ -144,6 +144,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface $form->setOwnerId($user->getUID()); $form->setAccess($formData['access']); $form->setExpires($formData['expires']); + $form->setOpens($formData['opens'] ?? 0); $form->setIsAnonymous($formData['isAnonymous']); $form->setSubmitMultiple($formData['submitMultiple']); $form->setAllowEditSubmissions($formData['allowEditSubmissions']); diff --git a/lib/Migration/Version050500Date20261030000000.php b/lib/Migration/Version050500Date20261030000000.php new file mode 100644 index 000000000..f3c1ae304 --- /dev/null +++ b/lib/Migration/Version050500Date20261030000000.php @@ -0,0 +1,41 @@ +getTable('forms_v2_forms'); + + if ($table->hasColumn('opens')) { + return null; + } + + $table->addColumn('opens', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'comment' => 'Timestamp from which the form accepts responses, 0 means immediately', + ]); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d658b910..15ed97324 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -106,6 +106,7 @@ * hash: string, * title: string, * expires: int, + * opens: int, * permissions: list, * partial: true, * state: int, @@ -123,6 +124,7 @@ * created: int, * access: FormsAccess, * expires: int, + * opens: int, * fileFormat: ?string, * fileId: ?int, * filePath?: ?string, diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 65505267e..d67fc625b 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -266,6 +266,7 @@ public function getPartialFormArrays(array $forms): array { 'hash' => $form->getHash(), 'title' => $form->getTitle(), 'expires' => $form->getExpires(), + 'opens' => $form->getOpens(), 'lastUpdated' => $form->getLastUpdated(), 'permissions' => $permissions, 'partial' => true, @@ -407,6 +408,10 @@ public function loadFormForSubmission(int $formId, string $shareHash): Form { throw new OCSForbiddenException('This form is no longer taking answers'); } + if (!$this->hasFormOpened($form)) { + throw new OCSForbiddenException('This form is not yet taking answers'); + } + return $form; } @@ -671,6 +676,13 @@ public function hasFormExpired(Form $form): bool { return ($form->getExpires() !== 0 && $form->getExpires() < time()); } + /** + * Check if the opening date of the form has been reached. + */ + public function hasFormOpened(Form $form): bool { + return $form->getOpens() <= time(); + } + /** * Get DisplayNames to Shares * diff --git a/openapi.json b/openapi.json index 694617425..4336a8c92 100644 --- a/openapi.json +++ b/openapi.json @@ -103,6 +103,7 @@ "created", "access", "expires", + "opens", "fileFormat", "fileId", "isAnonymous", @@ -154,6 +155,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "fileFormat": { "type": "string", "nullable": true @@ -345,6 +350,7 @@ "hash", "title", "expires", + "opens", "permissions", "partial", "state", @@ -367,6 +373,10 @@ "type": "integer", "format": "int64" }, + "opens": { + "type": "integer", + "format": "int64" + }, "permissions": { "type": "array", "items": { diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php index 92c6e2592..3da8ea088 100644 --- a/tests/Integration/Api/ApiV3Test.php +++ b/tests/Integration/Api/ApiV3Test.php @@ -309,6 +309,7 @@ public static function dataGetForms() { 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lastUpdated' => 123456789, 'permissions' => Constants::PERMISSION_ALL, @@ -321,6 +322,7 @@ public static function dataGetForms() { 'hash' => 'zyxwvutsrq654321', 'title' => 'Title of a third Form', 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lastUpdated' => 123456789, 'permissions' => Constants::PERMISSION_ALL, @@ -356,6 +358,7 @@ public static function dataGetSharedForms() { 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lastUpdated' => 123456789, 'permissions' => [ @@ -400,6 +403,7 @@ public static function dataGetNewForm() { 'showToAllUsers' => false ], 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lockedBy' => null, 'lockedUntil' => null, @@ -469,6 +473,7 @@ public static function dataGetFullForm() { 'showToAllUsers' => false ], 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lockedBy' => null, 'lockedUntil' => null, @@ -738,6 +743,24 @@ public function testUpdateFormRejectsForbiddenKeys(): void { $this->assertEquals($before, $after); } + public function testUpdateFormRejectsOpeningDateAfterExpiration(): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'expires' => time() + 3600, + 'opens' => time() + 7200, + ], + ], + 'http_errors' => false, + ]); + + $this->assertEquals(400, $resp->getStatusCode()); + + $form = $this->OcsResponse2Data($this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}")); + $this->assertEquals(0, $form['expires']); + $this->assertEquals(0, $form['opens']); + } + public function testDeleteForm() { $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}"); $data = $this->OcsResponse2Data($resp); @@ -1655,6 +1678,32 @@ public function testNewSubmission() { ], $data['submissions'][0]); } + public function testNewSubmissionBeforeOpeningDate() { + $opens = time() + 3600; + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'opens' => $opens, + ], + ], + ]); + $this->assertEquals(200, $resp->getStatusCode()); + + $form = $this->OcsResponse2Data($this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}")); + $this->assertEquals($opens, $form['opens']); + + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions", [ + 'json' => [ + 'answers' => [ + $this->testForms[0]['questions'][0]['id'] => ['ShortAnswer!'], + ], + ], + 'http_errors' => false, + ]); + + $this->assertEquals(403, $resp->getStatusCode()); + } + public static function dataDeleteSingleSubmission() { $submissionsExpected = self::dataGetSubmissions()['getSubmissions']['expected']; array_splice($submissionsExpected['submissions'], 0, 1); diff --git a/tests/Integration/Api/RespectAdminSettingsTest.php b/tests/Integration/Api/RespectAdminSettingsTest.php index 3d18e532a..227b7322d 100644 --- a/tests/Integration/Api/RespectAdminSettingsTest.php +++ b/tests/Integration/Api/RespectAdminSettingsTest.php @@ -120,6 +120,7 @@ private static function sharedTestForms(): array { 'description' => '', 'created' => 12345, 'expires' => 0, + 'opens' => 0, 'state' => 0, 'lockedBy' => null, 'lockedUntil' => null, diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index 0de992dcc..512f38db2 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -584,6 +584,7 @@ public static function dataTestCreateNewForm() { 'showToAllUsers' => false, ], 'expires' => 0, + 'opens' => 0, 'isAnonymous' => false, 'submitMultiple' => false, 'showExpiration' => false, @@ -683,6 +684,7 @@ public static function dataCloneForm() { 'ownerId' => 'currentUser', 'description' => '', 'expires' => 0, + 'opens' => 1700000000, 'isAnonymous' => false, 'submitMultiple' => false, 'showExpiration' => false @@ -698,6 +700,7 @@ public static function dataCloneForm() { 'ownerId' => 'currentUser', 'description' => '', 'expires' => 0, + 'opens' => 0, 'isAnonymous' => false, 'submitMultiple' => false, 'showExpiration' => false @@ -732,6 +735,7 @@ public function testCloneForm($old, $new) { self::assertInstanceOf(Form::class, $form); self::assertNull($form->getId()); self::assertEquals($form->getHash(), 'new hash'); + self::assertSame(0, $form->getOpens()); $form->setId(14); return true; })); @@ -1602,6 +1606,7 @@ private function createUpdatableForm(): Form { $form->setSubmitMultiple(false); $form->setAllowEditSubmissions(false); $form->setExpires(0); + $form->setOpens(0); $this->formsService ->method('getFormIfAllowed') @@ -1666,6 +1671,68 @@ public function testUpdateFormLogsOnlyNamesOfChangedResponseSettings(): void { ]); } + public static function dataUpdateFormRejectsInvalidOpeningDate(): array { + return [ + 'negative' => [0, 0, ['opens' => -1]], + 'not an integer' => [0, 0, ['opens' => '1700000000']], + 'after expiration' => [0, 1700000000, ['opens' => 1700003600]], + 'equal to expiration' => [0, 0, ['opens' => 1700000000, 'expires' => 1700000000]], + 'expiration before opening' => [1700003600, 0, ['expires' => 1700000000]], + ]; + } + + /** + * @dataProvider dataUpdateFormRejectsInvalidOpeningDate + */ + public function testUpdateFormRejectsInvalidOpeningDate(int $opens, int $expires, array $keyValuePairs): void { + $form = $this->createUpdatableForm(); + $form->setOpens($opens); + $form->setExpires($expires); + + $this->formMapper->expects($this->never())->method('update'); + $this->expectNoAuditLog(); + + $this->expectException(OCSBadRequestException::class); + $this->apiController->updateForm(1, $keyValuePairs); + } + + public static function dataUpdateFormAcceptsValidOpeningDate(): array { + return [ + 'without expiration' => [0, 0, ['opens' => 1700000000], 1700000000, 0], + 'before expiration' => [0, 1700003600, ['opens' => 1700000000], 1700000000, 1700003600], + 'both at once' => [0, 0, ['opens' => 1700000000, 'expires' => 1700003600], 1700000000, 1700003600], + 'removing expiration' => [1700003600, 1700007200, ['expires' => 0], 1700003600, 0], + 'removing opening date' => [1700003600, 1700007200, ['opens' => 0], 0, 1700007200], + ]; + } + + /** + * @dataProvider dataUpdateFormAcceptsValidOpeningDate + */ + public function testUpdateFormAcceptsValidOpeningDate(int $opens, int $expires, array $keyValuePairs, int $expectedOpens, int $expectedExpires): void { + $form = $this->createUpdatableForm(); + $form->setOpens($opens); + $form->setExpires($expires); + + $this->formMapper->expects($this->once()) + ->method('update') + ->with($form); + + $this->assertEquals(new DataResponse(1), $this->apiController->updateForm(1, $keyValuePairs)); + $this->assertSame($expectedOpens, $form->getOpens()); + $this->assertSame($expectedExpires, $form->getExpires()); + } + + public function testUpdateFormLogsOpeningDateChange(): void { + $this->createUpdatableForm(); + + $this->apiController->updateForm(1, ['opens' => 1700000000]); + + $this->assertAuditLogged([ + [Operation::FormSettingsUpdated, 'Response settings of form %s were changed', ['formId' => 1, 'changedKeys' => ['opens']]], + ]); + } + public function testUpdateFormLogsLinkedResultsFile(): void { $form = $this->createUpdatableForm(); $file = $this->createMock(File::class); diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index f1a21e942..d411d7a76 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -8,7 +8,9 @@ namespace OCA\Forms\Tests\Unit\Controller; +use OCA\Forms\Constants; use OCA\Forms\Controller\PageController; +use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\ShareMapper; use OCA\Forms\Service\ConfigService; @@ -84,4 +86,48 @@ public function testSetEmbeddedCSP() { ->with(self::callback(fn (ContentSecurityPolicy $csp): bool => preg_match('/frame-ancestors[^;]* \*[ ;]/', $csp->buildPolicy()) !== false)); TestCase::invokePrivate($this->pageController, 'setEmbeddedCSP', [$response]); } + + public function testPublicSubmitViewOfNotYetOpenedForm() { + $form = new Form(); + $form->setOpens(1700000000); + $this->formsService->method('hasFormExpired')->willReturn(false); + $this->formsService->method('hasFormOpened')->with($form)->willReturn(false); + $this->formsService->expects($this->never())->method('getPublicForm'); + $this->userSession->method('isLoggedIn')->willReturn(true); + + $initialStates = []; + $this->initialState->method('provideInitialState') + ->willReturnCallback(function (string $key, $value) use (&$initialStates) { + $initialStates[$key] = $value; + }); + + TestCase::invokePrivate($this->pageController, 'createPublicSubmitView', [$form, 'hash']); + + $this->assertSame([ + 'opens' => 1700000000, + 'renderAs' => Constants::EMPTY_NOTYETOPEN, + ], $initialStates); + } + + public function testPublicSubmitViewOfOpenedForm() { + $form = new Form(); + $this->formsService->method('hasFormExpired')->willReturn(false); + $this->formsService->method('hasFormOpened')->with($form)->willReturn(true); + $this->formsService->expects($this->once()) + ->method('getPublicForm') + ->with($form) + ->willReturn([]); + $this->userSession->method('isLoggedIn')->willReturn(true); + + $initialStates = []; + $this->initialState->method('provideInitialState') + ->willReturnCallback(function (string $key, $value) use (&$initialStates) { + $initialStates[$key] = $value; + }); + + TestCase::invokePrivate($this->pageController, 'createPublicSubmitView', [$form, 'hash']); + + $this->assertArrayHasKey('form', $initialStates); + $this->assertArrayNotHasKey('renderAs', $initialStates); + } } diff --git a/tests/Unit/FormsMigratorTest.php b/tests/Unit/FormsMigratorTest.php index a380cc464..02d6a5fa6 100644 --- a/tests/Unit/FormsMigratorTest.php +++ b/tests/Unit/FormsMigratorTest.php @@ -82,6 +82,7 @@ public static function dataExport() { "showToAllUsers": false }, "expires": 0, + "opens": 0, "state": 0, "lockedBy": null, "lockedUntil": null, @@ -244,16 +245,22 @@ public function testExport(string $expectedJson) { public static function dataImport() { return [ 'exactlyOneOfEach' => [ - 'inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"confirmationEmailEnabled":true,"confirmationEmailSubject":"Thanks","confirmationEmailBody":"Body","confirmationEmailQuestionId":null,"allowComments":false,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]' + 'inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"confirmationEmailEnabled":true,"confirmationEmailSubject":"Thanks","confirmationEmailBody":"Body","confirmationEmailQuestionId":null,"allowComments":false,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]', + 'expectedOpens' => 0, + ], + 'withOpeningDate' => [ + 'inputJson' => '[{"title":"Link","description":"","created":1646251830,"access":{"permitAllUsers":false,"showToAllUsers":false},"expires":0,"opens":1700000000,"state":0,"lockedBy":null,"lockedUntil":null,"maxSubmissions":null,"isAnonymous":false,"submitMultiple":false,"allowEditSubmissions":false,"showExpiration":false,"lastUpdated":123456789,"confirmationEmailEnabled":true,"confirmationEmailSubject":"Thanks","confirmationEmailBody":"Body","confirmationEmailQuestionId":null,"allowComments":false,"questions":[{"id":14,"order":2,"type":"multiple","isRequired":false,"text":"checkbox","description":"huhu","extraSettings":{},"options":[{"text":"ans1"}]}],"submissions":[{"userId":"anyUser@localhost","timestamp":1651354059,"answers":[{"questionId":14,"text":"ans1"}]}]}]', + 'expectedOpens' => 1700000000, ] ]; } /** * @param string $inputJson JsonString to input + * @param int $expectedOpens Opening date the imported form is expected to have */ #[DataProvider('dataImport')] - public function testImport(string $inputJson) { + public function testImport(string $inputJson, int $expectedOpens) { $user = $this->createMock(IUser::class); $importSource = $this->createMock(IImportSource::class); $output = $this->createMock(OutputInterface::class); @@ -276,7 +283,8 @@ public function testImport(string $inputJson) { $this->formMapper->expects($this->once()) ->method('insert') - ->willReturnCallback(function (Form $form) { + ->willReturnCallback(function (Form $form) use ($expectedOpens) { + $this->assertSame($expectedOpens, $form->getOpens()); $this->assertTrue($form->getConfirmationEmailEnabled()); $this->assertSame('Thanks', $form->getConfirmationEmailSubject()); $this->assertSame('Body', $form->getConfirmationEmailBody()); diff --git a/tests/Unit/Service/FormsServiceTest.php b/tests/Unit/Service/FormsServiceTest.php index eac660ef9..7952ed0c6 100644 --- a/tests/Unit/Service/FormsServiceTest.php +++ b/tests/Unit/Service/FormsServiceTest.php @@ -48,6 +48,7 @@ function microtime(bool|float $asFloat = false) { use OCA\Forms\Service\FormsService; use OCA\Forms\Tests\Unit\UserFolderMockTrait; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IFilenameValidator; use OCP\Files\IRootFolder; @@ -198,6 +199,7 @@ public static function dataGetForm() { 'showToAllUsers' => false, ], 'expires' => 0, + 'opens' => 0, 'isAnonymous' => false, 'submitMultiple' => true, 'showExpiration' => false, @@ -291,6 +293,7 @@ public function testGetForm(array $expected) { 'showToAllUsers' => false, ]); $form->setExpires(0); + $form->setOpens(0); $form->setIsAnonymous(false); $form->setSubmitMultiple(true); $form->setShowExpiration(false); @@ -376,6 +379,7 @@ public static function dataGetPartialForm() { 'hash' => 'abcdefg', 'title' => 'Form 1', 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'permissions' => Constants::PERMISSION_ALL, 'submissionCount' => 123, @@ -398,6 +402,7 @@ public function testGetPartialForm(array $expected) { $form->setTitle('Form 1'); $form->setOwnerId('currentUser'); $form->setExpires(0); + $form->setOpens(0); $form->setLastUpdated(123456789); $this->submissionMapper->expects($this->once()) @@ -416,6 +421,7 @@ public static function dataGetPartialFormShared() { 'hash' => 'abcdefg', 'title' => 'Form 1', 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'permissions' => ['results', 'submit'], 'submissionCount' => 123, @@ -440,6 +446,7 @@ public function testGetPartialFormShared(array $expected) { $form->setTitle('Form 1'); $form->setOwnerId('otherUser'); $form->setExpires(0); + $form->setOpens(0); $form->setLastUpdated(123456789); $share = new Share(); @@ -472,6 +479,7 @@ public function testGetPartialFormSharedUserSubmissions() { 'hash' => 'abcdefg', 'title' => 'Form 1', 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'permissions' => ['submit', 'results'], 'submissionCount' => 2, @@ -488,6 +496,7 @@ public function testGetPartialFormSharedUserSubmissions() { $form->setTitle('Form 1'); $form->setOwnerId('otherUser'); $form->setExpires(0); + $form->setOpens(0); $form->setLastUpdated(123456789); $share = new Share(); @@ -528,6 +537,7 @@ public function testGetPartialFormArraysCountsInBulk() { $ownedForm->setTitle('Form 1'); $ownedForm->setOwnerId('currentUser'); $ownedForm->setExpires(0); + $ownedForm->setOpens(0); $ownedForm->setLastUpdated(123456789); $sharedForm = new Form(); @@ -537,6 +547,7 @@ public function testGetPartialFormArraysCountsInBulk() { $sharedForm->setTitle('Form 2'); $sharedForm->setOwnerId('otherUser'); $sharedForm->setExpires(0); + $sharedForm->setOpens(0); $sharedForm->setLastUpdated(123456789); $share = new Share(); @@ -568,6 +579,7 @@ public function testGetPartialFormArraysCountsInBulk() { 'hash' => 'abcdefg', 'title' => 'Form 1', 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'permissions' => Constants::PERMISSION_ALL, 'partial' => true, @@ -581,6 +593,7 @@ public function testGetPartialFormArraysCountsInBulk() { 'hash' => 'hijklmn', 'title' => 'Form 2', 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'permissions' => ['submit'], 'partial' => true, @@ -604,6 +617,7 @@ public static function dataGetPublicForm() { 'description' => 'Description Text', 'created' => 123456789, 'expires' => 0, + 'opens' => 0, 'lastUpdated' => 123456789, 'isAnonymous' => false, 'submitMultiple' => true, @@ -644,6 +658,7 @@ public function testGetPublicForm(array $expected) { 'showToAllUsers' => false, ]); $form->setExpires(0); + $form->setOpens(0); $form->setLastUpdated(123456789); $form->setIsAnonymous(false); $form->setSubmitMultiple(true); @@ -1244,6 +1259,52 @@ public function testHasFormExpired(int $expires, int $state, bool $expected) { $this->assertEquals($expected, $this->formsService->hasFormExpired($form)); } + public static function dataHasFormOpened() { + return [ + 'hasOpened' => [time() - 3600, true], + 'hasNotOpened' => [time() + 3600, false], + 'noOpeningDate' => [0, true], + ]; + } + + #[DataProvider('dataHasFormOpened')] + public function testHasFormOpened(int $opens, bool $expected) { + $form = new Form(); + $form->setOpens($opens); + + $this->assertEquals($expected, $this->formsService->hasFormOpened($form)); + } + + public static function dataLoadFormForSubmission() { + return [ + 'hasOpened' => [time() - 3600, true], + 'hasNotOpened' => [time() + 3600, false], + 'noOpeningDate' => [0, true], + ]; + } + + #[DataProvider('dataLoadFormForSubmission')] + public function testLoadFormForSubmissionOpeningDate(int $opens, bool $canSubmit) { + $form = new Form(); + $form->setId(42); + $form->setOwnerId('currentUser'); + $form->setState(Constants::FORM_STATE_ACTIVE); + $form->setExpires(0); + $form->setOpens($opens); + + $this->formMapper->expects($this->once()) + ->method('findById') + ->with(42) + ->willReturn($form); + + if (!$canSubmit) { + $this->expectException(OCSForbiddenException::class); + $this->expectExceptionMessage('This form is not yet taking answers'); + } + + $this->assertSame($form, $this->formsService->loadFormForSubmission(42, '')); + } + public static function dataGetShareDisplayName() { return [ 'userShare' => [ From 02b2e03b0e5699fb46a1479e5028292c22e12622 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 30 Oct 2026 12:58:48 +0100 Subject: [PATCH 2/3] feat(ui): allow setting an opening date Add an opening date picker to the form settings. Forms that are not yet open show when they will open, both in the navigation and in place of the questions. The opening and expiration date pickers prevent selecting an opening date after the expiration date. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ferdinand Thiessen --- src/FormsEmptyContent.vue | 10 ++ src/components/AppNavigationForm.vue | 6 + .../SidebarTabs/SettingsSidebarTab.vue | 125 +++++++++++++++++- src/types/Entities.d.ts | 1 + src/views/Submit.vue | 29 +++- 5 files changed, 165 insertions(+), 6 deletions(-) diff --git a/src/FormsEmptyContent.vue b/src/FormsEmptyContent.vue index d9dd1e32a..17bd0fcc0 100644 --- a/src/FormsEmptyContent.vue +++ b/src/FormsEmptyContent.vue @@ -19,8 +19,10 @@