From e667391e92ad3aed1cc9d0702eb77b0a724bedb3 Mon Sep 17 00:00:00 2001 From: Christian Hartmann Date: Sat, 22 Jun 2024 21:45:17 +0200 Subject: [PATCH] feat: add API v3 Signed-off-by: Christian Hartmann --- appinfo/routes.php | 499 ++--- docs/API.md | 3 + docs/API_v3.md | 900 +++++++++ lib/Controller/ApiController.php | 1619 +++++++++++++++-- lib/Controller/PageController.php | 9 +- lib/Controller/ShareApiController.php | 272 ++- lib/Service/FormsService.php | 34 + playwright/support/sections/FormSection.ts | 2 +- src/Forms.vue | 12 +- src/components/AppNavigationForm.vue | 7 +- src/components/Questions/AnswerInput.vue | 27 +- src/components/Questions/QuestionDropdown.vue | 12 +- src/components/Questions/QuestionFile.vue | 4 +- src/components/Questions/QuestionMultiple.vue | 12 +- .../SidebarTabs/SharingSidebarTab.vue | 21 +- .../SidebarTabs/TransferOwnership.vue | 9 +- src/mixins/QuestionMixin.js | 52 +- src/mixins/ViewsMixin.js | 7 +- src/views/Create.vue | 43 +- src/views/Results.vue | 69 +- src/views/Submit.vue | 5 +- tests/Integration/Api/ApiV2Test.php | 16 +- tests/Integration/Api/ApiV3Test.php | 1401 ++++++++++++++ tests/Unit/Controller/ApiControllerTest.php | 101 +- .../Controller/ShareApiControllerTest.php | 14 +- 25 files changed, 4504 insertions(+), 646 deletions(-) create mode 100644 docs/API_v3.md create mode 100644 tests/Integration/Api/ApiV3Test.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 0c1fea1a9..e255eae83 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,353 +25,216 @@ * */ +$apiBase = '/api/{apiVersion}/'; +$requirements_v3 = [ + 'apiVersion' => 'v3', + 'formId' => '\d+', + 'questionId' => '\d+', + 'optionId' => '\d+', + 'shareId' => '\d+', + 'submissionId' => '\d+' +]; + return [ 'routes' => [ // Internal AppConfig routes - [ - 'name' => 'config#getAppConfig', - 'url' => '/config', - 'verb' => 'GET' - ], - [ - 'name' => 'config#updateAppConfig', - 'url' => '/config/update', - 'verb' => 'PATCH' - ], + ['name' => 'config#getAppConfig', 'url' => '/config', 'verb' => 'GET'], + ['name' => 'config#updateAppConfig', 'url' => '/config/update', 'verb' => 'PATCH'], // Public Share Link - [ - 'name' => 'page#public_link_view', - 'url' => '/s/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#public_link_view', 'url' => '/s/{hash}', 'verb' => 'GET'], // Embedded View - [ - 'name' => 'page#embedded_form_view', - 'url' => '/embed/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#embedded_form_view', 'url' => '/embed/{hash}', 'verb' => 'GET'], // Internal views - [ - 'name' => 'page#views', - 'url' => '/{hash}/{view}', - 'verb' => 'GET' - ], + ['name' => 'page#views', 'url' => '/{hash}/{view}', 'verb' => 'GET'], // Internal Form Link - [ - 'name' => 'page#internal_link_view', - 'url' => '/{hash}', - 'verb' => 'GET' - ], + ['name' => 'page#internal_link_view', 'url' => '/{hash}', 'verb' => 'GET'], // App Root - [ - 'name' => 'page#index', - 'url' => '/', - 'verb' => 'GET' - ], + ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], ], 'ocs' => [ // CORS Preflight - [ - 'name' => 'api#preflightedCors', - 'url' => '/api/{apiVersion}/{path}', - 'verb' => 'OPTIONS', - 'requirements' => [ - 'path' => '.+', - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#preflightedCors', 'url' => $apiBase . '{path}', 'verb' => 'OPTIONS', 'requirements' => [ + 'path' => '.+', + 'apiVersion' => 'v2(\.[1-4])?|v3' + ]], + // API routes v3 // Forms - [ - 'name' => 'api#getForms', - 'url' => '/api/{apiVersion}/forms', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#newForm', - 'url' => '/api/{apiVersion}/form', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getForm', - 'url' => '/api/{apiVersion}/form/{id}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#cloneForm', - 'url' => '/api/{apiVersion}/form/clone/{id}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - // TODO: Remove POST in next API release - [ - 'name' => 'api#updateForm', - 'url' => '/api/{apiVersion}/form/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateForm', - 'url' => '/api/{apiVersion}/form/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#transferOwner', - 'url' => '/api/{apiVersion}/form/transfer', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteForm', - 'url' => '/api/{apiVersion}/form/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getPartialForm', - 'url' => '/api/{apiVersion}/partial_form/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#getSharedForms', - 'url' => '/api/{apiVersion}/shared_forms', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#getForms', 'url' => $apiBase . 'forms', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newForm', 'url' => $apiBase . 'forms', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#getForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteForm', 'url' => $apiBase . 'forms/{formId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + + // Questions + ['name' => 'api#getQuestions', 'url' => $apiBase . 'forms/{formId}/questions', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newQuestion', 'url' => $apiBase . 'forms/{formId}/questions', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#getQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteQuestion', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + ['name' => 'api#reorderQuestions', 'url' => $apiBase . 'forms/{formId}/questions', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + + // Options + // ['name' => 'api#getOptions', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options', 'verb' => 'POST', 'requirements' => $requirements_v3], + // ['name' => 'api#getOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#updateOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteOption', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options/{optionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + // ['name' => 'api#reorderOptions', 'url' => $apiBase . 'forms/{formId}/questions/{questionId}/options', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + + // Shares + // ['name' => 'shareApi#getUserShares', 'url' => $apiBase . 'shares', 'verb' => 'GET', 'requirements' => $requirements_v3], + // ['name' => 'shareApi#getShares', 'url' => $apiBase . 'forms/{formId}/shares', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'shareApi#newShare', 'url' => $apiBase . 'forms/{formId}/shares', 'verb' => 'POST', 'requirements' => $requirements_v3], + // ['name' => 'shareApi#getShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'shareApi#updateShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'shareApi#deleteShare', 'url' => $apiBase . 'forms/{formId}/shares/{shareId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + + // Submissions + ['name' => 'api#getSubmissions', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'GET', 'requirements' => $requirements_v3], + ['name' => 'api#newSubmission', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#deleteAllSubmissions', 'url' => $apiBase . 'forms/{formId}/submissions', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + //['name' => 'api#getSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'GET', 'requirements' => $requirements_v3], + //['name' => 'api#updateSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'PATCH', 'requirements' => $requirements_v3], + ['name' => 'api#deleteSubmission', 'url' => $apiBase . 'forms/{formId}/submissions/{submissionId}', 'verb' => 'DELETE', 'requirements' => $requirements_v3], + ['name' => 'api#exportSubmissionsToCloud', 'url' => $apiBase . 'forms/{formId}/submissions/export', 'verb' => 'POST', 'requirements' => $requirements_v3], + ['name' => 'api#uploadFiles', 'url' => $apiBase . 'forms/{formId}/submissions/files/{questionId}', 'verb' => 'POST', 'requirements' => $requirements_v3], + + // Legacy v2 routes (TODO: remove with Forms v5) + // Forms + ['name' => 'api#getFormsLegacy', 'url' => $apiBase . 'forms', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#newFormLegacy', 'url' => $apiBase . 'form', 'verb' => 'POST', 'requirements' => [ + 'apiVersion_path' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#getFormLegacy', 'url' => $apiBase . 'form/{id}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion_path' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#cloneFormLegacy', 'url' => $apiBase . 'form/clone/{id}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#updateFormLegacy', 'url' => $apiBase . 'form/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateFormLegacy', 'url' => $apiBase . 'form/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#transferOwnerLegacy', 'url' => $apiBase . 'form/transfer', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteFormLegacy', 'url' => $apiBase . 'form/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#getPartialFormLegacy', 'url' => $apiBase . 'partial_form/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#getSharedFormsLegacy', 'url' => $apiBase . 'shared_forms', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // Questions - [ - 'name' => 'api#newQuestion', - 'url' => '/api/{apiVersion}/question', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#newQuestionLegacy', 'url' => $apiBase . 'question', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#updateQuestion', - 'url' => '/api/{apiVersion}/question/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateQuestion', - 'url' => '/api/{apiVersion}/question/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], + ['name' => 'api#updateQuestionLegacy', 'url' => $apiBase . 'question/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateQuestionLegacy', 'url' => $apiBase . 'question/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#reorderQuestions', - 'url' => '/api/{apiVersion}/question/reorder', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#reorderQuestions', - 'url' => '/api/{apiVersion}/question/reorder', - 'verb' => 'PUT', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteQuestion', - 'url' => '/api/{apiVersion}/question/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#cloneQuestion', - 'url' => '/api/{apiVersion}/question/clone/{id}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[3-4]' - ] - ], + ['name' => 'api#reorderQuestionsLegacy', 'url' => $apiBase . 'question/reorder', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#reorderQuestionsLegacy', 'url' => $apiBase . 'question/reorder', 'verb' => 'PUT', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteQuestionLegacy', 'url' => $apiBase . 'question/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], + ['name' => 'api#cloneQuestionLegacy', 'url' => $apiBase . 'question/clone/{id}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[3-4]', + 'id' => '\d+' + ]], // Options - [ - 'name' => 'api#newOption', - 'url' => '/api/{apiVersion}/option', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#newOptionLegacy', 'url' => $apiBase . 'option', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'api#updateOption', - 'url' => '/api/{apiVersion}/option/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#updateOption', - 'url' => '/api/{apiVersion}/option/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], - [ - 'name' => 'api#deleteOption', - 'url' => '/api/{apiVersion}/option/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#updateOptionLegacy', 'url' => $apiBase . 'option/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#updateOptionLegacy', 'url' => $apiBase . 'option/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], + ['name' => 'api#deleteOptionLegacy', 'url' => $apiBase . 'option/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // Shares - [ - 'name' => 'shareApi#newShare', - 'url' => '/api/{apiVersion}/share', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'shareApi#deleteShare', - 'url' => '/api/{apiVersion}/share/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'shareApi#newShareLegacy', 'url' => $apiBase . 'share', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'shareApi#deleteShareLegacy', 'url' => $apiBase . 'share/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // TODO: Remove POST in next API release - [ - 'name' => 'shareApi#updateShare', - 'url' => '/api/{apiVersion}/share/update', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2\.[1-4]' - ] - ], - [ - 'name' => 'shareApi#updateShare', - 'url' => '/api/{apiVersion}/share/update', - 'verb' => 'PATCH', - 'requirements' => [ - 'apiVersion' => 'v2\.[2-4]' - ] - ], + ['name' => 'shareApi#updateShareLegacy', 'url' => $apiBase . 'share/update', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2\.[1-4]' + ]], + ['name' => 'shareApi#updateShareLegacy', 'url' => $apiBase . 'share/update', 'verb' => 'PATCH', 'requirements' => [ + 'apiVersion' => 'v2\.[2-4]' + ]], // Submissions - [ - 'name' => 'api#getSubmissions', - 'url' => '/api/{apiVersion}/submissions/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#exportSubmissions', - 'url' => '/api/{apiVersion}/submissions/export/{hash}', - 'verb' => 'GET', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#exportSubmissionsToCloud', - 'url' => '/api/{apiVersion}/submissions/export', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#deleteAllSubmissions', - 'url' => '/api/{apiVersion}/submissions/{formId}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#uploadFiles', - 'url' => '/api/{apiVersion}/uploadFiles/{formId}/{questionId}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.5' - ] - ], - [ - 'name' => 'api#insertSubmission', - 'url' => '/api/{apiVersion}/submission/insert', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], - [ - 'name' => 'api#deleteSubmission', - 'url' => '/api/{apiVersion}/submission/{id}', - 'verb' => 'DELETE', - 'requirements' => [ - 'apiVersion' => 'v2(\.[1-4])?' - ] - ], + ['name' => 'api#getSubmissionsLegacy', 'url' => $apiBase . 'submissions/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#exportSubmissionsLegacy', 'url' => $apiBase . 'submissions/export/{hash}', 'verb' => 'GET', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'hash' => '[a-zA-Z0-9]{16}' + ]], + ['name' => 'api#exportSubmissionsToCloudLegacy', 'url' => $apiBase . 'submissions/export', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#deleteAllSubmissionsLegacy', 'url' => $apiBase . 'submissions/{formId}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'formId' => '\d+' + ]], + ['name' => 'api#uploadFilesLegacy', 'url' => $apiBase . 'uploadFiles/{formId}/{questionId}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.5', + 'formId' => '\d+', + 'questionId' => '\d+' + ]], + ['name' => 'api#insertSubmissionLegacy', 'url' => $apiBase . 'submission/insert', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?' + ]], + ['name' => 'api#deleteSubmissionLegacy', 'url' => $apiBase . 'submission/{id}', 'verb' => 'DELETE', 'requirements' => [ + 'apiVersion' => 'v2(\.[1-4])?', + 'id' => '\d+' + ]], // Submissions linking with file in cloud - [ - 'name' => 'api#linkFile', - 'url' => '/api/{apiVersion}/form/link/{fileFormat}', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.4', - 'fileFormat' => 'csv|ods|xlsx' - ] - ], - [ - 'name' => 'api#unlinkFile', - 'url' => '/api/{apiVersion}/form/unlink', - 'verb' => 'POST', - 'requirements' => [ - 'apiVersion' => 'v2.4', - ] - ] + ['name' => 'api#linkFileLegacy', 'url' => $apiBase . 'form/link/{fileFormat}', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.4', + 'fileFormat' => 'csv|ods|xlsx' + ]], + ['name' => 'api#unlinkFileLegacy', 'url' => $apiBase . 'form/unlink', 'verb' => 'POST', 'requirements' => [ + 'apiVersion' => 'v2.4', + ]] ] ]; diff --git a/docs/API.md b/docs/API.md index dccddaa16..97d22e380 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,5 +1,7 @@ # Forms Public API +## API v2 is now deprecated and will be removed with Forms v5. Please see documentation for [API v3](API_v3.md) + This file contains the API-Documentation. For more information on the returned Data-Structures, please refer to the [corresponding Documentation](DataStructure.md). ## Generals @@ -27,6 +29,7 @@ This file contains the API-Documentation. For more information on the returned D ### Deprecation info +- Starting with Forms v4.3 API v2 will be deprecated and removed with Forms v5: Please see the documentation for [API v3](API_v3.md) - Starting with API v2.2 all endpoints that update data will use PATCH/PUT as method. POST is now deprecated and will be removed in API v3 ### Breaking Changes on API v2 diff --git a/docs/API_v3.md b/docs/API_v3.md new file mode 100644 index 000000000..831ef6630 --- /dev/null +++ b/docs/API_v3.md @@ -0,0 +1,900 @@ +# Forms Public API + +This file contains the API-Documentation. For more information on the returned Data-Structures, please refer to the [corresponding Documentation](DataStructure.md). + +## Generals + +- Base URL for all calls to the forms API is `/ocs/v2.php/apps/forms` +- All Requests need to provide some authentication information. +- All Requests to OCS-Endpoints require the Header `OCS-APIRequest: true` +- Unless otherwise specified, all parameters are mandatory. + +- By default, the API returns data formatted as _xml_. If formatting as _json_ is desired, the request should contain the header `Accept: application/json`. For simple representation, the output presented in this document is all formatted as _json_. +- The OCS-Endpoint _always returns_ an object called `ocs`. This contains an object `meta` holding some meta-data, as well as an object `data` holding the actual data. In this document, the response-blocks only show the `data`, if not explicitely stated different. + +``` +"ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": +} +``` + +## API changes + +### Deprecation info + +- Starting with Forms v4.3 API v2 will be deprecated and removed with Forms v5 +- Starting with API v2.2 all endpoints that update data will use PATCH/PUT as method. POST is now deprecated and will be removed in API v3 + +### Breaking changes on API v3 + +- Most routes changed from API v2 to v3. Please adjust your applications accordingly. +- Removed possibility to get a single partial form + +### Breaking changes on API v2 + +- The `mandatory` property of questions has been removed. It is replaced by `isRequired`. +- Completely new way of handling access & shares. + +### Other API changes + +- In API version 3.0 the following endpoints were introduced/changed: + - `GET /api/v3/forms/{formId}/questions` to get all questions of a form + - `GET /api/v3/forms/{formId}/questions/{questionId}` to get a single question + - `POST /api/v3/forms/{formId}/questions/{questionId}/options` does now accept more options at once + - `POST /api/v3/forms/{formId}/submissions/files/{questionId}` to upload a file to a file question before submitting the form +- In API version 2.5 the following endpoints were introduced: + - `POST /api/v2.5/uploadFiles/{formId}/{questionId}` to upload files to answer before form submitting +- In API version 2.4 the following endpoints were introduced: + - `POST /api/v2.4/form/link/{fileFormat}` to link form to a file + - `POST /api/v2.4/form/unlink` to unlink form from a file +- In API version 2.4 the following endpoints were changed: + - `GET /api/v2.4/submissions/export/{hash}` was extended with optional parameter `fileFormat` to export submissions in different formats + - `GET /api/v2.4/submissions/export` was extended with optional parameter `fileFormat` to export submissions to cloud in different formats + - `GET /api/v2.4/form/{id}` was extended with optional parameters `fileFormat`, `fileId`, `filePath` to link form to a file +- In API version 2.3 the endpoint `/api/v2.3/question/clone` was added to clone a question +- In API version 2.2 the endpoint `/api/v2.2/form/transfer` was added to transfer ownership of a form +- In API version 2.1 the endpoint `/api/v2.1/share/update` was added to update a Share + +## Form Endpoints + +### List owned Forms + +Returns condensed objects of all Forms beeing owned by the authenticated user. + +- Endpoint: `/api/v3/forms[?type=owned]` +- Method: `GET` +- Parameters: None +- Response: Array of condensed Form Objects, sorted as newest first. + +``` +"data": [ + { + "id": 6, + "hash": "yWeMwcwCwoqRs8T2", + "title": "Form 2", + "expires": 0, + "permissions": [ + "edit", + "results", + "submit" + ], + "partial": true, + "state": 0 + }, + { + "id": 3, + "hash": "em4djk8B9BpXnkYG", + "title": "Form 1", + "expires": 0, + "permissions": [ + "edit", + "results", + "submit" + ], + "partial": true, + "state": 0 + } +] +``` + +### List shared Forms + +Returns condensed objects of all Forms, that are shared & shown to the authenticated user and that have not expired yet. + +- Endpoint: `/api/v3/forms?type=shared` +- Method: `GET` +- Parameters: None +- Response: Array of condensed Form Objects, sorted as newest first, similar to [List owned Forms](#list-owned-forms). + +``` +See above, 'List owned forms' +``` + +### Create a new Form + +- Endpoint: `/api/v3/forms` +- Method: `POST` +- Parameters: None +- Response: The new form object, similar to requesting an existing form. + +``` +See next section, 'Request full data of a form' +``` + +### Request full data of a form + +Returns the full-depth object of the requested form (without submissions). + +- Endpoint: `/api/v3/forms/{formId}` +- Method: `GET` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to request | +- Response: A full object of the form, including access, questions and options in full depth. + +``` +"data": { + "id": 3, + "hash": "em4djk8B9BpXnkYG", + "title": "Form 1", + "description": "Description Text", + "ownerId": "jonas", + "submissionMessage": "Thank **you** for submitting the form." + "created": 1611240961, + "access": { + "permitAllUsers": false, + "showToAllUsers": false + }, + "expires": 0, + "fileFormat": "csv", + "fileId": 157, + "filePath": "foo/bar", + "isAnonymous": false, + "submitMultiple": true, + "showExpiration": false, + "canSubmit": true, + "state": 0, + "permissions": [ + "edit", + "results", + "submit" + ], + "questions": [ + { + "id": 1, + "formId": 3, + "order": 1, + "type": "dropdown", + "isRequired": false, + "text": "Question 1", + "name": "something", + "options": [ + { + "id": 1, + "questionId": 1, + "text": "Option 1" + }, + { + "id": 2, + "questionId": 1, + "text": "Option 2" + } + ], + "accept": [], + "extraSettings": {} + }, + { + "id": 2, + "formId": 3, + "order": 2, + "type": "file", + "isRequired": true, + "text": "Question 2", + "name": "something_other", + "options": [], + "extraSettings": {} + "accept": ["image/*", ".pdf"], + } + ], + "shares": [ + { + "id": 1, + "formId": 3, + "shareType": 0, + "shareWith": "user1", + "displayName": "User 1 Displayname" + }, + { + "id": 2, + "formId": 3, + "shareType": 3, + "shareWith": "dYcTWjrSsxjMFFQzFAywzq5J", + "displayName": "" + } + ] +} +``` + +### Clone a form + +Creates a clone of a form (without submissions). + +- Endpoint: `/api/v3/forms?fromId={formId}` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to clone | +- Response: Returns the full object of the new form. See [Request full data of a Form](#request-full-data-of-a-form) + +``` +See section 'Request full data of a form'. +``` + +### Update form properties + +Update a single or multiple properties of a form-object. Concerns **only** the Form-Object, properties of Questions, Options and Submissions, as well as their creation or deletion, are handled separately. + +- Endpoint: `/api/v3/forms/{formId}` +- Method: `PATCH` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to update | +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _keyValuePairs_ | Array | Array of key-value pairs to update | +- Restrictions: + - It is **not allowed** to update one of the following key-value pairs: _id, hash, ownerId, created_ + - 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` +- Response: **Status-Code OK**, as well as the id of the updated form. + +``` +"data": 3 +``` + +### Delete a form + +- Endpoint: `/api/v3/forms/{formId}` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to delete | +- Response: **Status-Code OK**, as well as the id of the deleted form. + +``` +"data": 3 +``` + +## Question Endpoints + +### Get all questions of a form + +Returns the questions and options of the given form (without submissions). + +- Endpoint: `/api/v3/forms/{formId}/questions` +- Method: `GET` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form | +- Response: An array of all questions of the form including options. + +``` +"data": [ + { + "id": 1, + "formId": 3, + "order": 1, + "type": "dropdown", + "isRequired": false, + "text": "Question 1", + "name": "something", + "options": [ + { + "id": 1, + "questionId": 1, + "text": "Option 1" + }, + { + "id": 2, + "questionId": 1, + "text": "Option 2" + } + ], + "accept": [], + "extraSettings": {} + }, + { + "id": 2, + "formId": 3, + "order": 2, + "type": "file", + "isRequired": true, + "text": "Question 2", + "name": "something_other", + "options": [], + "extraSettings": {} + "accept": ["image/*", ".pdf"], + } +] +``` + +### Create a new question + +- Endpoint: `/api/v3/forms/{formId}/questions` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form | +- Parameters: + | Parameter | Type | Optional | Description | + |-----------|---------|----------|-------------| + | _type_ | [QuestionType](DataStructure.md#question-types) | | The question-type of the new question | + | _text_ | String | yes | _Optional_ The text of the new question. | +- Response: The new question object. + +``` +"data": { + "id": 3, + "formId": 3, + "order": 3, + "type": "short", + "isRequired": false, + "name": "", + "text": "", + "options": [] + "extraSettings": {} +} +``` + +### Get all questions of a form + +Returns the requested question and options of the given form (without submissions). + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}` +- Method: `GET` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form | + | _questionId_ | Integer | ID of the question | +- Response: An object of the requested question including options. + +``` +"data": { + "id": 1, + "formId": 3, + "order": 1, + "type": "dropdown", + "isRequired": false, + "text": "Question 1", + "name": "something", + "options": [ + { + "id": 1, + "questionId": 1, + "text": "Option 1" + }, + { + "id": 2, + "questionId": 1, + "text": "Option 2" + } + ], + "accept": [], + "extraSettings": {} +} +``` + +### Update question properties + +Update a single or multiple properties of a question-object. + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}` +- Method: `PATCH` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to request | + | _questionId_ | Integer | Id of the +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _keyValuePairs_ | Array | Array of key-value pairs to update | +- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order_. +- Response: **Status-Code OK**, as well as the id of the updated question. + +``` +"data": 1 +``` + +### Reorder questions + +Reorders all Questions of a single form + +- Endpoint: `/api/v3/forms/{formId}/questions` +- Method: `PATCH` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form, the questions belong to | +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _newOrder_ | Array | Array of **all** Question-IDs, ordered in the desired order | +- Restrictions: The Array **must** contain all Question-IDs corresponding to the specified form and **must not** contain any duplicates. +- Response: Array of questionIDs and their corresponding order. + +``` +"data": { + "1": { + "order": 1 + }, + "2": { + "order": 3 + }, + "3": { + "order": 2 + } +} +``` + +### Delete a question + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the question | + | _questionId_ | Integer | ID of the question to delete | +- Response: **Status-Code OK**, as well as the id of the deleted question. + +``` +"data": 4 +``` + +### Clone a question + +Creates a clone of a question with all its options. + +- Endpoint: `/api/v3/forms/{formId}/questions?fromId={questionId}` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the question | + | _questionId_ | Integer | ID of the question to clone | +- Response: Returns cloned question object with the new ID set. + +``` +See section 'Create a new question'. +``` + +## Option Endpoints + +Contains only manipulative question-endpoints. To retrieve options, request the full form data. + +### Create a new Option + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}/options` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the question | + | _questionId_ | Integer | ID of the question, the new option will belong to | +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _text_ | Array | Array of strings containing the new options | +- Response: The new array of option objects + +``` +"data": { + "id": 7, + "questionId": 1, + "text": "test" +} +``` + +### Update option properties + +Update a single or all properties of an option-object + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}/options/{optionId}` +- Method: `PATCH` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the question and option | + | _questionId_ | Integer | ID of the question, the new option will belong to | + | _optionId_ | Integer | ID of the option to update | +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _keyValuePairs_ | Array | Array of key-value pairs to update | +- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, questionId_. +- Response: **Status-Code OK**, as well as the id of the updated option. + +``` +"data": 7 +``` + +### Delete an option + +- Endpoint: `/api/v3/forms/{formId}/questions/{questionId}/options/{optionId}` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the question and option | + | _questionId_ | Integer | ID of the question, the new option will belong to | + | _optionId_ | Integer | ID of the option to delete | +- Response: **Status-Code OK**, as well as the id of the deleted option. + +``` +"data": 7 +``` + +## Sharing Endpoints + +### Add a new Share + +- Endpoint: `/api/v3/forms/{formId}/shares` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|----------|-------------| + | _formId_ | Integer | ID of the form to share | +- Parameters: + | Parameter | Type | Description | + |--------------|----------|-------------| + | _shareType_ | String | NC-shareType, out of the used shareTypes. | + | _shareWith_ | String | User/Group for the share. Not used for link-shares. | + | _permissions_ | String[] | Permissions of the sharees, see [DataStructure](DataStructure.md#Permissions). | +- Response: **Status-Code OK**, as well as the new share object. + +``` +"data": { + "id": 3, + "formId": 3, + "shareType": 0, + "shareWith": "user3", + "permissions": ["submit"], + "displayName": "User 3 Displayname" +} +``` + +### Update a Share + +- Endpoint: `/api/v3/forms/{formId}/shares/{shareId}` +- Method: `PATCH` +- Url-Parameters: + | Parameter | Type | Description | + |------------------|----------|-------------| + | _formId_ | Integer | ID of the form containing the share | + | _shareId_ | Integer | ID of the share to update | +- Parameters: + | Parameter | Type | Description | + |------------------|----------|-------------| + | _keyValuePairs_ | Array | Array of key-value pairs to update | +- Restrictions: Currently only the _permissions_ can be updated. +- Response: **Status-Code OK**, as well as the id of the share object. + +``` +"data": 5 +``` + +### Delete a Share + +- Endpoint: `/api/v3/forms/{formId}/shares/{shareId}` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the share | + | _shareId_ | Integer | ID of the share to delete | +- Response: **Status-Code OK**, as well as the id of the deleted share. + +``` +"data": 5 +``` + +## Submission Endpoints + +### Get Form Submissions + +Get all Submissions to a Form + +- Endpoint: `/api/v3/forms/{formId}/submissions` +- Method: `GET` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to get the submissions for | +- Response: An Array of all submissions, sorted as newest first, as well as an array of the corresponding questions. + +``` +"data": { + "submissions": [ + { + "id": 6, + "formId": 3, + "userId": "jonas", + "timestamp": 1611274453, + "answers": [ + { + "id": 8, + "submissionId": 6, + "questionId": 1, + "text": "Option 3" + }, + { + "id": 9, + "submissionId": 6, + "questionId": 2, + "text": "One more." + }, + ], + "userDisplayName": "jonas" + }, + { + "id": 5, + "formId": 3, + "userId": "jonas", + "timestamp": 1611274433, + "answers": [ + { + "id": 5, + "submissionId": 5, + "questionId": 1, + "text": "Option 2" + }, + { + "id": 6, + "submissionId": 5, + "questionId": 2, + "text": "This is an answer." + }, + ], + "userDisplayName": "jonas" + } + ], + "questions": [ + { + "id": 1, + "formId": 3, + "order": 1, + "type": "dropdown", + "isRequired": false, + "text": "Question 1", + "options": [ + { + "id": 1, + "questionId": 1, + "text": "Option 1" + }, + { + "id": 27, + "questionId": 1, + "text": "Option 2" + }, + { + "id": 30, + "questionId": 1, + "text": "Option 3" + } + ], + "extraSettings": {} + }, + { + "id": 2, + "formId": 3, + "order": 2, + "type": "short", + "isRequired": true, + "text": "Question 2", + "options": [], + "extraSettings": {} + } + ] +} +``` + +### Get Submissions as csv (Download) + +Returns all submissions to the form in form of a csv-file. + +- Endpoint: `/api/v3/forms/{formId}/submissions?fileFormat={fileFormat}` +- Method: `GET` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|---------|-------------| + | _formId_ | Integer | Id of the form to get the submissions for | + | _fileFormat_ | String | `csv|ods|xlsx` | +- Response: A Data Download Response containing the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv. For file format `ods` or `xlsx` the Download Response contains an Open Document Spreadsheet or an Office Open XML Spreadsheet file. + +``` +"User display name","Timestamp","Question 1","Question 2" +"jonas","Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2","Answer" +"jonas","Friday, January 22, 2021 at 12:45:57 AM GMT+0:00","Option 3","NextAnswer" +``` + +### Export Submissions to Cloud (Files-App) + +Creates a csv file and stores it to the cloud, resp. Files-App. + +- Endpoint: `/api/v3/forms/{formId}/submissions/export` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|---------|-------------| + | _formId_ | Integer | ID of the form to get the submissions for | +- Parameters: + | Parameter | Type | Description | + |--------------|---------|-------------| + | _path_ | String | Path within User-Dir, to store the file to | + | _fileFormat_ | String | csv|ods|xlsx | +- Response: Stores the file to the given path and returns the fileName. + +``` +"data": "Form 2 (responses).csv" +``` + +### Delete Submissions + +Delete all Submissions to a form + +- Endpoint: `/api/v3/forms/{formId}/submissions` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form to delete the submissions for | +- Response: **Status-Code OK**, as well as the id of the corresponding form. + +``` +"data": 3 +``` + +### Upload a file + +Upload a file to an answer before form submission + +- Endpoint: `/api/v3/forms/{formId}/submissions/files/{questionId}` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _formId_ | Integer | ID of the form to upload the file to | + | _questionId_ | Integer | ID of the question to upload the file to | +- Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _files_ | Array of files | Files to upload | +- Response: **Status-Code OK**, as well as the id of the uploaded file and it's name. + +``` +"data": {"uploadedFileId": integer, "fileName": "string"} +``` + +### Insert a Submission + +Store Submission to Database + +- Endpoint: `/api/v3/forms/{formId}/submissions` +- Method: `POST` +- Url-Parameters: + | Parameter | Type | Description | + |--------------|----------------|-------------| + | _formId_ | Integer | ID of the form to upload the file to | +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _answers_ | Array | Array of answers | + | _shareHash_ | String | optional, only neccessary for submissions to a public share link | +- Restrictions: The array of answers has the following structure: + - QuestionID as key + - An **array** of values as value --> Even for short Text Answers, wrapped into Array. + - For Question-Types with pre-defined answers (`multiple`, `multiple_unique`, `dropdown`), the array contains the corresponding option-IDs. + - For File-Uploads, the array contains the objects with key `uploadedFileId` (value from Upload a file endpoint). + +``` + { + "1":[27,32], // dropdown or multiple + "2":["ShortTextAnswer"], // All Text-Based Question-Types + "3":[ // File-Upload + {"uploadedFileId": integer}, + {"uploadedFileId": integer} + ], +} +``` + +- Response: **Status-Code OK**. + +### Delete a single Submission + +- Endpoint: `/api/v3/forms/{formId}/submissions/{submissionId}` +- Method: `DELETE` +- Url-Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _formId_ | Integer | ID of the form containing the submission | + | _submissionId_ | Integer | ID of the submission to delete | +- Response: **Status-Code OK**, as well as the id of the deleted submission. + +```` +"data": 5 +``` + +## Error Responses + +All Endpoints return one of the following Error-Responses, if the request is not properly raised. This also results in a different `ocs:meta` object. + +### 400 - Bad Request + +This returns in case the Request is not properly set. This can e.g. include: + +- The corresponding form can not be found +- Request Parameters are wrong (including formatting or type of parameters) + +``` + +"ocs": { +"meta": { +"status": "failure", +"statuscode": 400, +"message": "" +}, +"data": [] +} + +``` + +### 403 - Forbidden + +This returns in case the authenticated user is not allowed to access this resource/endpoint. This can e.g. include: + +- The user has no write access to the form (only form owner is allowed to edit) +- The user is not allowed to submit to the form (access-settings, form expired, already submitted) + +``` + +"ocs": { +"meta": { +"status": "failure", +"statuscode": 403, +"message": "" +}, +"data": [] +} + +``` + +### 412 - Precondition Failed + +This Error is not produed by the Forms-API, but comes from Nextclouds OCS API. Typically this is the result when missing the Request-Header `OCS-APIRequest: true`. + +``` + +{ +"message": "CSRF check failed" +} + +``` + +``` +```` diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index d821008ae..a6fb8a8f8 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1,8 +1,10 @@ * * @author affan98 + * @author Christian Hartmann * @author Ferdinand Thiessen * @author Jan-Christoph Borchardt * @author John Molakvoæ (skjnldsv) @@ -26,73 +28,1326 @@ * */ -namespace OCA\Forms\Controller; +namespace OCA\Forms\Controller; + +use OCA\Forms\Constants; +use OCA\Forms\Db\Answer; +use OCA\Forms\Db\AnswerMapper; +use OCA\Forms\Db\Form; +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Db\Option; +use OCA\Forms\Db\OptionMapper; +use OCA\Forms\Db\Question; +use OCA\Forms\Db\QuestionMapper; +use OCA\Forms\Db\ShareMapper; +use OCA\Forms\Db\Submission; +use OCA\Forms\Db\SubmissionMapper; +use OCA\Forms\Db\UploadedFile; +use OCA\Forms\Db\UploadedFileMapper; +use OCA\Forms\Service\ConfigService; +use OCA\Forms\Service\FormsService; +use OCA\Forms\Service\SubmissionService; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\IMapperException; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; + +use Psr\Log\LoggerInterface; + +class ApiController extends OCSController +{ + private ?IUser $currentUser; + + public function __construct( + string $appName, + IRequest $request, + IUserSession $userSession, + private AnswerMapper $answerMapper, + private FormMapper $formMapper, + private OptionMapper $optionMapper, + private QuestionMapper $questionMapper, + private ShareMapper $shareMapper, + private SubmissionMapper $submissionMapper, + private ConfigService $configService, + private FormsService $formsService, + private SubmissionService $submissionService, + private IL10N $l10n, + private LoggerInterface $logger, + private IUserManager $userManager, + private IRootFolder $storage, + private UploadedFileMapper $uploadedFileMapper, + private IMimeTypeDetector $mimeTypeDetector, + ) { + parent::__construct($appName, $request); + $this->currentUser = $userSession->getUser(); + } + + // API v3 methods + // Forms + /** + * @CORS + * @NoAdminRequired + * + * Read Form-List of owned forms + * Return only with necessary information for Listing. + * @return DataResponse + */ + public function getForms(string $type = 'owned'): DataResponse + { + if ($type === 'owned') { + $forms = $this->formMapper->findAllByOwnerId($this->currentUser->getUID()); + $result = []; + foreach ($forms as $form) { + $result[] = $this->formsService->getPartialFormArray($form); + } + return new DataResponse($result); + } elseif ($type === 'shared') { + $forms = $this->formsService->getSharedForms($this->currentUser); + $result = array_values(array_map(fn(Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); + return new DataResponse($result); + } else { + throw new OCSBadRequestException(); + } + } + + /** + * @CORS + * @NoAdminRequired + * + * Create a new Form and return the Form to edit. + * Return a cloned Form if the parameter $fromId is set + * + * @param int $fromId (optional) ID of the Form that should be cloned + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newForm(?int $fromId = null): DataResponse + { + // Check if user is allowed + if (!$this->configService->canCreateForms()) { + $this->logger->debug('This user is not allowed to create Forms.'); + throw new OCSForbiddenException(); + } + + if ($fromId === null) { + // Create Form + $form = new Form(); + $form->setOwnerId($this->currentUser->getUID()); + $form->setCreated(time()); + $form->setHash($this->formsService->generateFormHash()); + $form->setTitle(''); + $form->setDescription(''); + $form->setAccess([ + 'permitAllUsers' => false, + 'showToAllUsers' => false, + ]); + $form->setSubmitMultiple(false); + $form->setShowExpiration(false); + $form->setExpires(0); + $form->setIsAnonymous(false); + $form->setLastUpdated(time()); + + $this->formMapper->insert($form); + } else { + $oldForm = $this->getFormIfAllowed($fromId); + + // Read Form, set new Form specific data, extend Title. + $formData = $oldForm->read(); + unset($formData['id']); + $formData['created'] = time(); + $formData['lastUpdated'] = time(); + $formData['hash'] = $this->formsService->generateFormHash(); + // TRANSLATORS Appendix to the form Title of a duplicated/copied form. + $formData['title'] .= ' - ' . $this->l10n->t('Copy'); + + $form = Form::fromParams($formData); + $this->formMapper->insert($form); + + // Get Questions, set new formId, reinsert + $questions = $this->questionMapper->findByForm($oldForm->getId()); + foreach ($questions as $oldQuestion) { + $questionData = $oldQuestion->read(); + + unset($questionData['id']); + $questionData['formId'] = $form->getId(); + $newQuestion = Question::fromParams($questionData); + $this->questionMapper->insert($newQuestion); + + // Get Options, set new QuestionId, reinsert + $options = $this->optionMapper->findByQuestion($oldQuestion->getId()); + foreach ($options as $oldOption) { + $optionData = $oldOption->read(); + + unset($optionData['id']); + $optionData['questionId'] = $newQuestion->getId(); + $newOption = Option::fromParams($optionData); + $this->optionMapper->insert($newOption); + } + } + } + return $this->getForm($form->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Read all information to edit a Form (form, questions, options, except submissions/answers). + * + * @param int $formId Id of the form + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getForm(int $formId): DataResponse + { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $formData = $this->formsService->getForm($form); + + return new DataResponse($formData); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * + * @param int $formId FormId of form to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateForm(int $formId, array $keyValuePairs): DataResponse + { + $this->logger->debug('Updating form: formId: {formId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'keyValuePairs' => $keyValuePairs + ]); + + $form = $this->getFormIfAllowed($formId); + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + // Process owner transfer + if (sizeof($keyValuePairs) === 1 && key_exists('ownerId', $keyValuePairs)) { + $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ + 'formId' => $formId, + 'uid' => $keyValuePairs['ownerId'] + ]); + + $form = $this->getFormIfAllowed($formId); + $user = $this->userManager->get($keyValuePairs['ownerId']); + if ($user == null) { + $this->logger->debug('Could not find new form owner'); + throw new OCSBadRequestException('Could not find new form owner'); + } + + // update form owner + $form->setOwnerId($keyValuePairs['ownerId']); + + // Update changed Columns in Db. + $this->formMapper->update($form); + + return new DataResponse($form->getOwnerId()); + } + + // Don't allow to change params id, hash, ownerId, created, lastUpdated, fileId + if ( + key_exists('id', $keyValuePairs) || key_exists('hash', $keyValuePairs) || + key_exists('ownerId', $keyValuePairs) || key_exists('created', $keyValuePairs) || + isset($keyValuePairs['fileId']) || key_exists('lastUpdated', $keyValuePairs) + ) { + $this->logger->info('Not allowed to update id, hash, ownerId, created, fileId or lastUpdated'); + throw new OCSForbiddenException(); + } + + // Process file linking + if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) { + $file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']); + + $form->setFileId($file->getId()); + $form->setFileFormat($keyValuePairs['fileFormat']); + } + + // Process file unlinking + if (key_exists('fileId', $keyValuePairs) && key_exists('fileFormat', $keyValuePairs) && !isset($keyValuePairs['fileId']) && !isset($keyValuePairs['fileFormat'])) { + $form->setFileId(null); + $form->setFileFormat(null); + } + + unset($keyValuePairs['path']); + unset($keyValuePairs['fileId']); + unset($keyValuePairs['fileFormat']); + + // Create FormEntity with given Params & Id. + foreach ($keyValuePairs as $key => $value) { + $method = 'set' . ucfirst($key); + $form->$method($value); + } + + // Update changed Columns in Db. + $this->formMapper->update($form); + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($form->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a form + * + * @param int $formId the form id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteForm(int $formId): DataResponse + { + $this->logger->debug('Delete Form: {formId}', [ + 'formId' => $formId, + ]); + + $form = $this->getFormIfAllowed($formId); + $this->formMapper->deleteForm($form); + + return new DataResponse($formId); + } + + // Questions + /** + * @CORS + * @NoAdminRequired + * + * Read all questions (including options) + * + * @param int $formId FormId + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getQuestions(int $formId): DataResponse + { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $questionData = $this->formsService->getQuestions($formId); + + return new DataResponse($questionData); + } + + /** + * @CORS + * @NoAdminRequired + * + * Read a specific question (including options) + * + * @param int $formId FormId + * @param int $questionId QuestionId + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function getQuestion(int $formId, int $questionId): DataResponse + { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->hasUserAccess($form)) { + $this->logger->debug('User has no permissions to get this form'); + throw new OCSForbiddenException(); + } + + $question = $this->formsService->getQuestion($questionId); + + if ($question['formId'] !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + return new DataResponse($question); + } + + /** + * @CORS + * @NoAdminRequired + * + * Add a new question + * + * @param int $formId the form id + * @param string $type the new question type + * @param string $text the new question title + * @param int $fromId (optional) id of the question that should be cloned + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newQuestion(int $formId, ?string $type = null, string $text = '', ?int $fromId = null): DataResponse + { + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + if ($fromId === null) { + $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ + 'formId' => $formId, + 'type' => $type, + 'text' => $text, + ]); + + if (array_search($type, Constants::ANSWER_TYPES) === false) { + $this->logger->debug('Invalid type'); + throw new OCSBadRequestException('Invalid type'); + } + + // Block creation of datetime questions + if ($type === 'datetime') { + $this->logger->debug('Datetime question type no longer supported'); + throw new OCSBadRequestException('Datetime question type no longer supported'); + } + + // Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one. + $questions = $this->questionMapper->findByForm($formId); + $lastQuestion = array_pop($questions); + if ($lastQuestion) { + $questionOrder = $lastQuestion->getOrder() + 1; + } else { + $questionOrder = 1; + } + + $question = new Question(); + + $question->setFormId($formId); + $question->setOrder($questionOrder); + $question->setType($type); + $question->setText($text); + $question->setDescription(''); + $question->setIsRequired(false); + $question->setExtraSettings([]); + + $question = $this->questionMapper->insert($question); + + $response = $question->read(); + $response['options'] = []; + $response['accept'] = []; + + $this->formsService->setLastUpdatedTimestamp($formId); + } else { + $this->logger->debug('Question to be cloned: {fromId}', [ + 'fromId' => $fromId + ]); + + try { + $sourceQuestion = $this->questionMapper->findById($fromId); + $sourceOptions = $this->optionMapper->findByQuestion($fromId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSNotFoundException('Could not find question'); + } + + $allQuestions = $this->questionMapper->findByForm($formId); + + $questionData = $sourceQuestion->read(); + unset($questionData['id']); + $questionData['order'] = end($allQuestions)->getOrder() + 1; + + $newQuestion = Question::fromParams($questionData); + $this->questionMapper->insert($newQuestion); + + $response = $newQuestion->read(); + $response['options'] = []; + $response['accept'] = []; + + foreach ($sourceOptions as $sourceOption) { + $optionData = $sourceOption->read(); + + unset($optionData['id']); + $optionData['questionId'] = $newQuestion->getId(); + $newOption = Option::fromParams($optionData); + $insertedOption = $this->optionMapper->insert($newOption); + + $response['options'][] = $insertedOption->read(); + } + } + + return new DataResponse($response); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * Key 'order' should only be changed by reorderQuestions() and is not allowed here. + * + * @param int $formId the form id + * @param int $questionId id of question to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateQuestion(int $formId, int $questionId, array $keyValuePairs): DataResponse + { + $this->logger->debug('Updating question: formId: {formId}, questionId: {questionId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSBadRequestException('Could not find question'); + } + + if ($question->getFormId() !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change id or formId + if (key_exists('id', $keyValuePairs) || key_exists('formId', $keyValuePairs)) { + $this->logger->debug('Not allowed to update \'id\' or \'formId\''); + throw new OCSForbiddenException(); + } + + // Don't allow to reorder here + if (key_exists('order', $keyValuePairs)) { + $this->logger->debug('Key \'order\' is not allowed on updateQuestion. Please use reorderQuestions() to change order.'); + throw new OCSForbiddenException('Please use reorderQuestions() to change order'); + } + + if (key_exists('extraSettings', $keyValuePairs) && !$this->formsService->areExtraSettingsValid($keyValuePairs['extraSettings'], $question->getType())) { + throw new OCSBadRequestException('Invalid extraSettings, will not update.'); + } + + // Create QuestionEntity with given Params & Id. + $question = Question::fromParams($keyValuePairs); + $question->setId($questionId); + + // Update changed Columns in Db. + $this->questionMapper->update($question); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($question->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a question + * + * @param int $formId the form id + * @param int $questionId the question id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteQuestion(int $formId, int $questionId): DataResponse + { + $this->logger->debug('Mark question as deleted: {questionId}', [ + 'questionId' => $questionId, + ]); + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSBadRequestException('Could not find question'); + } + + if ($question->getFormId() !== $formId) { + throw new OCSBadRequestException('Question doesn\'t belong to given Form'); + } + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Store Order of deleted Question + $deletedOrder = $question->getOrder(); + + // Mark question as deleted + $question->setOrder(0); + $this->questionMapper->update($question); + + // Update all question-order > deleted order. + $formQuestions = $this->questionMapper->findByForm($formId); + foreach ($formQuestions as $question) { + $questionOrder = $question->getOrder(); + if ($questionOrder > $deletedOrder) { + $question->setOrder($questionOrder - 1); + $this->questionMapper->update($question); + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($questionId); + } + + /** + * @CORS + * @NoAdminRequired + * + * Updates the Order of all Questions of a Form. + * + * @param int $formId Id of the form to reorder + * @param Array $newOrder Array of Question-Ids in new order. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function reorderQuestions(int $formId, array $newOrder): DataResponse + { + $this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [ + 'formId' => $formId, + 'newOrder' => $newOrder + ]); + + $form = $this->getFormIfAllowed($formId); + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Check if array contains duplicates + if (array_unique($newOrder) !== $newOrder) { + $this->logger->debug('The given array contains duplicates'); + throw new OCSBadRequestException('The given array contains duplicates'); + } + + // Check if all questions are given in Array. + $questions = $this->questionMapper->findByForm($formId); + if (sizeof($questions) !== sizeof($newOrder)) { + $this->logger->debug('The length of the given array does not match the number of stored questions'); + throw new OCSBadRequestException('The length of the given array does not match the number of stored questions'); + } + + $questions = []; // Clear Array of Entities + $response = []; // Array of ['questionId' => ['order' => newOrder]] + + // Store array of Question-Entities and check the Questions FormId & old Order. + foreach ($newOrder as $arrayKey => $questionId) { + try { + $questions[$arrayKey] = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question. Id: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + // Abort if a question is not part of the Form. + if ($questions[$arrayKey]->getFormId() !== $formId) { + $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + // Abort if a question is already marked as deleted (order==0) + $oldOrder = $questions[$arrayKey]->getOrder(); + if ($oldOrder === 0) { + $this->logger->debug('This Question has already been marked as deleted: Id: {questionId}', [ + 'questionId' => $questions[$arrayKey]->getId() + ]); + throw new OCSBadRequestException(); + } + + // Only set order, if it changed. + if ($oldOrder !== $arrayKey + 1) { + // Set Order. ArrayKey counts from zero, order counts from 1. + $questions[$arrayKey]->setOrder($arrayKey + 1); + } + } + + // Write to Database + foreach ($questions as $question) { + $this->questionMapper->update($question); + + $response[$question->getId()] = [ + 'order' => $question->getOrder() + ]; + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($response); + } + + // Options + + /** + * @NoAdminRequired + * + * Add a new option to a question + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param array $optionTexts the new option text + * @return DataResponse Returns a DataResponse containing the added options + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newOption(int $formId, int $questionId, array $optionTexts): DataResponse + { + $this->logger->debug('Adding new options: formId: {formId}, questionId: {questionId}, text: {text}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'text' => $optionTexts, + ]); + + try { + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form or question'); + throw new OCSBadRequestException('Could not find form or question'); + } + + if ($question->getFormId() !== $formId) { + $this->logger->debug('This Question is not part of the given Form: questionId: {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + $addedOptions = []; + foreach ($optionTexts as $text) { + $option = new Option(); + + $option->setQuestionId($questionId); + $option->setText($text); + + try { + $option = $this->optionMapper->insert($option); + // Add the stored option to the collection of added options + $addedOptions[] = $option->read(); + } catch (IMapperException $e) { + $this->logger->error("Failed to add option: {$e->getMessage()}"); + // Optionally handle the error, e.g., by continuing to the next iteration or returning an error response + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($addedOptions); + } + + /** + * @CORS + * @NoAdminRequired + * + * Writes the given key-value pairs into Database. + * + * @param int $formId id of form + * @param int $questionId id of question + * @param int $optionId id of option to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateOption(int $formId, int $questionId, int $optionId, array $keyValuePairs): DataResponse + { + $this->logger->debug('Updating option: form: {formId}, question: {questionId}, option: {optionId}, values: {keyValuePairs}', [ + 'formId' => $formId, + 'questionId' => $questionId, + 'optionId' => $optionId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $option = $this->optionMapper->findById($optionId); + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find option, question or form'); + throw new OCSBadRequestException('Could not find option, question or form'); + } + + if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { + $this->logger->debug('The given option id doesn\'t match the question or form.'); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change id or questionId + if (key_exists('id', $keyValuePairs) || key_exists('questionId', $keyValuePairs)) { + $this->logger->debug('Not allowed to update id or questionId'); + throw new OCSForbiddenException(); + } + + // Create OptionEntity with given Params & Id. + $option = Option::fromParams($keyValuePairs); + $option->setId($optionId); + + // Update changed Columns in Db. + $this->optionMapper->update($option); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($option->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete an option + * + * @param int $formId id of form + * @param int $questionId id of question + * @param int $optionId id of option to update + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteOption(int $formId, int $questionId, int $optionId): DataResponse + { + $this->logger->debug('Deleting option: {optionId}', [ + 'optionId' => $optionId + ]); + + try { + $option = $this->optionMapper->findById($optionId); + $question = $this->questionMapper->findById($questionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form, question or option'); + throw new OCSBadRequestException('Could not find form, question or option'); + } + + if ($option->getQuestionId() !== $questionId || $question->getFormId() !== $formId) { + $this->logger->debug('The given option id doesn\'t match the question or form.'); + throw new OCSBadRequestException(); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if ($this->formsService->isFormArchived($form)) { + $this->logger->debug('This form is archived and can not be modified'); + throw new OCSForbiddenException(); + } + + $this->optionMapper->delete($option); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($optionId); + } + + // Submissions + + /** + * @CORS + * @NoAdminRequired + * + * Get all the submissions of a given form + * + * @param int $formId of the form + * @return DataResponse|DataDownloadResponse + * @throws OCSNotFoundException + * @throws OCSForbiddenException + */ + public function getSubmissions(int $formId, ?string $fileFormat = null): DataResponse|DataDownloadResponse + { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + if ($fileFormat !== null) { + $submissionsData = $this->submissionService->getSubmissionsData($form, $fileFormat); + $fileName = $this->formsService->getFileName($form, $fileFormat); + + return new DataDownloadResponse($submissionsData, $fileName, Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat]); + } + + // Load submissions and currently active questions + $submissions = $this->submissionService->getSubmissions($formId); + $questions = $this->formsService->getQuestions($formId); + + // Append Display Names + foreach ($submissions as $key => $submission) { + if (substr($submission['userId'], 0, 10) === 'anon-user-') { + // Anonymous User + // TRANSLATORS On Results when listing the single Responses to the form, this text is shown as heading of the Response. + $submissions[$key]['userDisplayName'] = $this->l10n->t('Anonymous response'); + } else { + $userEntity = $this->userManager->get($submission['userId']); + + if ($userEntity instanceof IUser) { + $submissions[$key]['userDisplayName'] = $userEntity->getDisplayName(); + } else { + // Fallback, should not occur regularly. + $submissions[$key]['userDisplayName'] = $submission['userId']; + } + } + } + + $response = [ + 'submissions' => $submissions, + 'questions' => $questions, + ]; + + return new DataResponse($response); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete all submissions of a specified form + * + * @param int $formId the form id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteAllSubmissions(int $formId): DataResponse + { + $this->logger->debug('Delete all submissions to form: {formId}', [ + 'formId' => $formId, + ]); + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } + + // The current user has permissions to remove submissions + if (!$this->formsService->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); + throw new OCSForbiddenException(); + } + + // Delete all submissions (incl. Answers) + $this->submissionMapper->deleteByForm($formId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($formId); + } + + /** + * @CORS + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * Process a new submission + * + * @param int $formId the form id + * @param array $answers [question_id => arrayOfString] + * @param string $shareHash public share-hash -> Necessary to submit on public link-shares. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse + { + $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ + 'formId' => $formId, + 'answers' => $answers, + 'shareHash' => $shareHash, + ]); + + $form = $this->loadFormForSubmission($formId, $shareHash); + + $questions = $this->formsService->getQuestions($formId); + // Is the submission valid + $isSubmissionValid = $this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId()); + if (is_string($isSubmissionValid)) { + throw new OCSBadRequestException($isSubmissionValid); + } + if ($isSubmissionValid === false) { + throw new OCSBadRequestException('At least one submitted answer is not valid'); + } + + // Create Submission + $submission = new Submission(); + $submission->setFormId($formId); + $submission->setTimestamp(time()); + + // If not logged in, anonymous, or embedded use anonID + if (!$this->currentUser || $form->getIsAnonymous()) { + $anonID = 'anon-user-' . hash('md5', strval(time() + rand())); + $submission->setUserId($anonID); + } else { + $submission->setUserId($this->currentUser->getUID()); + } + + // Does the user have permissions to submit + // This is done right before insert so we minimize race conditions for submitting on unique-submission forms + if (!$this->formsService->canSubmit($form)) { + throw new OCSForbiddenException('Already submitted'); + } + + // Insert new submission + $this->submissionMapper->insert($submission); + + // Ensure the form is unique if needed. + // If we can not submit anymore then the submission must be unique + if (!$this->formsService->canSubmit($form) && $this->submissionMapper->hasMultipleFormSubmissionsByUser($form, $submission->getUserId())) { + $this->submissionMapper->delete($submission); + throw new OCSForbiddenException('Already submitted'); + } + + // Process Answers + foreach ($answers as $questionId => $answerArray) { + // Search corresponding Question, skip processing if not found + $questionIndex = array_search($questionId, array_column($questions, 'id')); + if ($questionIndex === false) { + continue; + } + + $this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray); + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + //Create Activity + $this->formsService->notifyNewSubmission($form, $submission->getUserId()); + + if ($form->getFileId() !== null) { + try { + $filePath = $this->formsService->getFilePath($form); + $fileFormat = $form->getFileFormat(); + $ownerId = $form->getOwnerId(); + + $this->submissionService->writeFileToCloud($form, $filePath, $fileFormat, $ownerId); + } catch (NotFoundException $e) { + $this->logger->notice('Form {formId} linked to a file that doesn\'t exist anymore', [ + 'formId' => $formId + ]); + } + } + + return new DataResponse(); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a specific submission + * + * @param int $formId the form id + * @param int $submissionId the submission id + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteSubmission(int $formId, int $submissionId): DataResponse + { + $this->logger->debug('Delete Submission: {submissionId}', [ + 'submissionId' => $submissionId, + ]); + + try { + $submission = $this->submissionMapper->findById($submissionId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form or submission'); + throw new OCSBadRequestException(); + } + + if ($formId !== $submission->getFormId()) { + $this->logger->debug('Submission doesn\'t belong to given form'); + throw new OCSBadRequestException('Submission doesn\'t belong to given form'); + } + + // The current user has permissions to remove submissions + if (!$this->formsService->canDeleteResults($form)) { + $this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission'); + throw new OCSForbiddenException(); + } + + // Delete submission (incl. Answers) + $this->submissionMapper->deleteById($submissionId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($submissionId); + } + + /** + * @CORS + * @NoAdminRequired + * + * Export Submissions to the Cloud + * + * @param int $formId of the form + * @param string $path The Cloud-Path to export to + * @param string $fileFormat File format used for export + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportSubmissionsToCloud(int $formId, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) + { + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); + + return new DataResponse($file->getName()); + } + + /** + * @CORS + * @NoAdminRequired + * @PublicPage + * + * Uploads a temporary files to the server during form filling + * + * @param int $formId id of the form + * @param int $questionId id of the question + * @param string $shareHash hash of the form share + * @return Response + */ + public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response + { + $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}', [ + 'formId' => $formId, + 'questionId' => $questionId + ]); + + $uploadedFiles = []; + foreach ($this->request->getUploadedFile('files') as $key => $files) { + foreach ($files as $i => $value) { + $uploadedFiles[$i][$key] = $value; + } + } + + if (!count($uploadedFiles)) { + throw new OCSBadRequestException('No files provided'); + } + + $form = $this->loadFormForSubmission($formId, $shareHash); + + if (!$this->formsService->canSubmit($form)) { + throw new OCSForbiddenException('Already submitted'); + } + + try { + $question = $this->questionMapper->findById($questionId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question with id {questionId}', [ + 'questionId' => $questionId + ]); + throw new OCSBadRequestException(previous: $e instanceof \Exception ? $e : null); + } + + if ($formId !== $question->getFormId()) { + $this->logger->debug('Question doesn\'t belong to the given form'); + throw new OCSBadRequestException('Question doesn\'t belong to the given form'); + } -use OCA\Forms\Constants; -use OCA\Forms\Db\Answer; -use OCA\Forms\Db\AnswerMapper; -use OCA\Forms\Db\Form; -use OCA\Forms\Db\FormMapper; -use OCA\Forms\Db\Option; -use OCA\Forms\Db\OptionMapper; -use OCA\Forms\Db\Question; -use OCA\Forms\Db\QuestionMapper; -use OCA\Forms\Db\ShareMapper; -use OCA\Forms\Db\Submission; -use OCA\Forms\Db\SubmissionMapper; -use OCA\Forms\Db\UploadedFile; -use OCA\Forms\Db\UploadedFileMapper; -use OCA\Forms\Service\ConfigService; -use OCA\Forms\Service\FormsService; -use OCA\Forms\Service\SubmissionService; + $path = $this->formsService->getTemporaryUploadedFilePath($form, $question); -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\AppFramework\Db\IMapperException; -use OCP\AppFramework\Http\DataDownloadResponse; -use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\Response; -use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSForbiddenException; -use OCP\AppFramework\OCS\OCSNotFoundException; -use OCP\AppFramework\OCSController; -use OCP\Files\IMimeTypeDetector; -use OCP\Files\IRootFolder; -use OCP\Files\NotFoundException; -use OCP\IL10N; -use OCP\IRequest; -use OCP\IUser; -use OCP\IUserManager; -use OCP\IUserSession; + $response = []; + foreach ($uploadedFiles as $uploadedFile) { + $error = $uploadedFile['error'] ?? 0; + if ($error !== UPLOAD_ERR_OK) { + $this->logger->error( + 'Failed to get the uploaded file. PHP file upload error code: ' . $error, + ['file_name' => $uploadedFile['name']] + ); -use Psr\Log\LoggerInterface; + throw new OCSBadRequestException(sprintf('Failed to upload the file "%s".', $uploadedFile['name'])); + } -class ApiController extends OCSController { - private ?IUser $currentUser; - - public function __construct( - string $appName, - IRequest $request, - IUserSession $userSession, - private AnswerMapper $answerMapper, - private FormMapper $formMapper, - private OptionMapper $optionMapper, - private QuestionMapper $questionMapper, - private ShareMapper $shareMapper, - private SubmissionMapper $submissionMapper, - private ConfigService $configService, - private FormsService $formsService, - private SubmissionService $submissionService, - private IL10N $l10n, - private LoggerInterface $logger, - private IUserManager $userManager, - private IRootFolder $storage, - private UploadedFileMapper $uploadedFileMapper, - private IMimeTypeDetector $mimeTypeDetector, - ) { - parent::__construct($appName, $request); - $this->currentUser = $userSession->getUser(); + if (!is_uploaded_file($uploadedFile['tmp_name'])) { + throw new OCSBadRequestException('Invalid file provided'); + } + + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $userFolder->getStorage()->verifyPath($path, $uploadedFile['name']); + + $extraSettings = $question->getExtraSettings(); + if (($extraSettings['maxFileSize'] ?? 0) > 0 && $uploadedFile['size'] > $extraSettings['maxFileSize']) { + throw new OCSBadRequestException(sprintf('File size exceeds the maximum allowed size of %s bytes.', $extraSettings['maxFileSize'])); + } + + if (!empty($extraSettings['allowedFileTypes']) || !empty($extraSettings['allowedFileExtensions'])) { + $mimeType = $this->mimeTypeDetector->detectContent($uploadedFile['tmp_name']); + $aliases = $this->mimeTypeDetector->getAllAliases(); + + $valid = false; + foreach ($extraSettings['allowedFileTypes'] ?? [] as $allowedFileType) { + if (str_starts_with($aliases[$mimeType] ?? '', $allowedFileType)) { + $valid = true; + break; + } + } + + if (!$valid && !empty($extraSettings['allowedFileExtensions'])) { + $mimeTypesPerExtension = method_exists($this->mimeTypeDetector, 'getAllMappings') + ? $this->mimeTypeDetector->getAllMappings() : []; + foreach ($extraSettings['allowedFileExtensions'] as $allowedFileExtension) { + if ( + isset($mimeTypesPerExtension[$allowedFileExtension]) + && in_array($mimeType, $mimeTypesPerExtension[$allowedFileExtension]) + ) { + $valid = true; + break; + } + } + } + + if (!$valid) { + throw new OCSBadRequestException(sprintf( + 'File type is not allowed. Allowed file types: %s', + implode(', ', array_merge($extraSettings['allowedFileTypes'] ?? [], $extraSettings['allowedFileExtensions'] ?? [])) + )); + } + } + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + /** @var \OCP\Files\Folder $folder */ + + $fileName = $folder->getNonExistingName($uploadedFile['name']); + $file = $folder->newFile($fileName, file_get_contents($uploadedFile['tmp_name'])); + + $uploadedFileEntity = new UploadedFile(); + $uploadedFileEntity->setFormId($formId); + $uploadedFileEntity->setOriginalFileName($fileName); + $uploadedFileEntity->setFileId($file->getId()); + $uploadedFileEntity->setCreated(time()); + $this->uploadedFileMapper->insert($uploadedFileEntity); + + $response[] = [ + 'uploadedFileId' => $uploadedFileEntity->getId(), + 'fileName' => $fileName, + ]; + } + + return new DataResponse($response); } + /* + * + * Legacy API v2 methods (TODO: remove with Forms v5) + * + */ + /** * @CORS * @NoAdminRequired @@ -101,7 +1356,8 @@ public function __construct( * Return only with necessary information for Listing. * @return DataResponse */ - public function getForms(): DataResponse { + public function getFormsLegacy(): DataResponse + { $forms = $this->formMapper->findAllByOwnerId($this->currentUser->getUID()); $result = []; @@ -120,9 +1376,10 @@ public function getForms(): DataResponse { * Return only with necessary information for Listing. * @return DataResponse */ - public function getSharedForms(): DataResponse { + public function getSharedFormsLegacy(): DataResponse + { $forms = $this->formsService->getSharedForms($this->currentUser); - $result = array_values(array_map(fn (Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); + $result = array_values(array_map(fn(Form $form): array => $this->formsService->getPartialFormArray($form), $forms)); return new DataResponse($result); } @@ -137,7 +1394,8 @@ public function getSharedForms(): DataResponse { * @return DataResponse * @throws OCSBadRequestException if forbidden or not found */ - public function getPartialForm(string $hash): DataResponse { + public function getPartialFormLegacy(string $hash): DataResponse + { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -164,7 +1422,8 @@ public function getPartialForm(string $hash): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function getForm(int $id): DataResponse { + public function getFormLegacy(int $id): DataResponse + { try { $form = $this->formMapper->findById($id); $formData = $this->formsService->getForm($form); @@ -191,7 +1450,8 @@ public function getForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newForm(): DataResponse { + public function newFormLegacy(): DataResponse + { // Check if user is allowed if (!$this->configService->canCreateForms()) { $this->logger->debug('This user is not allowed to create Forms.'); @@ -232,7 +1492,8 @@ public function newForm(): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function cloneForm(int $id): DataResponse { + public function cloneFormLegacy(int $id): DataResponse + { $this->logger->debug('Cloning Form: {id}', [ 'id' => $id ]); @@ -295,7 +1556,8 @@ public function cloneForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateForm(int $id, array $keyValuePairs): DataResponse { + public function updateFormLegacy(int $id, array $keyValuePairs): DataResponse + { $this->logger->debug('Updating form: FormId: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -310,9 +1572,11 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { } // Don't allow to change params id, hash, ownerId, created, lastUpdated - if (key_exists('id', $keyValuePairs) || key_exists('hash', $keyValuePairs) || + if ( + key_exists('id', $keyValuePairs) || key_exists('hash', $keyValuePairs) || key_exists('ownerId', $keyValuePairs) || key_exists('created', $keyValuePairs) || - key_exists('lastUpdated', $keyValuePairs)) { + key_exists('lastUpdated', $keyValuePairs) + ) { $this->logger->info('Not allowed to update id, hash, ownerId or created'); throw new OCSForbiddenException(); } @@ -341,7 +1605,8 @@ public function updateForm(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function transferOwner(int $formId, string $uid): DataResponse { + public function transferOwnerLegacy(int $formId, string $uid): DataResponse + { $this->logger->debug('Updating owner: formId: {formId}, userId: {uid}', [ 'formId' => $formId, 'uid' => $uid @@ -374,7 +1639,8 @@ public function transferOwner(int $formId, string $uid): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteForm(int $id): DataResponse { + public function deleteFormLegacy(int $id): DataResponse + { $this->logger->debug('Delete Form: {id}', [ 'id' => $id, ]); @@ -398,7 +1664,8 @@ public function deleteForm(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newQuestion(int $formId, string $type, string $text = ''): DataResponse { + public function newQuestionLegacy(int $formId, string $type, string $text = ''): DataResponse + { $this->logger->debug('Adding new question: formId: {formId}, type: {type}, text: {text}', [ 'formId' => $formId, 'type' => $type, @@ -464,7 +1731,8 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function reorderQuestions(int $formId, array $newOrder): DataResponse { + public function reorderQuestionsLegacy(int $formId, array $newOrder): DataResponse + { $this->logger->debug('Reordering Questions on Form {formId} as Question-Ids {newOrder}', [ 'formId' => $formId, 'newOrder' => $newOrder @@ -554,7 +1822,8 @@ public function reorderQuestions(int $formId, array $newOrder): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateQuestion(int $id, array $keyValuePairs): DataResponse { + public function updateQuestionLegacy(int $id, array $keyValuePairs): DataResponse + { $this->logger->debug('Updating question: questionId: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -618,7 +1887,8 @@ public function updateQuestion(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteQuestion(int $id): DataResponse { + public function deleteQuestionLegacy(int $id): DataResponse + { $this->logger->debug('Mark question as deleted: {id}', [ 'id' => $id, ]); @@ -669,7 +1939,8 @@ public function deleteQuestion(int $id): DataResponse { * @return DataResponse * @throws OCSBadRequestException|OCSForbiddenException */ - public function cloneQuestion(int $id): DataResponse { + public function cloneQuestionLegacy(int $id): DataResponse + { $this->logger->debug('Question to be cloned: {id}', [ 'id' => $id ]); @@ -731,7 +2002,8 @@ public function cloneQuestion(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function newOption(int $questionId, string $text): DataResponse { + public function newOptionLegacy(int $questionId, string $text): DataResponse + { $this->logger->debug('Adding new option: questionId: {questionId}, text: {text}', [ 'questionId' => $questionId, 'text' => $text, @@ -779,7 +2051,8 @@ public function newOption(int $questionId, string $text): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateOption(int $id, array $keyValuePairs): DataResponse { + public function updateOptionLegacy(int $id, array $keyValuePairs): DataResponse + { $this->logger->debug('Updating option: option: {id}, values: {keyValuePairs}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs @@ -839,7 +2112,8 @@ public function updateOption(int $id, array $keyValuePairs): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteOption(int $id): DataResponse { + public function deleteOptionLegacy(int $id): DataResponse + { $this->logger->debug('Deleting option: {id}', [ 'id' => $id ]); @@ -881,7 +2155,8 @@ public function deleteOption(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function getSubmissions(string $hash): DataResponse { + public function getSubmissionsLegacy(string $hash): DataResponse + { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -924,66 +2199,6 @@ public function getSubmissions(string $hash): DataResponse { return new DataResponse($response); } - /** - * Insert answers for a question - * - * @param Form $form - * @param int $submissionId - * @param array $question - * @param string[]|array $answerArray - */ - private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray) { - foreach ($answerArray as $answer) { - $answerEntity = new Answer(); - $answerEntity->setSubmissionId($submissionId); - $answerEntity->setQuestionId($question['id']); - - $answerText = ''; - $uploadedFile = null; - // Are we using answer ids as values - if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { - // Search corresponding option, skip processing if not found - $optionIndex = array_search($answer, array_column($question['options'], 'id')); - if ($optionIndex !== false) { - $answerText = $question['options'][$optionIndex]['text']; - } elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) { - $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer); - } - } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) { - $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']); - $answerEntity->setFileId($uploadedFile->getFileId()); - - $userFolder = $this->storage->getUserFolder($form->getOwnerId()); - $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); - - if ($userFolder->nodeExists($path)) { - $folder = $userFolder->get($path); - } else { - $folder = $userFolder->newFolder($path); - } - /** @var \OCP\Files\Folder $folder */ - - $file = $userFolder->getById($uploadedFile->getFileId())[0]; - $name = $folder->getNonExistingName($file->getName()); - $file->move($folder->getPath() . '/' . $name); - - $answerText = $name; - } else { - $answerText = $answer; // Not a multiple-question, answerText is given answer - } - - if ($answerText === '') { - continue; - } - - $answerEntity->setText($answerText); - $this->answerMapper->insert($answerEntity); - if ($uploadedFile) { - $this->uploadedFileMapper->delete($uploadedFile); - } - } - } - /** * @CORS * @NoAdminRequired @@ -993,9 +2208,12 @@ private function storeAnswersForQuestion(Form $form, $submissionId, array $quest * * @return Response */ - public function uploadFiles(int $formId, int $questionId, string $shareHash = ''): Response { - $this->logger->debug('Uploading files for formId: {formId}, questionId: {questionId}', - ['formId' => $formId, 'questionId' => $questionId]); + public function uploadFilesLegacy(int $formId, int $questionId, string $shareHash = ''): Response + { + $this->logger->debug( + 'Uploading files for formId: {formId}, questionId: {questionId}', + ['formId' => $formId, 'questionId' => $questionId] + ); $uploadedFiles = []; foreach ($this->request->getUploadedFile('files') as $key => $files) { @@ -1027,8 +2245,10 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' foreach ($uploadedFiles as $uploadedFile) { $error = $uploadedFile['error'] ?? 0; if ($error !== UPLOAD_ERR_OK) { - $this->logger->error('Failed to get the uploaded file. PHP file upload error code: ' . $error, - ['file_name' => $uploadedFile['name']]); + $this->logger->error( + 'Failed to get the uploaded file. PHP file upload error code: ' . $error, + ['file_name' => $uploadedFile['name']] + ); throw new OCSBadRequestException(sprintf('Failed to upload the file "%s".', $uploadedFile['name'])); } @@ -1061,8 +2281,10 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' $mimeTypesPerExtension = method_exists($this->mimeTypeDetector, 'getAllMappings') ? $this->mimeTypeDetector->getAllMappings() : []; foreach ($extraSettings['allowedFileExtensions'] as $allowedFileExtension) { - if (isset($mimeTypesPerExtension[$allowedFileExtension]) - && in_array($mimeType, $mimeTypesPerExtension[$allowedFileExtension])) { + if ( + isset($mimeTypesPerExtension[$allowedFileExtension]) + && in_array($mimeType, $mimeTypesPerExtension[$allowedFileExtension]) + ) { $valid = true; break; } @@ -1070,7 +2292,8 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' } if (!$valid) { - throw new OCSBadRequestException(sprintf('File type is not allowed. Allowed file types: %s', + throw new OCSBadRequestException(sprintf( + 'File type is not allowed. Allowed file types: %s', implode(', ', array_merge($extraSettings['allowedFileTypes'] ?? [], $extraSettings['allowedFileExtensions'] ?? [])) )); } @@ -1117,7 +2340,8 @@ public function uploadFiles(int $formId, int $questionId, string $shareHash = '' * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse { + public function insertSubmissionLegacy(int $formId, array $answers, string $shareHash = ''): DataResponse + { $this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [ 'formId' => $formId, 'answers' => $answers, @@ -1143,7 +2367,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash // If not logged in, anonymous, or embedded use anonID if (!$this->currentUser || $form->getIsAnonymous()) { - $anonID = 'anon-user-'. hash('md5', strval(time() + rand())); + $anonID = 'anon-user-' . hash('md5', strval(time() + rand())); $submission->setUserId($anonID); } else { $submission->setUserId($this->currentUser->getUID()); @@ -1207,7 +2431,8 @@ public function insertSubmission(int $formId, array $answers, string $shareHash * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteSubmission(int $id): DataResponse { + public function deleteSubmissionLegacy(int $id): DataResponse + { $this->logger->debug('Delete Submission: {id}', [ 'id' => $id, ]); @@ -1245,7 +2470,8 @@ public function deleteSubmission(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteAllSubmissions(int $formId): DataResponse { + public function deleteAllSubmissionsLegacy(int $formId): DataResponse + { $this->logger->debug('Delete all submissions to form: {formId}', [ 'formId' => $formId, ]); @@ -1283,7 +2509,8 @@ public function deleteAllSubmissions(int $formId): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissions(string $hash, string $fileFormat = Constants::DEFAULT_FILE_FORMAT): DataDownloadResponse { + public function exportSubmissionsLegacy(string $hash, string $fileFormat = Constants::DEFAULT_FILE_FORMAT): DataDownloadResponse + { $this->logger->debug('Export submissions for form: {hash}', [ 'hash' => $hash, ]); @@ -1319,7 +2546,8 @@ public function exportSubmissions(string $hash, string $fileFormat = Constants:: * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissionsToCloud(string $hash, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { + public function exportSubmissionsToCloudLegacy(string $hash, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) + { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -1342,7 +2570,8 @@ public function exportSubmissionsToCloud(string $hash, string $path, string $fil * * @param string $hash of the form */ - public function unlinkFile(string $hash): DataResponse { + public function unlinkFileLegacy(string $hash): DataResponse + { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { @@ -1382,7 +2611,8 @@ public function unlinkFile(string $hash): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function linkFile(string $hash, string $path, string $fileFormat): DataResponse { + public function linkFileLegacy(string $hash, string $path, string $fileFormat): DataResponse + { $this->logger->debug('Linking form {hash} to file at /{path} in format {fileFormat}', [ 'hash' => $hash, 'path' => $path, @@ -1420,7 +2650,71 @@ public function linkFile(string $hash, string $path, string $fileFormat): DataRe ]); } - private function loadFormForSubmission(int $formId, string $shareHash): Form { + // private functions + + /** + * Insert answers for a question + * + * @param Form $form + * @param int $submissionId + * @param array $question + * @param string[]|array $answerArray + */ + private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray) + { + foreach ($answerArray as $answer) { + $answerEntity = new Answer(); + $answerEntity->setSubmissionId($submissionId); + $answerEntity->setQuestionId($question['id']); + + $answerText = ''; + $uploadedFile = null; + // Are we using answer ids as values + if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) { + // Search corresponding option, skip processing if not found + $optionIndex = array_search($answer, array_column($question['options'], 'id')); + if ($optionIndex !== false) { + $answerText = $question['options'][$optionIndex]['text']; + } elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) { + $answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer); + } + } elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) { + $uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']); + $answerEntity->setFileId($uploadedFile->getFileId()); + + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $path = $this->formsService->getUploadedFilePath($form, $submissionId, $question['id'], $question['name'], $question['text']); + + if ($userFolder->nodeExists($path)) { + $folder = $userFolder->get($path); + } else { + $folder = $userFolder->newFolder($path); + } + /** @var \OCP\Files\Folder $folder */ + + $file = $userFolder->getById($uploadedFile->getFileId())[0]; + $name = $folder->getNonExistingName($file->getName()); + $file->move($folder->getPath() . '/' . $name); + + $answerText = $name; + } else { + $answerText = $answer; // Not a multiple-question, answerText is given answer + } + + if ($answerText === '') { + continue; + } + + $answerEntity->setText($answerText); + $this->answerMapper->insert($answerEntity); + if ($uploadedFile) { + $this->uploadedFileMapper->delete($uploadedFile); + } + } + } + + private function loadFormForSubmission(int $formId, string $shareHash): Form + { try { $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { @@ -1465,13 +2759,14 @@ private function loadFormForSubmission(int $formId, string $shareHash): Form { /** * Helper that retrieves a form if the current user is allowed to edit it * This throws an exception in case either the form is not found or permissions are missing. - * @param int $id The form ID to retrieve + * @param int $formId The form ID to retrieve * @throws OCSNotFoundException If the form was not found * @throws OCSForbiddenException If the current user has no permission to edit */ - private function getFormIfAllowed(int $id): Form { + private function getFormIfAllowed(int $formId): Form + { try { - $form = $this->formMapper->findById($id); + $form = $this->formMapper->findById($formId); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); throw new OCSNotFoundException(); diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 2c425d705..a66e8f3ba 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -79,13 +79,16 @@ public function __construct( * * @return TemplateResponse */ - public function index(): TemplateResponse { + public function index(?string $hash = null): TemplateResponse { Util::addScript($this->appName, 'forms-main'); Util::addStyle($this->appName, 'forms'); Util::addStyle($this->appName, 'forms-style'); $this->insertHeaderOnIos(); $this->initialState->provideInitialState('maxStringLengths', Constants::MAX_STRING_LENGTHS); $this->initialState->provideInitialState('appConfig', $this->configService->getAppConfig()); + if (isset($hash)) { + $this->initialState->provideInitialState('formId', $this->formMapper->findByHash($hash)->id); + } return new TemplateResponse($this->appName, self::TEMPLATE_MAIN, [ 'id-app-content' => '#app-content-vue', 'id-app-navigation' => '#app-navigation-vue', @@ -98,8 +101,8 @@ public function index(): TemplateResponse { * * @return TemplateResponse */ - public function views(): TemplateResponse { - return $this->index(); + public function views(string $hash): TemplateResponse { + return $this->index($hash); } /** diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 07bc5bcde..27719d516 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -203,6 +203,274 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar return new DataResponse($shareData); } + /** + * @CORS + * @NoAdminRequired + * + * Update permissions of a share + * + * @param int $formId of the form + * @param int $shareId of the share to update + * @param array $keyValuePairs Array of key=>value pairs to update. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function updateShare(int $formId, int $shareId, array $keyValuePairs): DataResponse { + $this->logger->debug('Updating share: {shareId} of form {formId}, permissions: {permissions}', [ + 'formId' => $formId, + 'shareId' => $shareId, + 'keyValuePairs' => $keyValuePairs + ]); + + try { + $formShare = $this->shareMapper->findById($shareId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + + if ($formId !== $formShare->getFormId()) { + $this->logger->debug('This share doesn\'t belong to the given Form'); + throw new OCSBadRequestException('Share doesn\'t belong to given Form'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + // Don't allow empty array + if (sizeof($keyValuePairs) === 0) { + $this->logger->info('Empty keyValuePairs, will not update.'); + throw new OCSForbiddenException(); + } + + //Don't allow to change other properties than permissions + if (count($keyValuePairs) > 1 || !key_exists('permissions', $keyValuePairs)) { + $this->logger->debug('Not allowed to update other properties than permissions'); + throw new OCSForbiddenException(); + } + + if (!$this->validatePermissions($keyValuePairs['permissions'], $formShare->getShareType())) { + throw new OCSBadRequestException('Invalid permission given'); + } + + $formShare->setPermissions($keyValuePairs['permissions']); + $formShare = $this->shareMapper->update($formShare); + + if (in_array($formShare->getShareType(), [IShare::TYPE_USER, IShare::TYPE_GROUP, IShare::TYPE_USERGROUP, IShare::TYPE_CIRCLE], true)) { + $userFolder = $this->storage->getUserFolder($form->getOwnerId()); + $uploadedFilesFolderPath = $this->formsService->getFormUploadedFilesFolderPath($form); + if ($userFolder->nodeExists($uploadedFilesFolderPath)) { + $folder = $userFolder->get($uploadedFilesFolderPath); + } else { + $folder = $userFolder->newFolder($uploadedFilesFolderPath); + } + /** @var \OCP\Files\Folder $folder */ + + if (in_array(Constants::PERMISSION_RESULTS, $keyValuePairs['permissions'], true)) { + $folderShare = $this->shareManager->newShare(); + $folderShare->setShareType($formShare->getShareType()); + $folderShare->setSharedWith($formShare->getShareWith()); + $folderShare->setSharedBy($form->getOwnerId()); + $folderShare->setPermissions(\OCP\Constants::PERMISSION_READ); + $folderShare->setNode($folder); + $folderShare->setShareOwner($form->getOwnerId()); + + $this->shareManager->createShare($folderShare); + } else { + $folderShares = $this->shareManager->getSharesBy($form->getOwnerId(), $formShare->getShareType(), $folder); + foreach ($folderShares as $folderShare) { + if ($folderShare->getSharedWith() === $formShare->getShareWith()) { + $this->shareManager->deleteShare($folderShare); + } + } + } + } + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($formShare->getId()); + } + + /** + * @CORS + * @NoAdminRequired + * + * Delete a share + * + * @param int $formId of the form + * @param int $shareId of the share to delete + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function deleteShare(int $formId, int $shareId): DataResponse { + $this->logger->debug('Deleting share: {shareId} of form {formId}', [ + 'formId' => $formId, + 'shareId' => $shareId, + ]); + + try { + $share = $this->shareMapper->findById($shareId); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find share', ['exception' => $e]); + throw new OCSBadRequestException('Could not find share'); + } + + if ($formId !== $share->getFormId()) { + $this->logger->debug('This share doesn\'t belong to the given Form'); + throw new OCSBadRequestException('Share doesn\'t belong to given Form'); + } + + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + $this->shareMapper->deleteById($shareId); + + $this->formsService->setLastUpdatedTimestamp($formId); + + return new DataResponse($shareId); + } + + /* + * + * Legacy API v2 methods (TODO: remove with Forms v5) + * + */ + + /** + * @CORS + * @NoAdminRequired + * + * Add a new share + * + * @param int $formId The form to share + * @param int $shareType Nextcloud-ShareType + * @param string $shareWith ID of user/group/... to share with. For Empty shareWith and shareType Link, this will be set as RandomID. + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function newShareLegacy(int $formId, int $shareType, string $shareWith = '', array $permissions = [Constants::PERMISSION_SUBMIT]): DataResponse { + $this->logger->debug('Adding new share: formId: {formId}, shareType: {shareType}, shareWith: {shareWith}, permissions: {permissions}', [ + 'formId' => $formId, + 'shareType' => $shareType, + 'shareWith' => $shareWith, + 'permissions' => $permissions, + ]); + + // Only accept usable shareTypes + if (array_search($shareType, Constants::SHARE_TYPES_USED) === false) { + $this->logger->debug('Invalid shareType'); + throw new OCSBadRequestException('Invalid shareType'); + } + + // Block LinkShares if not allowed + if ($shareType === IShare::TYPE_LINK && !$this->configService->getAllowPublicLink()) { + $this->logger->debug('Link Share not allowed.'); + throw new OCSForbiddenException('Link Share not allowed.'); + } + + try { + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form', ['exception' => $e]); + throw new OCSBadRequestException('Could not find form'); + } + + // Check for permission to share form + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if (!$this->validatePermissions($permissions, $shareType)) { + throw new OCSBadRequestException('Invalid permission given'); + } + + // Create public-share hash, if necessary. + if ($shareType === IShare::TYPE_LINK) { + $shareWith = $this->secureRandom->generate( + 24, + ISecureRandom::CHAR_HUMAN_READABLE + ); + } + + // Check for valid shareWith, needs to be done separately per shareType + switch ($shareType) { + case IShare::TYPE_USER: + if (!($this->userManager->get($shareWith) instanceof IUser)) { + $this->logger->debug('Invalid user to share with.'); + throw new OCSBadRequestException('Invalid user to share with.'); + } + break; + + case IShare::TYPE_GROUP: + if (!($this->groupManager->get($shareWith) instanceof IGroup)) { + $this->logger->debug('Invalid group to share with.'); + throw new OCSBadRequestException('Invalid group to share with.'); + } + break; + + case IShare::TYPE_LINK: + // Check if hash already exists. (Unfortunately not possible here by unique index on db.) + try { + // Try loading a share to the hash. + $nonex = $this->shareMapper->findPublicShareByHash($shareWith); + + // If we come here, a share has been found --> The share hash already exists, thus aborting. + $this->logger->debug('Share Hash already exists.'); + throw new OCSException('Share Hash exists. Please retry.'); + } catch (DoesNotExistException $e) { + // Just continue, this is what we expect to happen (share hash not existing yet). + } + break; + + case IShare::TYPE_CIRCLE: + if (!$this->circlesService->isCirclesEnabled()) { + $this->logger->debug('Teams app is disabled, sharing to teams not possible.'); + throw new OCSException('Teams app is disabled.'); + } + $circle = $this->circlesService->getCircle($shareWith); + if (is_null($circle)) { + $this->logger->debug('Invalid team to share with.'); + throw new OCSBadRequestException('Invalid team to share with.'); + } + break; + + default: + // This passed the check for used shareTypes, but has not been found here. + $this->logger->warning('Unknown, but used shareType: {shareType}. Please file an issue on GitHub.', [ 'shareType' => $shareType ]); + throw new OCSException('Unknown shareType.'); + } + + $share = new Share(); + $share->setFormId($formId); + $share->setShareType($shareType); + $share->setShareWith($shareWith); + $share->setPermissions($permissions); + + /** @var Share */ + $share = $this->shareMapper->insert($share); + + // Create share-notifications (activity) + $this->formsService->notifyNewShares($form, $share); + + $this->formsService->setLastUpdatedTimestamp($formId); + + // Append displayName for Frontend + $shareData = $share->read(); + $shareData['displayName'] = $this->formsService->getShareDisplayName($shareData); + + return new DataResponse($shareData); + } + /** * @CORS * @NoAdminRequired @@ -214,7 +482,7 @@ public function newShare(int $formId, int $shareType, string $shareWith = '', ar * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function deleteShare(int $id): DataResponse { + public function deleteShareLegacy(int $id): DataResponse { $this->logger->debug('Deleting share: {id}', [ 'id' => $id ]); @@ -251,7 +519,7 @@ public function deleteShare(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function updateShare(int $id, array $keyValuePairs): DataResponse { + public function updateShareLegacy(int $id, array $keyValuePairs): DataResponse { $this->logger->debug('Updating share: {id}, permissions: {permissions}', [ 'id' => $id, 'keyValuePairs' => $keyValuePairs diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index ac4cc3595..d32b6dcb6 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -144,6 +144,40 @@ public function getQuestions(int $formId): array { } } + /** + * Load specific question + * + * @param integer $questionId id of the question + * @return array + */ + public function getQuestion(int $questionId): array { + $question = []; + try { + $questionEntity = $this->questionMapper->findById($questionId); + $question = $questionEntity->read(); + $question['options'] = $this->getOptions($question['id']); + $question['accept'] = []; + if ($question['type'] === Constants::ANSWER_TYPE_FILE) { + if ($question['extraSettings']['allowedFileTypes'] ?? null) { + $question['accept'] = array_keys(array_intersect( + $this->mimeTypeDetector->getAllAliases(), + $question['extraSettings']['allowedFileTypes'] + )); + } + + if ($question['extraSettings']['allowedFileExtensions'] ?? null) { + foreach ($question['extraSettings']['allowedFileExtensions'] as $extension) { + $question['accept'][] = '.' . $extension; + } + } + } + } catch (DoesNotExistException $e) { + //handle silently + } finally { + return $question; + } + } + /** * Load shares corresponding to form * diff --git a/playwright/support/sections/FormSection.ts b/playwright/support/sections/FormSection.ts index 8305f4b08..1fe2b235c 100644 --- a/playwright/support/sections/FormSection.ts +++ b/playwright/support/sections/FormSection.ts @@ -60,7 +60,7 @@ export class FormSection { response .request() .url() - .endsWith('/ocs/v2.php/apps/forms/api/v2.4/form/update'), + .includes('/ocs/v2.php/apps/forms/api/v3/forms/'), ) } } diff --git a/src/Forms.vue b/src/Forms.vue index 4aeb241fe..66411b4a1 100644 --- a/src/Forms.vue +++ b/src/Forms.vue @@ -362,7 +362,7 @@ export default { // Load Owned forms try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/forms'), + generateOcsUrl('apps/forms/api/v3/forms'), ) this.forms = OcsResponse2Data(response) } catch (error) { @@ -375,7 +375,7 @@ export default { // Load shared forms try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/shared_forms'), + generateOcsUrl('apps/forms/api/v3/forms?type=shared'), ) this.allSharedForms = OcsResponse2Data(response) } catch (error) { @@ -413,8 +413,8 @@ export default { ) { try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/partial_form/{hash}', { - hash, + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: loadState(appName, 'formId'), }), ) const form = OcsResponse2Data(response) @@ -447,7 +447,7 @@ export default { try { // Request a new empty form const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form'), + generateOcsUrl('apps/forms/api/v3/forms'), ) const newForm = OcsResponse2Data(response) this.forms.unshift(newForm) @@ -467,7 +467,7 @@ export default { async onCloneForm(id) { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form/clone/{id}', { id }), + generateOcsUrl('apps/forms/api/v3/forms?fromId={id}', { id }), ) const newForm = OcsResponse2Data(response) this.forms.unshift(newForm) diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 6fdd0d3b0..5e92f1e63 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -301,9 +301,10 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/form/update'), - { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, + }), + { keyValuePairs: { state: this.isArchived ? FormState.FormClosed @@ -326,7 +327,7 @@ export default { this.loading = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, }), ) diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 6af41b2d6..9c32ff4c4 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -68,6 +68,10 @@ export default { type: Number, required: true, }, + formId: { + type: Number, + required: true, + }, isUnique: { type: Boolean, required: true, @@ -168,17 +172,22 @@ export default { async createAnswer(answer) { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/option'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options', + { + id: this.formId, + questionId: answer.questionId, + }, + ), { - questionId: answer.questionId, - text: answer.text, + optionTexts: [answer.text], }, ) logger.debug('Created answer', { answer }) // Was synced once, this is now up to date with the server delete answer.local - return Object.assign({}, answer, OcsResponse2Data(response)) + return Object.assign({}, answer, OcsResponse2Data(response)[0]) } catch (error) { logger.error('Error while saving answer', { answer, error }) showError(t('forms', 'Error while saving the answer')) @@ -199,9 +208,15 @@ export default { async updateAnswer(answer) { try { await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/option/update'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: answer.questionId, + optionId: answer.id, + }, + ), { - id: this.answer.id, keyValuePairs: { text: answer.text, }, diff --git a/src/components/Questions/QuestionDropdown.vue b/src/components/Questions/QuestionDropdown.vue index 0431dd5ff..3b41df5aa 100644 --- a/src/components/Questions/QuestionDropdown.vue +++ b/src/components/Questions/QuestionDropdown.vue @@ -66,6 +66,7 @@ " ref="input" :answer="answer" + :form-id="formId" :index="index" :is-unique="!isMultiple" :is-dropdown="true" @@ -304,9 +305,14 @@ export default { // let's not await, deleting in background axios .delete( - generateOcsUrl('apps/forms/api/v2.4/option/{id}', { - id: option.id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: this.id, + optionId: option.id, + }, + ), ) .catch((error) => { logger.error('Error while deleting an option', { diff --git a/src/components/Questions/QuestionFile.vue b/src/components/Questions/QuestionFile.vue index 045deea5d..78087c932 100644 --- a/src/components/Questions/QuestionFile.vue +++ b/src/components/Questions/QuestionFile.vue @@ -303,9 +303,9 @@ export default { formData.append('shareHash', loadState('forms', 'shareHash', null)) const url = generateOcsUrl( - 'apps/forms/api/v2.5/uploadFiles/{formId}/{questionId}', + 'apps/forms/api/v3/forms/{id}/submissions/files/{questionId}', { - formId: this.formId, + id: this.formId, questionId: this.id, }, ) diff --git a/src/components/Questions/QuestionMultiple.vue b/src/components/Questions/QuestionMultiple.vue index 6728495b2..f8c14404b 100644 --- a/src/components/Questions/QuestionMultiple.vue +++ b/src/components/Questions/QuestionMultiple.vue @@ -144,6 +144,7 @@ " ref="input" :answer="answer" + :form-id="formId" :index="index" :is-unique="isUnique" :is-dropdown="false" @@ -611,9 +612,14 @@ export default { // let's not await, deleting in background axios .delete( - generateOcsUrl('apps/forms/api/v2.4/option/{id}', { - id: option.id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options/{optionId}', + { + id: this.formId, + questionId: this.id, + optionId: option.id, + }, + ), ) .catch((error) => { logger.error('Error while deleting an option', { diff --git a/src/components/SidebarTabs/SharingSidebarTab.vue b/src/components/SidebarTabs/SharingSidebarTab.vue index a3559c38c..e4bc95695 100644 --- a/src/components/SidebarTabs/SharingSidebarTab.vue +++ b/src/components/SidebarTabs/SharingSidebarTab.vue @@ -320,9 +320,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/share'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares', { + id: this.form.id, + }), { - formId: this.form.id, shareType: newShare.shareType, shareWith: newShare.shareWith, }, @@ -347,9 +348,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/share'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares', { + id: this.form.id, + }), { - formId: this.form.id, shareType: this.SHARE_TYPES.SHARE_TYPE_LINK, }, ) @@ -389,9 +391,11 @@ export default { try { const response = await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/share/update'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares/{shareId}', { + id: this.form.id, + shareId: updatedShare.id, + }), { - id: updatedShare.id, keyValuePairs: { permissions: updatedShare.permissions, }, @@ -424,8 +428,9 @@ export default { try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/share/{id}', { - id: share.id, + generateOcsUrl('apps/forms/api/v3/forms/{id}/shares/{shareId}', { + id: this.form.id, + shareId: share.id, }), ) this.$emit('remove-share', share) diff --git a/src/components/SidebarTabs/TransferOwnership.vue b/src/components/SidebarTabs/TransferOwnership.vue index 169e4d770..6514cffc8 100644 --- a/src/components/SidebarTabs/TransferOwnership.vue +++ b/src/components/SidebarTabs/TransferOwnership.vue @@ -183,11 +183,12 @@ export default { if (this.form.id && this.selected.shareWith) { try { emit('forms:last-updated:set', this.form.id) - await axios.post( - generateOcsUrl('apps/forms/api/v2.4/form/transfer'), + await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), { - formId: this.form.id, - uid: this.selected.shareWith, + ownerId: this.selected.shareWith, }, ) showSuccess( diff --git a/src/mixins/QuestionMixin.js b/src/mixins/QuestionMixin.js index b569b7a85..9c04279bd 100644 --- a/src/mixins/QuestionMixin.js +++ b/src/mixins/QuestionMixin.js @@ -372,9 +372,14 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/question/update'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { + id: this.formId, + questionId: this.id, + }, + ), { - id: this.id, keyValuePairs: { [key]: value, }, @@ -393,29 +398,38 @@ export default { * @param {Array} answers - The array of answers for the question. */ async handleMultipleOptions(answers) { - const options = this.options.slice() this.isLoading = true - for (let i = 0; i < answers.length; i++) { + try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2/option'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}/options', + { + id: this.formId, + questionId: this.id, + }, + ), { - questionId: this.id, - text: answers[i], + optionTexts: answers, // Send the entire array of answers at once }, ) - const newServerOption = OcsResponse2Data(response) - const option = { - id: newServerOption.id, // use the ID from the server - questionId: this.id, - text: answers[i], - local: false, - } - options.push(option) + const newServerOptions = OcsResponse2Data(response) // Assuming this function can handle arrays + const options = this.options.slice() + newServerOptions.forEach((option) => { + options.push({ + id: option.id, // Use the ID from the server + questionId: this.id, + text: option.text, + local: false, + }) + }) + this.updateOptions(options) + this.$nextTick(() => { + this.focusIndex(options.length - 1) + }) + } catch (error) { + logger.error('Error while saving question options', { error }) + showError(t('forms', 'Error while saving question options')) } - this.updateOptions(options) - this.$nextTick(() => { - this.focusIndex(options.length - 1) - }) this.isLoading = false }, diff --git a/src/mixins/ViewsMixin.js b/src/mixins/ViewsMixin.js index c41c0a457..7b416a621 100644 --- a/src/mixins/ViewsMixin.js +++ b/src/mixins/ViewsMixin.js @@ -154,7 +154,7 @@ export default { try { const response = await request( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { id }), + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id }), ) this.$emit('update:form', OcsResponse2Data(response)) this.isLoadingForm = false @@ -178,9 +178,10 @@ export default { try { // TODO: add loading status feedback ? await axios.patch( - generateOcsUrl('apps/forms/api/v2.4/form/update'), - { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, + }), + { keyValuePairs: { [key]: this.form[key], }, diff --git a/src/views/Create.vue b/src/views/Create.vue index e5cda6c99..63efa343f 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -147,7 +147,7 @@ :max-string-lengths="maxStringLengths" v-bind.sync="form.questions[index]" @clone="cloneQuestion(question)" - @delete="deleteQuestion(question)" + @delete="deleteQuestion(question.id)" @move-down="onMoveDown(index)" @move-up="onMoveUp(index)" /> @@ -416,9 +416,10 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/question'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/questions', { + id: this.form.id, + }), { - formId: this.form.id, type, text, }, @@ -458,23 +459,30 @@ export default { /** * Delete a question * - * @param {object} question the question to delete - * @param {number} question.id the question id to delete + * @param {number} questionId the question id to delete */ - async deleteQuestion({ id }) { + async deleteQuestion(questionId) { this.isLoadingQuestions = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/question/{id}', { id }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { + id: this.form.id, + questionId, + }, + ), ) const index = this.form.questions.findIndex( - (search) => search.id === id, + (search) => search.id === questionId, ) this.form.questions.splice(index, 1) emit('forms:last-updated:set', this.form.id) } catch (error) { - logger.error(`Error while removing question ${id}`, { error }) + logger.error(`Error while removing question ${questionId}`, { + error, + }) showError( t('forms', 'There was an error while removing the question'), ) @@ -493,9 +501,13 @@ export default { try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/question/clone/{id}', { - id, - }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions?fromId={questionId}', + { + id: this.form.id, + questionId: id, + }, + ), ) const question = OcsResponse2Data(response) @@ -529,10 +541,11 @@ export default { const newOrder = this.form.questions.map((question) => question.id) try { - await axios.put( - generateOcsUrl('apps/forms/api/v2.4/question/reorder'), + await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}/questions', { + id: this.form.id, + }), { - formId: this.form.id, newOrder, }, ) diff --git a/src/views/Results.vue b/src/views/Results.vue index 8d070a04b..7ce4adc8b 100644 --- a/src/views/Results.vue +++ b/src/views/Results.vue @@ -474,9 +474,17 @@ export default { methods: { async onUnlinkFile() { - await axios.post(generateOcsUrl('apps/forms/api/v2.4/form/unlink'), { - hash: this.form.hash, - }) + await axios.update( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), + { + keyValuePairs: { + fileId: null, + fileFormat: null, + }, + }, + ) this.form.fileFormat = null this.form.fileId = null @@ -489,8 +497,8 @@ export default { try { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/submissions/{hash}', { - hash: this.form.hash, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }), ) @@ -515,8 +523,8 @@ export default { async onDownloadFile(fileFormat) { const exportUrl = - generateOcsUrl('apps/forms/api/v2.4/submissions/export/{hash}', { - hash: this.form.hash, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }) + '?requesttoken=' + encodeURIComponent(getRequestToken()) + @@ -531,14 +539,15 @@ export default { .pick() .then(async (path) => { try { - const response = await axios.post( - generateOcsUrl( - 'apps/forms/api/v2.4/form/link/{fileFormat}', - { fileFormat }, - ), + const response = await axios.patch( + generateOcsUrl('apps/forms/api/v3/forms/{id}', { + id: this.form.id, + }), { - hash: this.form.hash, - path, + keyValuePairs: { + path, + fileFormat, + }, }, ) const responseData = OcsResponse2Data(response) @@ -581,9 +590,15 @@ export default { try { const response = await axios.post( generateOcsUrl( - 'apps/forms/api/v2.4/submissions/export', + 'apps/forms/api/v3/forms/{id}/submissions/export', + { + id: this.form.id, + }, ), - { hash: this.form.hash, path, fileFormat }, + { + path, + fileFormat, + }, ) showSuccess( t('forms', 'Export successful to {file}', { @@ -608,7 +623,7 @@ export default { async fetchLinkedFileInfo() { const response = await axios.get( - generateOcsUrl('apps/forms/api/v2.4/form/{id}', { + generateOcsUrl('apps/forms/api/v3/forms/{id}', { id: this.form.id, }), ) @@ -628,9 +643,13 @@ export default { } try { const response = await axios.post( - generateOcsUrl('apps/forms/api/v2.4/submissions/export'), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/submissions/export', + { + id: this.form.id, + }, + ), { - hash: this.form.hash, path: this.form.filePath, fileFormat: this.form.fileFormat, }, @@ -651,7 +670,13 @@ export default { try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/submission/{id}', { id }), + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/submissions/{submissionId}', + { + id: this.form.id, + submissionId: id, + }, + ), ) showSuccess(t('forms', 'Submission deleted')) const index = this.form.submissions.findIndex( @@ -678,8 +703,8 @@ export default { this.loadingResults = true try { await axios.delete( - generateOcsUrl('apps/forms/api/v2.4/submissions/{formId}', { - formId: this.form.id, + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, }), ) this.form.submissions = [] diff --git a/src/views/Submit.vue b/src/views/Submit.vue index 2f29040fa..b5a73bb54 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -624,9 +624,10 @@ export default { try { await axios.post( - generateOcsUrl('apps/forms/api/v2.4/submission/insert'), + generateOcsUrl('apps/forms/api/v3/forms/{id}/submissions', { + id: this.form.id, + }), { - formId: this.form.id, answers: this.answers, shareHash: this.shareHash, }, diff --git a/tests/Integration/Api/ApiV2Test.php b/tests/Integration/Api/ApiV2Test.php index 902af9def..5d77d94ea 100644 --- a/tests/Integration/Api/ApiV2Test.php +++ b/tests/Integration/Api/ApiV2Test.php @@ -48,7 +48,7 @@ class ApiV2Test extends IntegrationBase { private function setTestForms() { $this->testForms = [ [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'description' => 'Just a simple form.', 'owner_id' => 'test', @@ -168,7 +168,7 @@ private function setTestForms() { ] ], [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'description' => '', 'owner_id' => 'someUser', @@ -205,7 +205,7 @@ private function setTestForms() { 'submissions' => [] ], [ - 'hash' => 'third', + 'hash' => 'zyxwvutsrq654321', 'title' => 'Title of a third Form', 'description' => '', 'owner_id' => 'test', @@ -291,7 +291,7 @@ public function dataGetForms() { 'getTestforms' => [ 'expected' => [ [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'expires' => 0, 'state' => 0, @@ -301,7 +301,7 @@ public function dataGetForms() { 'submissionCount' => 3, ], [ - 'hash' => 'third', + 'hash' => 'zyxwvutsrq654321', 'title' => 'Title of a third Form', 'expires' => 0, 'state' => 0, @@ -334,7 +334,7 @@ public function dataGetSharedForms() { 'getTestforms' => [ 'expected' => [ [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'expires' => 0, 'state' => 0, @@ -367,7 +367,7 @@ public function dataGetPartialForm() { return [ 'getPartialForm' => [ 'expected' => [ - 'hash' => 'abcdefghij', + 'hash' => 'abcdefghij123456', 'title' => 'Title of a second Form', 'expires' => 0, 'state' => 0, @@ -458,7 +458,7 @@ public function dataGetFullForm() { return [ 'getFullForm' => [ 'expected' => [ - 'hash' => 'abcdefg', + 'hash' => '0123456789abcdef', 'title' => 'Title of a Form', 'description' => 'Just a simple form.', 'ownerId' => 'test', diff --git a/tests/Integration/Api/ApiV3Test.php b/tests/Integration/Api/ApiV3Test.php new file mode 100644 index 000000000..73c310bdf --- /dev/null +++ b/tests/Integration/Api/ApiV3Test.php @@ -0,0 +1,1401 @@ + + * + * @author Jonas Rittershofer + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Forms\Tests\Integration\Api; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\ClientException; + +use OCA\Forms\Constants; +use OCA\Forms\Tests\Integration\IntegrationBase; + +/** + * @group DB + */ +class ApiV3Test extends IntegrationBase { + /** @var GuzzleHttp\Client */ + private $http; + + protected array $users = [ + 'test' => 'Test user', + ]; + + /** + * Store Test Forms Array. + * Necessary as function due to object type-casting. + */ + private function setTestForms() { + $this->testForms = [ + [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'description' => 'Just a simple form.', + 'owner_id' => 'test', + 'access_enum' => 0, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => 'Back to website', + 'file_id' => null, + 'file_format' => null, + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'First Question?', + 'description' => 'Please answer this.', + 'isRequired' => true, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + [ + 'type' => 'multiple_unique', + 'text' => 'Second Question?', + 'description' => '', + 'isRequired' => false, + 'name' => 'city', + 'order' => 2, + 'options' => [ + [ + 'text' => 'Option 1' + ], + [ + 'text' => 'Option 2' + ], + [ + 'text' => '' + ] + ], + 'accept' => [], + 'extraSettings' => [ + 'shuffleOptions' => true + ] + ], + [ + 'type' => 'file', + 'text' => 'File Question?', + 'description' => '', + 'isRequired' => false, + 'name' => 'file', + 'order' => 3, + 'options' => [], + 'accept' => ['.txt'], + 'extraSettings' => [ + 'allowedFileExtensions' => ['txt'], + 'maxAllowedFilesCount' => 1, + 'maxFileSize' => 1024, + ], + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user1', + 'permissions' => ['submit', 'results'], + ], + [ + 'shareType' => 3, + 'shareWith' => 'shareHash', + 'permissions' => ['submit'], + ], + ], + 'submissions' => [ + [ + 'userId' => 'user1', + 'timestamp' => 123456, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => 'This is a short answer.' + ], + [ + 'questionIndex' => 1, + 'text' => 'Option 1' + ] + ] + ], + [ + 'userId' => 'user2', + 'timestamp' => 12345, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => 'This is another short answer.' + ], + [ + 'questionIndex' => 1, + 'text' => 'Option 2' + ] + ] + ], + [ + 'userId' => 'user3', + 'timestamp' => 1234, + 'answers' => [ + [ + 'questionIndex' => 0, + 'text' => '' + ] + ] + ] + ] + ], + [ + 'hash' => 'abcdefghij123456', + 'title' => 'Title of a second Form', + 'description' => '', + 'owner_id' => 'someUser', + 'access_enum' => 2, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => '', + 'file_id' => null, + 'file_format' => null, + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'Third Question?', + 'description' => '', + 'isRequired' => false, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user2', + ], + ], + 'submissions' => [] + ], + [ + 'hash' => 'zyxwvutsrq654321', + 'title' => 'Title of a third Form', + 'description' => '', + 'owner_id' => 'test', + 'access_enum' => 2, + 'created' => 12345, + 'expires' => 0, + 'state' => 0, + 'is_anonymous' => false, + 'submit_multiple' => false, + 'show_expiration' => false, + 'last_updated' => 123456789, + 'submission_message' => '', + 'file_id' => 12, + 'file_format' => 'csv', + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'Third Question?', + 'description' => '', + 'isRequired' => false, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'extraSettings' => [] + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user2', + ], + ], + 'submissions' => [] + ], + ]; + } + + /** + * Set up test environment. + * Writing testforms into db, preparing http request + */ + public function setUp(): void { + $this->setTestForms(); + $this->users = [ + 'test' => 'Test Displayname', + 'user1' => 'User No. 1', + ]; + + parent::setUp(); + + // Set up http Client + $this->http = new Client([ + 'base_uri' => 'http://localhost:8080/ocs/v2.php/apps/forms/', + 'auth' => ['test', 'test'], + 'headers' => [ + 'OCS-ApiRequest' => 'true', + 'Accept' => 'application/json' + ], + ]); + } + + public function tearDown(): void { + parent::tearDown(); + } + + // Small Wrapper for OCS-Response + private function OcsResponse2Data($resp) { + $arr = json_decode($resp->getBody()->getContents(), true); + return $arr['ocs']['data']; + } + + // Unset Id, as we can not control it on the tests. + private function arrayUnsetId(array $arr): array { + foreach ($arr as $index => $elem) { + unset($arr[$index]['id']); + } + return $arr; + } + + public function dataGetForms() { + return [ + 'getTestforms' => [ + 'expected' => [ + [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => Constants::PERMISSION_ALL, + 'partial' => true, + 'submissionCount' => 3, + ], + [ + 'hash' => 'zyxwvutsrq654321', + 'title' => 'Title of a third Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => Constants::PERMISSION_ALL, + 'partial' => true, + 'submissionCount' => 0, + ] + ] + ] + ]; + } + /** + * @dataProvider dataGetForms + * + * @param array $expected + */ + public function testGetForms(array $expected): void { + $resp = $this->http->request('GET', 'api/v3/forms'); + + $data = $this->OcsResponse2Data($resp); + $data = $this->arrayUnsetId($data); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetSharedForms() { + return [ + 'getTestforms' => [ + 'expected' => [ + [ + 'hash' => 'abcdefghij123456', + 'title' => 'Title of a second Form', + 'expires' => 0, + 'state' => 0, + 'lastUpdated' => 123456789, + 'permissions' => [ + 'submit' + ], + 'partial' => true + ], + ] + ] + ]; + } + /** + * @dataProvider dataGetSharedForms + * + * @param array $expected + */ + public function testGetSharedForms(array $expected): void { + $resp = $this->http->request('GET', 'api/v3/forms?type=shared'); + + $data = $this->OcsResponse2Data($resp); + $data = $this->arrayUnsetId($data); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetNewForm() { + return [ + 'getNewForm' => [ + 'expected' => [ + // 'hash' => Some random, cannot be checked. + 'title' => '', + 'description' => '', + 'ownerId' => 'test', + // 'created' => time() can not be checked exactly + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false + ], + 'expires' => 0, + 'state' => 0, + 'isAnonymous' => false, + 'submitMultiple' => false, + 'showExpiration' => false, + // 'lastUpdated' => time() can not be checked exactly + 'canSubmit' => true, + 'permissions' => Constants::PERMISSION_ALL, + 'questions' => [], + 'shares' => [], + 'submissionCount' => 0, + 'submissionMessage' => null, + 'fileId' => null, + 'fileFormat' => null, + ] + ] + ]; + } + /** + * @dataProvider dataGetNewForm + * + * @param array $expected + */ + public function testGetNewForm(array $expected): void { + $resp = $this->http->request('POST', 'api/v3/forms'); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[] = $data; + + // Cannot control id + unset($data['id']); + // Check general behaviour of hash + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{16}$/', $data['hash']); + unset($data['hash']); + // Check general behaviour of created (Created in the last 10 seconds) + $this->assertEqualsWithDelta(time(), $data['created'], 10); + unset($data['created']); + // Check general behaviour of lastUpdated (Last update in the last 10 seconds) + $this->assertEqualsWithDelta(time(), $data['lastUpdated'], 10); + unset($data['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataGetFullForm() { + return [ + 'getFullForm' => [ + 'expected' => [ + 'hash' => '0123456789abcdef', + 'title' => 'Title of a Form', + 'description' => 'Just a simple form.', + 'ownerId' => 'test', + 'created' => 12345, + 'access' => [ + 'permitAllUsers' => false, + 'showToAllUsers' => false + ], + 'expires' => 0, + 'state' => 0, + 'isAnonymous' => false, + 'submitMultiple' => false, + 'showExpiration' => false, + 'lastUpdated' => 123456789, + 'canSubmit' => true, + 'permissions' => Constants::PERMISSION_ALL, + 'submissionMessage' => 'Back to website', + 'questions' => [ + [ + 'type' => 'short', + 'text' => 'First Question?', + 'isRequired' => true, + 'name' => '', + 'order' => 1, + 'options' => [], + 'accept' => [], + 'description' => 'Please answer this.', + 'extraSettings' => [] + ], + [ + 'type' => 'multiple_unique', + 'text' => 'Second Question?', + 'isRequired' => false, + 'name' => 'city', + 'order' => 2, + 'options' => [ + [ + 'text' => 'Option 1' + ], + [ + 'text' => 'Option 2' + ], + [ + 'text' => '' + ] + ], + 'accept' => [], + 'description' => '', + 'extraSettings' => [ + 'shuffleOptions' => true, + ] + ], + [ + 'type' => 'file', + 'text' => 'File Question?', + 'isRequired' => false, + 'name' => 'file', + 'order' => 3, + 'options' => [], + 'accept' => ['.txt'], + 'description' => '', + 'extraSettings' => [ + 'allowedFileExtensions' => ['txt'], + 'maxAllowedFilesCount' => 1, + 'maxFileSize' => 1024, + ], + ], + ], + 'shares' => [ + [ + 'shareType' => 0, + 'shareWith' => 'user1', + 'permissions' => ['submit', 'results'], + 'displayName' => 'User No. 1' + ], + [ + 'shareType' => 3, + 'shareWith' => 'shareHash', + 'permissions' => ['submit'], + 'displayName' => '' + ], + ], + 'submissionCount' => 3, + 'fileId' => null, + 'fileFormat' => null, + ] + ] + ]; + } + /** + * @dataProvider dataGetFullForm + * + * @param array $expected + */ + public function testGetFullForm(array $expected): void { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + // Cannot control ids, but check general consistency. + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($data['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + foreach ($data['shares'] as $sIndex => $share) { + $this->assertEquals($data['id'], $share['formId']); + unset($data['shares'][$sIndex]['formId']); + unset($data['shares'][$sIndex]['id']); + } + unset($data['id']); + + // Allow a 10 second diff for lastUpdated between expectation and data + $this->assertEqualsWithDelta($expected['lastUpdated'], $data['lastUpdated'], 10); + unset($data['lastUpdated']); + unset($expected['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataCloneForm() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + // Compared to full form expected, update changed properties + $fullFormExpected['title'] = 'Title of a Form - Copy'; + $fullFormExpected['shares'] = []; + $fullFormExpected['submissionCount'] = 0; + // Compared to full form expected, unset unpredictable properties. These will be checked logically. + unset($fullFormExpected['id']); + unset($fullFormExpected['hash']); + unset($fullFormExpected['created']); + unset($fullFormExpected['lastUpdated']); + foreach ($fullFormExpected['questions'] as $qIndex => $question) { + unset($fullFormExpected['questions'][$qIndex]['formId']); + } + + return [ + 'updateFormProps' => [ + 'expected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataCloneForm + * + * @param array $expected + */ + public function testCloneForm(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms?fromId={$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[] = $data; + + // Cannot control ids, but check general consistency. + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($data['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + foreach ($data['shares'] as $sIndex => $share) { + $this->assertEquals($data['id'], $share['formId']); + unset($data['shares'][$sIndex]['formId']); + unset($data['shares'][$sIndex]['id']); + } + // Check not just returning source-form (id must differ). + $this->assertGreaterThan($this->testForms[0]['id'], $data['id']); + unset($data['id']); + + // Check general behaviour of hash + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{16}$/', $data['hash']); + unset($data['hash']); + // Check general behaviour of created (Created in the last 10 seconds) + $this->assertTrue(time() - $data['created'] < 10); + unset($data['created']); + // Check general behaviour of lastUpdated (Last update in the last 10 seconds) + $this->assertTrue(time() - $data['lastUpdated'] < 10); + unset($data['lastUpdated']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateFormProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['title'] = 'This is my NEW Title!'; + $fullFormExpected['access'] = [ + 'permitAllUsers' => true, + 'showToAllUsers' => true + ]; + return [ + 'updateFormProps' => [ + 'expected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateFormProperties + * + * @param array $expected + */ + public function testUpdateFormProperties(array $expected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'title' => 'This is my NEW Title!', + 'access' => [ + 'permitAllUsers' => true, + 'showToAllUsers' => true + ] + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + $expected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($expected); + } + + public function testDeleteForm() { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + // Check if not existent anymore. + try { + $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}"); + } catch (ClientException $e) { + $resp = $e->getResponse(); + } + $this->assertEquals(400, $resp->getStatusCode()); + } + + public function dataCreateNewQuestion() { + return [ + 'newQuestion' => [ + 'expected' => [ + // 'formId' => 3, // Checked during test + // 'order' => 3, // Checked during test + 'type' => 'short', + 'isRequired' => false, + 'text' => 'Already some Question?', + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + ] + ], + 'emptyQuestion' => [ + 'expected' => [ + // 'formId' => 3, // Checked during test + // 'order' => 3, // Checked during test + 'type' => 'short', + 'isRequired' => false, + 'text' => '', + 'name' => '', + 'options' => [], + 'accept' => [], + 'description' => '', + 'extraSettings' => [], + ] + ] + ]; + } + /** + * @dataProvider dataCreateNewQuestion + * + * @param array $expected + */ + public function testCreateNewQuestion(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'type' => 'short', + 'text' => $expected['text'] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion on tearDown + $this->testForms[0]['questions'][] = $data; + + // Check formId & order + $this->assertEquals($this->testForms[0]['id'], $data['formId']); + unset($data['formId']); + $this->assertEquals(sizeof($this->testForms[0]['questions']), $data['order']); + unset($data['order']); + unset($data['id']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateQuestionProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][0]['text'] = 'Still first Question!'; + $fullFormExpected['questions'][0]['isRequired'] = false; + + return [ + 'updateQuestionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateQuestionProperties + * + * @param array $fullFormExpected + */ + public function testUpdateQuestionProperties(array $fullFormExpected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'isRequired' => false, + 'text' => 'Still first Question!' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataReorderQuestions() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][0]['order'] = 2; + $fullFormExpected['questions'][1]['order'] = 1; + + // Exchange questions, as they will be returned in new order. + $tmp = $fullFormExpected['questions'][0]; + $fullFormExpected['questions'][0] = $fullFormExpected['questions'][1]; + $fullFormExpected['questions'][1] = $tmp; + + return [ + 'updateQuestionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataReorderQuestions + * + * @param array $fullFormExpected + */ + public function testReorderQuestions(array $fullFormExpected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions", [ + 'json' => [ + 'newOrder' => [ + $this->testForms[0]['questions'][1]['id'], + $this->testForms[0]['questions'][0]['id'], + $this->testForms[0]['questions'][2]['id'], + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals([ + $this->testForms[0]['questions'][0]['id'] => [ 'order' => 2 ], + $this->testForms[0]['questions'][1]['id'] => [ 'order' => 1 ], + $this->testForms[0]['questions'][2]['id'] => [ 'order' => 3 ], + ], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteQuestion() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['questions'], 0, 1); + $fullFormExpected['questions'][0]['order'] = 1; + $fullFormExpected['questions'][1]['order'] = 2; + + return [ + 'deleteQuestion' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteQuestion + * + * @param array $fullFormExpected + */ + public function testDeleteQuestion(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function testCloneQuestion() { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions?fromId=" . $this->testForms[0]['questions'][0]['id']); + $data = $this->OcsResponse2Data($resp); + $this->testForms[0]['questions'][] = $data; + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertNotEquals($data['id'], $this->testForms[0]['questions'][0]['id']); + + $copy = $this->testForms[0]['questions'][0]; + unset($copy['id']); + unset($copy['order']); + foreach ($copy as $key => $value) { + $this->assertEquals($value, $data[$key]); + } + } + + public function dataCreateNewOption() { + return [ + 'newOption' => [ + 'expected' => [ + // 'questionId' => Done dynamically below. + 'text' => 'A new Option.' + ] + ] + ]; + } + /** + * @dataProvider dataCreateNewOption + * + * @param array $expected + */ + public function testCreateNewOption(array $expected): void { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options", [ + 'json' => [ + 'optionTexts' => ['A new Option.'] + ] + ]); + $data = $this->OcsResponse2Data($resp)[0]; + + // Store for deletion on tearDown + $this->testForms[0]['questions'][1]['options'][] = $data; + + // Check questionId + $this->assertEquals($this->testForms[0]['questions'][1]['id'], $data['questionId']); + unset($data['questionId']); + unset($data['id']); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataUpdateOptionProperties() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['questions'][1]['options'][0]['text'] = 'New option Text.'; + + return [ + 'updateOptionProps' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateOptionProperties + * + * @param array $fullFormExpected + */ + public function testUpdateOptionProperties(array $fullFormExpected): void { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options/{$this->testForms[0]['questions'][1]['options'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'text' => 'New option Text.' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][1]['options'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + // Check if form equals updated form. + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteOption() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['questions'][1]['options'], 0, 1); + + return [ + 'deleteOption' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteOption + * + * @param array $fullFormExpected + */ + public function testDeleteOption(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/questions/{$this->testForms[0]['questions'][1]['id']}/options/{$this->testForms[0]['questions'][1]['options'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['questions'][1]['options'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataAddShare() { + return [ + 'addAShare' => [ + 'expected' => [ + // 'formId' => Checked dynamically + 'shareType' => 0, + 'shareWith' => 'test', + 'permissions' => ['submit'], + 'displayName' => 'Test Displayname' + ] + ] + ]; + } + /** + * @dataProvider dataAddShare + * + * @param array $expected + */ + public function testAddShare(array $expected) { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/shares", [ + 'json' => [ + 'shareType' => 0, + 'shareWith' => 'test', + 'permissions' => ['submit'] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + // Store for cleanup + $this->testForms[0]['shares'][] = $data; + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data['formId']); + unset($data['formId']); + unset($data['id']); + $this->assertEquals($expected, $data); + } + + public function dataUpdateShare() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + $fullFormExpected['shares'][0]['permissions'] = [ Constants::PERMISSION_SUBMIT ]; + + return [ + 'deleteShare' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataUpdateShare + * + * @param array $fullFormExpected + */ + public function testUpdateShare(array $fullFormExpected) { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}/shares/{$this->testForms[0]['shares'][0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'permissions' => [ Constants::PERMISSION_SUBMIT ], + ], + ], + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['shares'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataDeleteShare() { + $fullFormExpected = $this->dataGetFullForm()['getFullForm']['expected']; + array_splice($fullFormExpected['shares'], 0, 1); + + return [ + 'deleteShare' => [ + 'fullFormExpected' => $fullFormExpected + ] + ]; + } + /** + * @dataProvider dataDeleteShare + * + * @param array $fullFormExpected + */ + public function testDeleteShare(array $fullFormExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/shares/{$this->testForms[0]['shares'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['shares'][0]['id'], $data); + + $fullFormExpected['lastUpdated'] = time(); + + $this->testGetFullForm($fullFormExpected); + } + + public function dataGetSubmissions() { + return [ + 'getSubmissions' => [ + 'expected' => [ + 'submissions' => [ + [ + // 'formId' => Checked dynamically + 'userId' => 'user1', + 'userDisplayName' => 'User No. 1', + 'timestamp' => 123456, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'This is a short answer.', + 'fileId' => null, + ], + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'Option 1', + 'fileId' => null, + ] + ] + ], + [ + // 'formId' => Checked dynamically + 'userId' => 'user2', + 'userDisplayName' => 'user2', + 'timestamp' => 12345, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'This is another short answer.', + 'fileId' => null, + ], + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => 'Option 2', + 'fileId' => null, + ] + ] + ], + [ + // 'formId' => Checked dynamically + 'userId' => 'user3', + 'userDisplayName' => 'user3', + 'timestamp' => 1234, + 'answers' => [ + [ + // 'submissionId' => Checked dynamically + // 'questionId' => Checked dynamically + 'text' => '', + 'fileId' => null, + ] + ] + ] + ], + 'questions' => $this->dataGetFullForm()['getFullForm']['expected']['questions'] + ] + ] + ]; + } + /** + * @dataProvider dataGetSubmissions + * + * @param array $expected + */ + public function testGetSubmissions(array $expected) { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + // Cannot control ids, but check general consistency. + foreach ($data['submissions'] as $sIndex => $submission) { + $this->assertEquals($this->testForms[0]['id'], $submission['formId']); + unset($data['submissions'][$sIndex]['formId']); + + foreach ($submission['answers'] as $aIndex => $answer) { + $this->assertEquals($submission['id'], $answer['submissionId']); + $this->assertEquals($this->testForms[0]['questions'][ + $this->testForms[0]['submissions'][$sIndex]['answers'][$aIndex]['questionIndex'] + ]['id'], $answer['questionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['submissionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['questionId']); + unset($data['submissions'][$sIndex]['answers'][$aIndex]['id']); + } + unset($data['submissions'][$sIndex]['id']); + } + foreach ($data['questions'] as $qIndex => $question) { + $this->assertEquals($this->testForms[0]['id'], $question['formId']); + unset($data['questions'][$qIndex]['formId']); + + foreach ($question['options'] as $oIndex => $option) { + $this->assertEquals($question['id'], $option['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['questionId']); + unset($data['questions'][$qIndex]['options'][$oIndex]['id']); + } + unset($data['questions'][$qIndex]['id']); + } + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($expected, $data); + } + + public function dataExportSubmissions() { + return [ + 'exportSubmissions' => [ + 'expected' => <<<'CSV' + "User ID","User display name","Timestamp","First Question?","Second Question?","File Question?" + "","Anonymous user","1970-01-01T00:20:34+00:00","","","" + "","Anonymous user","1970-01-01T03:25:45+00:00","This is another short answer.","Option 2","" + "user1","User No. 1","1970-01-02T10:17:36+00:00","This is a short answer.","Option 1","" +CSV + ] + ]; + } + /** + * @dataProvider dataExportSubmissions + * + * @param array $expected + */ + public function testExportSubmissions(string $expected) { + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions?fileFormat=csv"); + $data = substr($resp->getBody()->getContents(), 3); // Some strange Character removed at the beginning + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('attachment; filename="Title of a Form (responses).csv"', $resp->getHeaders()['Content-Disposition'][0]); + $this->assertEquals('text/csv;charset=UTF-8', $resp->getHeaders()['Content-type'][0]); + $arr_txt_expected = preg_split('/,/', str_replace(["\t", "\n"], '', $expected)); + $arr_txt_data = preg_split('/,/', str_replace(["\t", "\n"], '', $data)); + $this->assertEquals($arr_txt_expected, $arr_txt_data); + } + + public function testLinkFile() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'path' => '', + 'fileFormat' => 'csv' + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + } + + public function testUnlinkFile() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[2]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'fileId' => null, + 'fileFormat' => null + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[2]['id'], $data); + } + + public function testExportToCloud() { + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions/export", [ + 'json' => [ + 'path' => '' + ]] + ); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('Title of a Form (responses).csv', $data); + } + + public function dataDeleteSubmissions() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + $submissionsExpected['submissions'] = []; + + return [ + 'deleteSubmissions' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataDeleteSubmissions + * + * @param array $submissionsExpected + */ + public function testDeleteSubmissions(array $submissionsExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['id'], $data); + + $this->testGetSubmissions($submissionsExpected); + } + + public function dataNewSubmission() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + $submissionsExpected['submissions'][] = [ + 'userId' => 'test' + ]; + + return [ + 'insertSubmission' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataNewSubmission + */ + public function testNewSubmission() { + + $uploadedFileResponse = $this->http->request('POST', + "api/v3/forms/{$this->testForms[0]['id']}/submissions/files/{$this->testForms[0]['questions'][2]['id']}", + [ + 'multipart' => [ + [ + 'name' => 'files[]', + 'contents' => 'hello world', + 'filename' => 'test.txt' + ] + ] + ]); + + $data = $this->OcsResponse2Data($uploadedFileResponse); + $uploadedFileId = $data[0]['uploadedFileId']; + + $resp = $this->http->request('POST', "api/v3/forms/{$this->testForms[0]['id']}/submissions", [ + 'json' => [ + 'answers' => [ + $this->testForms[0]['questions'][0]['id'] => ['ShortAnswer!'], + $this->testForms[0]['questions'][1]['id'] => [ + $this->testForms[0]['questions'][1]['options'][0]['id'] + ], + $this->testForms[0]['questions'][2]['id'] => [['uploadedFileId' => $uploadedFileId]] + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + + // Check stored submissions + $resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions"); + $data = $this->OcsResponse2Data($resp); + + // Store for deletion + $this->testForms[0]['submissions'][] = $data['submissions'][0]; + + // Check Ids + foreach ($data['submissions'][0]['answers'] as $aIndex => $answer) { + $this->assertEquals($data['submissions'][0]['id'], $answer['submissionId']); + unset($data['submissions'][0]['answers'][$aIndex]['id']); + unset($data['submissions'][0]['answers'][$aIndex]['submissionId']); + + if (isset($answer['fileId'])) { + $this->assertIsNumeric($answer['fileId'], 'fileId should be numeric.'); + $this->assertGreaterThan(0, $answer['fileId'], 'fileId should be greater than 0.'); + unset($data['submissions'][0]['answers'][$aIndex]['fileId']); + } + } + unset($data['submissions'][0]['id']); + // Check general behaviour of timestamp (Insert in the last 10 seconds) + $this->assertTrue(time() - $data['submissions'][0]['timestamp'] < 10); + unset($data['submissions'][0]['timestamp']); + + $this->assertEquals([ + 'userId' => 'test', + 'userDisplayName' => 'Test Displayname', + 'formId' => $this->testForms[0]['id'], + 'answers' => [ + [ + 'questionId' => $this->testForms[0]['questions'][0]['id'], + 'text' => 'ShortAnswer!', + 'fileId' => null, + ], + [ + 'questionId' => $this->testForms[0]['questions'][1]['id'], + 'text' => 'Option 1', + 'fileId' => null, + ], + [ + 'questionId' => $this->testForms[0]['questions'][2]['id'], + 'text' => 'test.txt', + ], + ] + ], $data['submissions'][0]); + } + + public function dataDeleteSingleSubmission() { + $submissionsExpected = $this->dataGetSubmissions()['getSubmissions']['expected']; + array_splice($submissionsExpected['submissions'], 0, 1); + + return [ + 'deleteSingleSubmission' => [ + 'submissionsExpected' => $submissionsExpected + ] + ]; + } + /** + * @dataProvider dataDeleteSingleSubmission + * + * @param array $submissionsExpected + */ + public function testDeleteSingleSubmission(array $submissionsExpected) { + $resp = $this->http->request('DELETE', "api/v3/forms/{$this->testForms[0]['id']}/submissions/{$this->testForms[0]['submissions'][0]['id']}"); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals($this->testForms[0]['submissions'][0]['id'], $data); + + $this->testGetSubmissions($submissionsExpected); + } + + /** + * Test transfer owner endpoint for form + * + * Keep this test at the end as it might break other tests + */ + public function testTransferOwner() { + $resp = $this->http->request('PATCH', "api/v3/forms/{$this->testForms[0]['id']}", [ + 'json' => [ + 'keyValuePairs' => [ + 'ownerId' => 'user1', + ] + ] + ]); + $data = $this->OcsResponse2Data($resp); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('user1', $data); + } +}; diff --git a/tests/Unit/Controller/ApiControllerTest.php b/tests/Unit/Controller/ApiControllerTest.php index eae6950d2..f9f3c2f7d 100644 --- a/tests/Unit/Controller/ApiControllerTest.php +++ b/tests/Unit/Controller/ApiControllerTest.php @@ -43,8 +43,6 @@ function time($expected = null) { return $value; } -namespace OCA\Forms\Controller; - /** * mock is_uploaded_file() function used in services * @param string|bool|null $filename the value that should be returned when called @@ -113,6 +111,8 @@ class ApiControllerTest extends TestCase { private $formMapper; /** @var OptionMapper|MockObject */ private $optionMapper; + /** @var Question|MockObject */ + private $question; /** @var QuestionMapper|MockObject */ private $questionMapper; /** @var ShareMapper|MockObject */ @@ -144,6 +144,7 @@ public function setUp(): void { $this->answerMapper = $this->createMock(AnswerMapper::class); $this->formMapper = $this->createMock(FormMapper::class); $this->optionMapper = $this->createMock(OptionMapper::class); + $this->question = $this->createMock(Question::class); $this->questionMapper = $this->createMock(QuestionMapper::class); $this->shareMapper = $this->createMock(ShareMapper::class); $this->submissionMapper = $this->createMock(SubmissionMapper::class); @@ -225,22 +226,21 @@ public function throwMockedException(string $class) { public function testGetSubmissions_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willThrowException($exception); - $this->expectException(OCSBadRequestException::class); - $this->apiController->getSubmissions('hash'); + $this->expectException(OCSNotFoundException::class); + $this->apiController->getSubmissions(1); } public function testGetSubmissions_noPermissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -249,7 +249,7 @@ public function testGetSubmissions_noPermissions() { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->getSubmissions('hash'); + $this->apiController->getSubmissions(1); } public function dataGetSubmissions() { @@ -293,12 +293,11 @@ public function dataGetSubmissions() { public function testGetSubmissions(array $submissions, array $questions, array $expected) { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('otherUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -316,28 +315,27 @@ public function testGetSubmissions(array $submissions, array $questions, array $ ->with(1) ->willReturn($questions); - $this->assertEquals(new DataResponse($expected), $this->apiController->getSubmissions('hash')); + $this->assertEquals(new DataResponse($expected), $this->apiController->getSubmissions(1)); } public function testExportSubmissions_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(99) ->willThrowException($exception); $this->expectException(OCSNotFoundException::class); - $this->apiController->exportSubmissions('hash'); + $this->apiController->getSubmissions(99, 'csv'); } public function testExportSubmissions_noPermissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -346,18 +344,17 @@ public function testExportSubmissions_noPermissions() { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->exportSubmissions('hash'); + $this->apiController->getSubmissions(1, 'csv'); } public function testExportSubmissions() { $form = new Form(); $form->setId(1); - $form->setHash('hash'); $form->setOwnerId('currentUser'); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willReturn($form); $this->formsService->expects(($this->once())) @@ -377,17 +374,17 @@ public function testExportSubmissions() { ->with($form, 'csv') ->willReturn($fileName); - $this->assertEquals(new DataDownloadResponse($csv, $fileName, 'text/csv'), $this->apiController->exportSubmissions('hash')); + $this->assertEquals(new DataDownloadResponse($csv, $fileName, 'text/csv'), $this->apiController->getSubmissions(1, 'csv')); } public function testExportSubmissionsToCloud_invalidForm() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) - ->method('findByHash') - ->with('hash') + ->method('findById') + ->with(1) ->willThrowException($exception); $this->expectException(OCSNotFoundException::class); - $this->apiController->exportSubmissionsToCloud('hash', ''); + $this->apiController->exportSubmissionsToCloud(1, ''); } public function testUnlinkFile() { @@ -408,7 +405,7 @@ public function testUnlinkFile() { ->with($form) ->willReturn(true); - $this->apiController->unlinkFile('hash'); + $this->apiController->unlinkFileLegacy('hash'); $this->assertNull($form->getFileId()); $this->assertNull($form->getFileFormat()); @@ -537,7 +534,7 @@ public function testCloneForm_exceptions(bool $canCreate, $callback, string $exc ->with(7) ->willReturnCallback($callback); $this->expectException($exception); - $this->apiController->cloneForm(7); + $this->apiController->newForm(7); } public function dataCloneForm() { @@ -641,7 +638,7 @@ public function testCloneForm($old, $new) { ->method('getForm') ->with(14) ->willReturn(new DataResponse('success')); - $this->assertEquals(new DataResponse('success'), $apiController->cloneForm(7)); + $this->assertEquals(new DataResponse('success'), $apiController->newForm(7)); } private function formAccess(bool $hasUserAccess = true, bool $hasFormExpired = false, bool $canSubmit = true) { @@ -661,7 +658,7 @@ private function formAccess(bool $hasUserAccess = true, bool $hasFormExpired = f public function testCloneQuestion_notFound() { $this->questionMapper->method('findById')->with(42)->willThrowException($this->createMock(IMapperException::class)); $this->expectException(OCSNotFoundException::class); - $this->apiController->cloneQuestion(42); + $this->apiController->cloneQuestionLegacy(42); } public function testCloneQuestion_noPermission() { @@ -670,7 +667,7 @@ public function testCloneQuestion_noPermission() { $this->questionMapper->method('findById')->with(42)->willReturn($question); $this->formMapper->method('findById')->with(1)->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->apiController->cloneQuestion(42); + $this->apiController->cloneQuestionLegacy(42); } public function testUploadFiles() { @@ -678,12 +675,18 @@ public function testUploadFiles() { $form->setId(1); $form->setHash('hash'); $form->setOwnerId('currentUser'); - + $question = Question::fromParams(['formId' => 1]); + $this->formMapper->expects($this->once()) ->method('findById') ->with(1) ->willReturn($form); - + + $this->questionMapper->expects($this->once()) + ->method('findById') + ->with(10) + ->willReturn($question); + $this->request->expects($this->once()) ->method('getUploadedFile') ->with('files') @@ -739,7 +742,7 @@ public function testUploadFiles() { $this->apiController->uploadFiles(1, 10, ''); } - public function testInsertSubmission_answers() { + public function testNewSubmission_answers() { $form = new Form(); $form->setId(1); $form->setHash('hash'); @@ -876,17 +879,17 @@ public function testInsertSubmission_answers() { ->with('admin') ->willReturn($userFolder); - $this->apiController->insertSubmission(1, $answers, ''); + $this->apiController->newSubmission(1, $answers, ''); } - public function testInsertSubmission_formNotFound() { + public function testNewSubmission_formNotFound() { $exception = $this->createMock(MapperException::class); $this->formMapper->expects($this->once()) ->method('findById') ->with(1) ->willThrowException($exception); $this->expectException(OCSBadRequestException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } /** @@ -903,7 +906,7 @@ public function dataForCheckForbiddenException() { /** * @dataProvider dataForCheckForbiddenException() */ - public function testInsertSubmission_forbiddenException($hasUserAccess, $hasFormExpired, $canSubmit) { + public function testNewSubmission_forbiddenException($hasUserAccess, $hasFormExpired, $canSubmit) { $form = new Form(); $form->setId(1); $form->setOwnerId('admin'); @@ -921,10 +924,10 @@ public function testInsertSubmission_forbiddenException($hasUserAccess, $hasForm $this->expectException(OCSForbiddenException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } - public function testInsertSubmission_validateSubmission() { + public function testNewSubmission_validateSubmission() { $form = new Form(); $form->setId(1); $form->setOwnerId('admin'); @@ -947,7 +950,7 @@ public function testInsertSubmission_validateSubmission() { $this->expectException(OCSBadRequestException::class); - $this->apiController->insertSubmission(1, [], ''); + $this->apiController->newSubmission(1, [], ''); } public function testDeleteSubmissionNotFound() { @@ -960,7 +963,7 @@ public function testDeleteSubmissionNotFound() { ->willThrowException($exception); $this->expectException(OCSBadRequestException::class); - $this->apiController->deleteSubmission(42); + $this->apiController->deleteSubmission(1, 42); } /** @@ -987,7 +990,7 @@ public function testDeleteSubmissionNoPermission($submissionData, $formData) { ->willReturn(false); $this->expectException(OCSForbiddenException::class); - $this->apiController->deleteSubmission(42); + $this->apiController->deleteSubmission(1, 42); } /** @@ -1023,7 +1026,7 @@ public function testDeleteSubmission($submissionData, $formData) { ->method('setLastUpdatedTimestamp') ->with($formData['id']); - $this->assertEquals(new DataResponse(42), $this->apiController->deleteSubmission(42)); + $this->assertEquals(new DataResponse(42), $this->apiController->deleteSubmission(1, 42)); } public function dataTestDeletePermission() { @@ -1063,7 +1066,7 @@ public function testTransferOwnerNotOwner() { ->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->apiController->transferOwner(1, 'newOwner'); + $this->apiController->transferOwnerLegacy(1, 'newOwner'); } public function testTransferNewOwnerNotFound() { @@ -1083,7 +1086,7 @@ public function testTransferNewOwnerNotFound() { ->willReturn(null); $this->expectException(OCSBadRequestException::class); - $this->apiController->transferOwner(1, 'newOwner'); + $this->apiController->transferOwnerLegacy(1, 'newOwner'); } public function testTransferOwner() { @@ -1103,7 +1106,7 @@ public function testTransferOwner() { ->with('newOwner') ->willReturn($newOwner); - $this->assertEquals(new DataResponse('newOwner'), $this->apiController->transferOwner(1, 'newOwner')); + $this->assertEquals(new DataResponse('newOwner'), $this->apiController->transferOwnerLegacy(1, 'newOwner')); $this->assertEquals('newOwner', $form->getOwnerId()); } } diff --git a/tests/Unit/Controller/ShareApiControllerTest.php b/tests/Unit/Controller/ShareApiControllerTest.php index 50042ae27..005cd5ac7 100644 --- a/tests/Unit/Controller/ShareApiControllerTest.php +++ b/tests/Unit/Controller/ShareApiControllerTest.php @@ -551,7 +551,7 @@ public function testDeleteShare() { ->with('8'); $response = new DataResponse(8); - $this->assertEquals($response, $this->shareApiController->deleteShare(8)); + $this->assertEquals($response, $this->shareApiController->deleteShare(5, 8)); } /** @@ -565,7 +565,7 @@ public function testDeleteUnknownShare() { ; $this->expectException(OCSBadRequestException::class); - $this->shareApiController->deleteShare(8); + $this->shareApiController->deleteShare(1, 8); } /** @@ -589,7 +589,7 @@ public function testDeleteForeignShare() { ->willReturn($form); $this->expectException(OCSForbiddenException::class); - $this->shareApiController->deleteShare(8); + $this->shareApiController->deleteShare(5, 8); } public function dataUpdateShare() { @@ -824,10 +824,10 @@ public function testUpdateShare(array $share, string $formOwner, array $keyValue if ($exception === null) { $expectedResponse = new DataResponse($expected); - $this->assertEquals($expectedResponse, $this->shareApiController->updateShare($share['id'], $keyValuePairs)); + $this->assertEquals($expectedResponse, $this->shareApiController->updateShare($share['formId'], $share['id'], $keyValuePairs)); } else { $this->expectException($exception); - $this->shareApiController->updateShare($share['id'], $keyValuePairs); + $this->shareApiController->updateShare($share['formId'], $share['id'], $keyValuePairs); } } @@ -846,7 +846,7 @@ public function testUpdateShare_NotExistingShare() { ->method('debug'); $this->expectException(OCSBadRequestException::class); - $this->shareApiController->updateShare(1337, [Constants::PERMISSION_SUBMIT]); + $this->shareApiController->updateShare(1, 1337, [Constants::PERMISSION_SUBMIT]); } /** @@ -876,6 +876,6 @@ public function testUpdateShare_NotExistingForm() { ->method('debug'); $this->expectException(OCSBadRequestException::class); - $this->shareApiController->updateShare(1337, [Constants::PERMISSION_SUBMIT]); + $this->shareApiController->updateShare(7331, 1337, [Constants::PERMISSION_SUBMIT]); } }