Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 494
0.00% covered (danger)
0.00%
0 / 50
CRAP
0.00% covered (danger)
0.00%
0 / 1
taoQtiTest_actions_Runner
0.00% covered (danger)
0.00%
0 / 494
0.00% covered (danger)
0.00%
0 / 50
20022
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getStorageManager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 returnJson
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getSessionId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getServiceContext
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 validateSecurityToken
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getErrorResponse
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
156
 getStatusCodeFromException
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
132
 init
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getTestData
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getTestContext
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getTestMap
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getItem
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 getNextItemData
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getItemData
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 saveItemState
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 endItemTimer
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 saveItemResponses
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
110
 submitItem
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 move
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 skip
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 timeout
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 exitTest
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 shouldTimerStopOnPause
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 pause
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 resume
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 flagItem
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 comment
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 storeTraceData
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 up
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 messages
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 validateDeliveryExecutionInteractionAccessibility
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getRunnerService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRawRequestParameter
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getInitResponse
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getClientStoreId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getInitSerializedResponse
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeliveryExecutionService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRuntimeService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPauseService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getItemDuration
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getItemState
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getItemResponse
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setNavigationContextToCommand
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setItemContextToCommand
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 setToolsStateContextToCommand
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createErrorResponseFromException
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 checkExceptionForTestSessionConflict
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getConcurringSessionService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2016-2023 (original work) Open Assessment Technologies SA.
19 */
20
21/**
22 * @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
23 *
24 * @noinspection AutoloadingIssuesInspection
25 */
26
27use oat\libCat\exception\CatEngineConnectivityException;
28use oat\taoDelivery\model\execution\DeliveryExecutionInterface;
29use oat\taoDelivery\model\execution\DeliveryExecutionService;
30use oat\taoDelivery\model\RuntimeService;
31use oat\taoQtiTest\model\Service\ConcurringSessionService;
32use oat\taoQtiTest\model\Service\ExitTestCommand;
33use oat\taoQtiTest\model\Service\ExitTestService;
34use oat\taoQtiTest\model\Service\ItemContextAwareInterface;
35use oat\taoQtiTest\model\Service\ListItemsQuery;
36use oat\taoQtiTest\model\Service\ListItemsService;
37use oat\taoQtiTest\model\Service\MoveCommand;
38use oat\taoQtiTest\model\Service\MoveService;
39use oat\taoQtiTest\model\Service\NavigationContextAwareInterface;
40use oat\taoQtiTest\model\Service\PauseCommand;
41use oat\taoQtiTest\model\Service\PauseService;
42use oat\taoQtiTest\model\Service\SkipCommand;
43use oat\taoQtiTest\model\Service\SkipService;
44use oat\taoQtiTest\model\Service\StoreTraceVariablesService;
45use oat\taoQtiTest\model\Service\StoreTraceVariablesCommand;
46use oat\taoQtiTest\model\Service\TimeoutCommand;
47use oat\taoQtiTest\model\Service\TimeoutService;
48use oat\taoQtiTest\model\Service\ToolsStateAwareInterface;
49use oat\taoQtiTest\models\cat\CatEngineNotFoundException;
50use oat\taoQtiTest\models\classes\runner\QtiRunnerInvalidResponsesException;
51use oat\taoQtiTest\models\container\QtiTestDeliveryContainer;
52use oat\taoQtiTest\models\runner\communicator\CommunicationService;
53use oat\taoQtiTest\models\runner\communicator\QtiCommunicationService;
54use oat\taoQtiTest\models\runner\QtiRunnerClosedException;
55use oat\taoQtiTest\models\runner\QtiRunnerEmptyResponsesException;
56use oat\taoQtiTest\models\runner\QtiRunnerItemResponseException;
57use oat\taoQtiTest\models\runner\QtiRunnerMessageService;
58use oat\taoQtiTest\models\runner\QtiRunnerPausedException;
59use oat\taoQtiTest\models\runner\QtiRunnerService;
60use oat\taoQtiTest\models\runner\QtiRunnerServiceContext;
61use oat\taoQtiTest\models\runner\RunnerToolStates;
62use oat\taoQtiTest\models\runner\StorageManager;
63use qtism\runtime\tests\AssessmentTestSessionState;
64use taoQtiTest_helpers_TestRunnerUtils as TestRunnerUtils;
65use oat\oatbox\session\SessionService;
66
67/**
68 * Class taoQtiTest_actions_Runner
69 *
70 * Serves QTI implementation of the test runner
71 */
72class taoQtiTest_actions_Runner extends tao_actions_ServiceModule
73{
74    use RunnerToolStates;
75
76    /**
77     * The current test session
78     * @var QtiRunnerServiceContext
79     */
80    protected $serviceContext;
81
82    /**
83     * taoQtiTest_actions_Runner constructor.
84     * @security("hide");
85     */
86    public function __construct()
87    {
88        parent::__construct();
89
90        // Prevent anything to be cached by the client.
91        TestRunnerUtils::noHttpClientCache();
92    }
93
94    /**
95     * @return StorageManager
96     */
97    protected function getStorageManager()
98    {
99        /** @noinspection PhpIncompatibleReturnTypeInspection */
100        return $this->getServiceLocator()->get(StorageManager::SERVICE_ID);
101    }
102
103    /**
104     * @param $data
105     * @param int [$httpStatus]
106     * @param bool [$token]
107     */
108    protected function returnJson($data, $httpStatus = 200)
109    {
110        try {
111            // auto append platform messages, if any
112            if ($this->serviceContext && !isset($data['messages'])) {
113                /* @var $communicationService CommunicationService */
114                $communicationService = $this->getServiceManager()->get(QtiCommunicationService::SERVICE_ID);
115                $data['messages'] = $communicationService->processOutput($this->serviceContext);
116            }
117
118            // ensure the state storage is properly updated
119            $this->getStorageManager()->persist();
120        } catch (common_Exception $e) {
121            $data = $this->getErrorResponse($e);
122            $httpStatus = $this->getStatusCodeFromException($e);
123        }
124
125        // Applies status code to the response object
126        $this->response = $this->getPsrResponse()
127            ->withStatus($httpStatus);
128
129        return parent::returnJson($data, $httpStatus);
130    }
131
132    /**
133     * Gets the identifier of the test session
134     * @return string
135     */
136    protected function getSessionId()
137    {
138        if ($this->hasRequestParameter('testServiceCallId')) {
139            return $this->getRequestParameter('testServiceCallId');
140        }
141
142        return $this->getRequestParameter('serviceCallId');
143    }
144
145    /**
146     * Gets the test service context
147     * @return QtiRunnerServiceContext
148     * @throws common_Exception
149     */
150    protected function getServiceContext()
151    {
152        if (!$this->serviceContext) {
153            $testExecution = $this->getSessionId();
154            $execution = $this->getDeliveryExecutionService()->getDeliveryExecution($testExecution);
155            if (!$execution) {
156                throw new common_exception_ResourceNotFound();
157            }
158
159            $currentUser = $this->getSessionService()->getCurrentUser();
160            if (!$currentUser || $execution->getUserIdentifier() !== $currentUser->getIdentifier()) {
161                throw new common_exception_Unauthorized($execution->getUserIdentifier());
162            }
163
164            $delivery = $execution->getDelivery();
165            $container = $this->getRuntimeService()->getDeliveryContainer($delivery->getUri());
166            if (!$container instanceof QtiTestDeliveryContainer) {
167                throw new common_Exception(
168                    'Non QTI test container ' . get_class($container) . ' in qti test runner'
169                );
170            }
171            $testDefinition = $container->getSourceTest($execution);
172            $testCompilation = $container->getPrivateDirId($execution) . '|' . $container->getPublicDirId($execution);
173
174            $this->serviceContext = $this->getRunnerService()->getServiceContext(
175                $testDefinition,
176                $testCompilation,
177                $testExecution
178            );
179        }
180
181        return $this->serviceContext;
182    }
183
184    /**
185     * Checks the security token.
186     * @throws common_Exception
187     * @throws common_exception_Error
188     * @throws common_exception_Unauthorized
189     * @throws common_ext_ExtensionException
190     */
191    protected function validateSecurityToken(): void
192    {
193        $config = $this->getRunnerService()->getTestConfig()->getConfigValue('security');
194
195        $isCsrfValidationRequired = (bool)($config['csrfToken'] ?? false);
196
197        if (!$isCsrfValidationRequired) {
198            return;
199        }
200
201        $this->validateCsrf();
202    }
203
204    /**
205     * Gets an error response object
206     * @param Exception [$e] Optional exception from which extract the error context
207     * @param array $prevResponse Response before catch
208     * @return array
209     */
210    protected function getErrorResponse($e = null, $prevResponse = [])
211    {
212        $this->logError($e->getMessage());
213
214        $response = [
215            'success' => false,
216            'type' => 'error',
217        ];
218
219        if ($e) {
220            if ($e instanceof Exception) {
221                $response['type'] = 'exception';
222                $response['code'] = $e->getCode();
223            }
224
225            if ($e instanceof common_exception_UserReadableException) {
226                $response['message'] = $e->getUserMessage();
227            } else {
228                $response['message'] = __('Internal server error!');
229            }
230
231            switch (true) {
232                case $e instanceof CatEngineConnectivityException:
233                case $e instanceof CatEngineNotFoundException:
234                    $response = array_merge($response, $prevResponse);
235                    $response['type'] = 'catEngine';
236                    $response['code'] = 200;
237                    $response['testMap'] = [];
238                    $response['message'] = $e->getMessage();
239                    break;
240                case $e instanceof QtiRunnerClosedException:
241                case $e instanceof QtiRunnerPausedException:
242                    if ($this->serviceContext) {
243                        /** @var QtiRunnerMessageService $messageService */
244                        $messageService = $this->getServiceManager()->get(QtiRunnerMessageService::SERVICE_ID);
245                        try {
246                            // phpcs:disable Generic.Files.LineLength
247                            $response['message'] = __($messageService->getStateMessage($this->serviceContext->getTestSession()));
248                            // phpcs:enable Generic.Files.LineLength
249                        } catch (common_exception_Error $e) {
250                            $response['message'] = null;
251                        }
252                    }
253                    $response['type'] = 'TestState';
254                    break;
255
256                case $e instanceof tao_models_classes_FileNotFoundException:
257                    $response['type'] = 'FileNotFound';
258                    $response['message'] = __('File not found');
259                    break;
260
261                case $e instanceof common_exception_Unauthorized:
262                    $response['code'] = 403;
263                    break;
264            }
265        }
266
267        return $response;
268    }
269
270    protected function getStatusCodeFromException(Exception $exception): int
271    {
272        switch (get_class($exception)) {
273            case CatEngineConnectivityException::class:
274            case CatEngineNotFoundException::class:
275            case QtiRunnerEmptyResponsesException::class:
276            case QtiRunnerClosedException::class:
277            case QtiRunnerPausedException::class:
278            case QtiRunnerInvalidResponsesException::class:
279                return 200;
280
281            case common_exception_NotImplemented::class:
282            case common_exception_NoImplementation::class:
283            case common_exception_Unauthorized::class:
284                return 403;
285
286            case tao_models_classes_FileNotFoundException::class:
287                return 404;
288        }
289
290        return 500;
291    }
292
293    /**
294     * Initializes the delivery session
295     * @throws common_Exception
296     */
297    public function init()
298    {
299        $this->validateSecurityToken();
300
301        try {
302            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
303            $this->returnJson($this->getInitResponse($serviceContext));
304        } catch (Exception $e) {
305            $this->returnJson(
306                $this->getErrorResponse($e),
307                $this->getStatusCodeFromException($e)
308            );
309        }
310    }
311
312    /**
313     * Provides the test definition data
314     *
315     * @deprecated
316     */
317    public function getTestData()
318    {
319        $code = 200;
320
321        try {
322            $this->validateSecurityToken();
323            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
324
325            $response = [
326                'testData' => $this->getRunnerService()->getTestData($serviceContext),
327                'success' => true,
328            ];
329        } catch (common_Exception $e) {
330            $response = $this->getErrorResponse($e);
331            $code = $this->getStatusCodeFromException($e);
332        }
333
334        $this->returnJson($response, $code);
335    }
336
337    /**
338     * Provides the test context object
339     */
340    public function getTestContext()
341    {
342        $code = 200;
343
344        try {
345            $this->validateSecurityToken();
346            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
347
348            $response = [
349                'testContext' => $this->getRunnerService()->getTestContext($serviceContext),
350                'success' => true,
351            ];
352        } catch (common_Exception $e) {
353            $response = $this->getErrorResponse($e);
354            $code = $this->getStatusCodeFromException($e);
355        }
356
357        $this->returnJson($response, $code);
358    }
359
360    /**
361     * Provides the map of the test items
362     */
363    public function getTestMap()
364    {
365        $code = 200;
366
367        try {
368            $this->validateSecurityToken();
369            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
370
371            $response = [
372                'testMap' => $this->getRunnerService()->getTestMap($serviceContext),
373                'success' => true,
374            ];
375        } catch (common_Exception $e) {
376            $response = $this->getErrorResponse($e);
377            $code = $this->getStatusCodeFromException($e);
378        }
379
380        $this->returnJson($response, $code);
381    }
382
383    /**
384     * Provides the definition data and the state for a particular item
385     */
386    public function getItem()
387    {
388        $code = 200;
389
390        $itemIdentifier = $this->getRequestParameter('itemDefinition');
391
392        try {
393            $serviceContext = $this->getServiceContext();
394
395            //load item data
396            $response = $this->getItemData($itemIdentifier);
397
398            if (is_array($response)) {
399                $response['success'] = true;
400            } else {
401                // Build an appropriate failure response.
402                $response = [];
403                $response['success'] = false;
404
405                $userIdentifier = common_session_SessionManager::getSession()->getUser()->getIdentifier();
406                common_Logger::e(
407                    "Unable to retrieve item with identifier '${itemIdentifier}' for user '${userIdentifier}'."
408                );
409            }
410
411            $this->getRunnerService()->startTimer($serviceContext);
412        } catch (common_Exception $e) {
413            $userIdentifier = common_session_SessionManager::getSession()->getUser()->getIdentifier();
414            $msg = __CLASS__ . "::getItem(): Unable to retrieve item with identifier '${itemIdentifier}' for "
415                . "user '${userIdentifier}'.\n";
416            $msg .= "Exception of type '" . get_class($e) . "' was thrown in '" . $e->getFile() . "' l." . $e->getLine()
417                . " with message '" . $e->getMessage() . "'.";
418
419            if ($e instanceof common_exception_Unauthorized) {
420                // Log as debug as not being authorized is not a "real" system error.
421                common_Logger::d($msg);
422            } else {
423                common_Logger::e($msg);
424            }
425
426            $response = $this->getErrorResponse($e);
427            $code = $this->getStatusCodeFromException($e);
428        }
429
430        $this->returnJson($response, $code);
431    }
432
433    /**
434     * Provides the definition data and the state for a list of items
435     */
436    public function getNextItemData()
437    {
438        $itemIdentifiers = $this->getRequestParameter('itemDefinition');
439
440        if (!is_array($itemIdentifiers)) {
441            $itemIdentifiers = [$itemIdentifiers];
442        }
443
444        try {
445            $query = new ListItemsQuery(
446                $this->getServiceContext(),
447                $itemIdentifiers
448            );
449
450            /** @var ListItemsService $listItems */
451            $listItems = $this->getPsrContainer()->get(ListItemsService::class);
452
453            $response = $listItems($query);
454
455            $this->returnJson($response->toArray());
456        } catch (common_Exception $e) {
457            $this->returnJson(
458                $this->getErrorResponse($e),
459                $this->getStatusCodeFromException($e)
460            );
461        }
462    }
463
464    /**
465     * Create the item definition response for a given item
466     * @param string $itemIdentifier the item id
467     * @return array the item data
468     * @throws common_Exception
469     * @throws common_exception_Error
470     * @throws common_exception_InvalidArgumentType
471     */
472    protected function getItemData($itemIdentifier)
473    {
474        $serviceContext = $this->getServiceContext();
475        $itemRef        = $this->getRunnerService()->getItemHref($serviceContext, $itemIdentifier);
476        $itemData       = $this->getRunnerService()->getItemData($serviceContext, $itemRef);
477        $baseUrl        = $this->getRunnerService()->getItemPublicUrl($serviceContext, $itemRef);
478        $portableElements = $this->getRunnerService()->getItemPortableElements($serviceContext, $itemRef);
479
480        $itemState = $this->getRunnerService()->getItemState($serviceContext, $itemIdentifier);
481        if (is_null($itemState) || !count($itemState)) {
482            $itemState = new stdClass();
483        }
484
485        return [
486            'baseUrl'        => $baseUrl,
487            'itemData'       => $itemData,
488            'itemState'      => $itemState,
489            'itemIdentifier' => $itemIdentifier,
490            'portableElements' => $portableElements
491        ];
492    }
493
494    /**
495     * Save the actual item state.
496     * Requires params itemIdentifier and itemState
497     * @return boolean true if saved
498     * @throws common_Exception
499     */
500    protected function saveItemState()
501    {
502        if ($this->hasRequestParameter('itemDefinition') && $this->hasRequestParameter('itemState')) {
503            $serviceContext = $this->getServiceContext();
504            $itemIdentifier = $this->getRequestParameter('itemDefinition');
505
506            return $this->getRunnerService()->setItemState($serviceContext, $itemIdentifier, $this->getItemState());
507        }
508        return false;
509    }
510
511    /**
512     * End the item timer and save the duration
513     * Requires params itemDuration and optionaly consumedExtraTime
514     * @return boolean true if saved
515     * @throws common_Exception
516     */
517    protected function endItemTimer()
518    {
519        if ($this->hasRequestParameter('itemDuration')) {
520            $serviceContext    = $this->getServiceContext();
521            $itemDuration      = $this->getRequestParameter('itemDuration');
522            return $this->getRunnerService()->endTimer($serviceContext, $itemDuration);
523        }
524        return false;
525    }
526
527    /**
528     * Save the item responses
529     * Requires params itemDuration and optionally consumedExtraTime
530     * @param boolean $emptyAllowed if we allow empty responses
531     * @return boolean true if saved
532     * @throws common_Exception
533     * @throws QtiRunnerEmptyResponsesException if responses are empty, emptyAllowed is false and no allowSkipping
534     */
535    protected function saveItemResponses($emptyAllowed = true)
536    {
537        if ($this->hasRequestParameter('itemDefinition') && $this->hasRequestParameter('itemResponse')) {
538            $itemIdentifier = $this->getRequestParameter('itemDefinition');
539            $serviceContext = $this->getServiceContext();
540            $itemDefinition = $this->getRunnerService()->getItemHref($serviceContext, $itemIdentifier);
541
542            //to read JSON encoded params
543            $itemResponse = $this->getItemResponse();
544
545            if ($serviceContext->getCurrentAssessmentItemRef()->getIdentifier() !== $itemIdentifier) {
546                throw new QtiRunnerItemResponseException(
547                    __('Item response identifier does not match current item')
548                );
549            }
550
551            if (!is_null($itemResponse) && !empty($itemDefinition)) {
552                $responses = $this->getRunnerService()->parsesItemResponse(
553                    $serviceContext,
554                    $itemDefinition,
555                    $itemResponse
556                );
557
558                //still verify allowSkipping & empty responses
559                if (
560                    !$emptyAllowed &&
561                    $this->getRunnerService()->getTestConfig()->getConfigValue('enableAllowSkipping') &&
562                    !TestRunnerUtils::doesAllowSkipping($serviceContext->getTestSession())
563                ) {
564                    if ($this->getRunnerService()->emptyResponse($serviceContext, $responses)) {
565                        throw new QtiRunnerEmptyResponsesException();
566                    }
567                }
568
569                return $this->getRunnerService()->storeItemResponse($serviceContext, $itemDefinition, $responses);
570            }
571        }
572        return false;
573    }
574
575    /**
576     * Stores the state object and the response set of a particular item
577     */
578    public function submitItem()
579    {
580        $code = 200;
581        $successState = false;
582
583        try {
584            // get the service context, but do not perform the test state check,
585            // as we need to store the item state whatever the test state is
586            $this->validateSecurityToken();
587            $serviceContext = $this->getServiceContext();
588            $itemRef = $this->getRunnerService()->getItemHref(
589                $serviceContext,
590                $this->getRequestParameter('itemDefinition')
591            );
592
593            if (!$this->getRunnerService()->isTerminated($serviceContext)) {
594                $this->endItemTimer();
595                $successState = $this->saveItemState();
596            }
597
598            $this->getRunnerService()->initServiceContext($serviceContext);
599
600            $successResponse = $this->saveItemResponses(false);
601            $displayFeedback = $this->getRunnerService()->displayFeedbacks($serviceContext);
602
603            $response = [
604                'success' => $successState && $successResponse,
605                'displayFeedbacks' => $displayFeedback
606            ];
607
608            if ($displayFeedback == true) {
609                $response['feedbacks'] = $this->getRunnerService()->getFeedbacks($serviceContext, $itemRef);
610                $response['itemSession'] = $this->getRunnerService()->getItemSession($serviceContext);
611            }
612
613            $this->getRunnerService()->persist($serviceContext);
614        } catch (common_Exception $e) {
615            $response = $this->getErrorResponse($e);
616            $code = $this->getStatusCodeFromException($e);
617        }
618
619        $this->returnJson($response, $code);
620    }
621
622    /**
623     * Moves the current position to the provided scoped reference: item, section, part
624     *
625     * This action is called when the user sends a test response, but not when
626     * the test starts.
627     */
628    public function move()
629    {
630        try {
631            $this->validateSecurityToken();
632
633            $moveCommand = new MoveCommand(
634                $this->getServiceContext(),
635                $this->hasRequestParameter('start')
636            );
637
638            $this->setNavigationContextToCommand($moveCommand);
639            $this->setItemContextToCommand($moveCommand);
640            $this->setToolsStateContextToCommand($moveCommand);
641
642            /** @var MoveService $moveService */
643            $moveService = $this->getPsrContainer()->get(MoveService::class);
644
645            $response = $moveService($moveCommand);
646
647            $this->returnJson($response->toArray());
648        } catch (common_Exception $e) {
649            $this->checkExceptionForTestSessionConflict($e);
650
651            $this->returnJson(
652                $this->getErrorResponse($e),
653                $this->getStatusCodeFromException($e)
654            );
655        }
656    }
657
658    /**
659     * Skip the current position to the provided scope: item, section, part
660     */
661    public function skip()
662    {
663        try {
664            $this->validateSecurityToken();
665
666            $command = new SkipCommand(
667                $this->getServiceContext(),
668                $this->hasRequestParameter('start')
669            );
670
671            $this->setNavigationContextToCommand($command);
672            $this->setItemContextToCommand($command);
673            $this->setToolsStateContextToCommand($command);
674
675            /** @var SkipService $skip */
676            $skip = $this->getPsrContainer()->get(SkipService::class);
677
678            $response = $skip($command);
679
680            $this->returnJson($response->toArray());
681        } catch (common_Exception $e) {
682            $this->checkExceptionForTestSessionConflict($e);
683
684            $this->returnJson(
685                $this->getErrorResponse($e),
686                $this->getStatusCodeFromException($e)
687            );
688        }
689    }
690
691    /**
692     * Handles a test timeout
693     */
694    public function timeout()
695    {
696        try {
697            $this->validateSecurityToken();
698            $this->validateDeliveryExecutionInteractionAccessibility();
699
700            $command = new TimeoutCommand(
701                $this->getServiceContext(),
702                $this->hasRequestParameter('start'),
703                $this->hasRequestParameter('late')
704            );
705
706            $this->setNavigationContextToCommand($command);
707            $this->setItemContextToCommand($command);
708            $this->setToolsStateContextToCommand($command);
709
710            /** @var TimeoutService $timeout */
711            $timeout = $this->getPsrContainer()->get(TimeoutService::class);
712
713            $response = $timeout($command);
714
715            $this->returnJson($response->toArray());
716        } catch (common_Exception $e) {
717            $this->checkExceptionForTestSessionConflict($e);
718
719            $this->returnJson(
720                $this->getErrorResponse($e),
721                $this->getStatusCodeFromException($e)
722            );
723        }
724    }
725
726    /**
727     * Exits the test before its end
728     */
729    public function exitTest()
730    {
731        try {
732            $this->validateSecurityToken();
733
734            $command = new ExitTestCommand($this->getServiceContext());
735
736            $this->setNavigationContextToCommand($command);
737            $this->setItemContextToCommand($command);
738            $this->setToolsStateContextToCommand($command);
739
740            /** @var ExitTestService $exitTest */
741            $exitTest = $this->getPsrContainer()->get(ExitTestService::class);
742
743            $response = $exitTest($command);
744
745            $this->returnJson($response->toArray());
746        } catch (common_Exception $e) {
747            $this->returnJson(
748                $this->getErrorResponse($e),
749                $this->getStatusCodeFromException($e)
750            );
751        }
752    }
753
754    /**
755     * @param  bool  $isTerminated
756     * @return bool
757     * @throws common_Exception
758     * @throws common_ext_ExtensionException
759     */
760    private function shouldTimerStopOnPause(bool $isTerminated)
761    {
762        if (!$isTerminated) {
763            $timerTarget = $this->getRunnerService()->getTestConfig()->getConfigValue('timer.target');
764            if ($timerTarget === 'client') {
765                return  true;
766            }
767        }
768        return false;
769    }
770
771    /**
772     * Sets the test in paused state
773     */
774    public function pause()
775    {
776        try {
777            $this->validateSecurityToken();
778            $this->validateDeliveryExecutionInteractionAccessibility();
779
780            $command = new PauseCommand($this->getServiceContext());
781
782            $this->setItemContextToCommand($command);
783
784            $response = $this->getPauseService()($command);
785
786            $this->returnJson($response->toArray());
787        } catch (common_Exception $e) {
788            $this->createErrorResponseFromException($e);
789        }
790    }
791
792    /**
793     * Resumes the test from paused state
794     */
795    public function resume()
796    {
797        $code = 200;
798
799        try {
800            $this->validateSecurityToken();
801            /** @var QtiRunnerServiceContext $serviceContext */
802            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
803            $result = $this->getRunnerService()->resume($serviceContext);
804
805            $response = [
806                'success' => $result,
807            ];
808
809            if ($result) {
810                $response['testContext'] = $this->getRunnerService()->getTestContext($serviceContext);
811
812                if ($serviceContext->containsAdaptive()) {
813                    // Force map update.
814                    $response['testMap'] = $this->getRunnerService()->getTestMap($serviceContext, true);
815                }
816            }
817
818            $this->getRunnerService()->persist($serviceContext);
819        } catch (common_Exception $e) {
820            $response = $this->getErrorResponse($e);
821            $code = $this->getStatusCodeFromException($e);
822        }
823
824        $this->returnJson($response, $code);
825    }
826
827    /**
828     * Flag an item
829     */
830    public function flagItem()
831    {
832        $code = 200;
833
834        try {
835            $this->validateSecurityToken();
836            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
837            $testSession = $serviceContext->getTestSession();
838
839            if ($this->hasRequestParameter('position')) {
840                $itemPosition = intval($this->getRequestParameter('position'));
841            } else {
842                $itemPosition = $testSession->getRoute()->getPosition();
843            }
844
845            if ($this->hasRequestParameter('flag')) {
846                $flag = $this->getRequestParameter('flag');
847                if (is_numeric($flag)) {
848                    $flag = (bool)(int)$flag;
849                } else {
850                    $flag = 'false' !== strtolower($flag);
851                }
852            } else {
853                $flag = true;
854            }
855
856            TestRunnerUtils::setItemFlag($testSession, $itemPosition, $flag, $serviceContext);
857
858            $response = [
859                'success' => true,
860            ];
861        } catch (common_Exception $e) {
862            $response = $this->getErrorResponse($e);
863            $code = $this->getStatusCodeFromException($e);
864        }
865
866        $this->returnJson($response, $code);
867    }
868
869    /**
870     * Comment the test
871     */
872    public function comment()
873    {
874        $code = 200;
875
876        $comment = $this->getRequestParameter('comment');
877
878        try {
879            $this->validateSecurityToken();
880            $serviceContext = $this->getRunnerService()->initServiceContext($this->getServiceContext());
881            $result = $this->getRunnerService()->comment($serviceContext, $comment);
882
883            $response = [
884                'success' => $result,
885            ];
886        } catch (common_Exception $e) {
887            $response = $this->getErrorResponse($e);
888            $code = $this->getStatusCodeFromException($e);
889        }
890
891        $this->returnJson($response, $code);
892    }
893
894    /**
895     * allow client to store information about the test, the section or the item
896     */
897    public function storeTraceData()
898    {
899        try {
900            $this->validateSecurityToken();
901
902            $traceVariables = json_decode(
903                html_entity_decode($this->getRequestParameter('traceData')),
904                true
905            );
906
907            $command = new StoreTraceVariablesCommand(
908                $this->getServiceContext(),
909                $traceVariables,
910                $this->getRequestParameter('itemDefinition') ?: null
911            );
912
913            /** @var StoreTraceVariablesService $storeTraceVariables */
914            $storeTraceVariables = $this->getPsrContainer()->get(StoreTraceVariablesService::class);
915
916            $response = $storeTraceVariables($command);
917
918            common_Logger::d('Stored ' . count($traceVariables) . ' trace variables');
919
920            $this->returnJson($response->toArray());
921        } catch (common_Exception $e) {
922            $this->returnJson(
923                $this->getErrorResponse($e),
924                $this->getStatusCodeFromException($e)
925            );
926        }
927    }
928
929    /**
930     * The smallest telemetry signal,
931     * just to know the server is up.
932     */
933    public function up()
934    {
935        $this->returnJson([
936            'success' => true
937        ], 200);
938    }
939
940    /**
941     * Manage the bidirectional communication
942     * @throws common_Exception
943     * @throws common_exception_Error
944     * @throws common_exception_Unauthorized
945     * @throws common_ext_ExtensionException
946     */
947    public function messages()
948    {
949        $code = 200;
950
951        $this->validateSecurityToken(); // will return 500 on error
952
953        // close the PHP session to prevent session overwriting and loss of security token for secured queries
954        session_write_close();
955
956        try {
957            $input = taoQtiCommon_helpers_Utils::readJsonPayload();
958            if (!$input) {
959                $input = [];
960            }
961
962            $serviceContext = $this->getServiceContext();
963
964            /* @var $communicationService CommunicationService */
965            $communicationService = $this->getServiceLocator()->get(QtiCommunicationService::SERVICE_ID);
966
967            $response = [
968                'responses' => $communicationService->processInput($serviceContext, $input),
969                'messages' => $communicationService->processOutput($serviceContext),
970                'success' => true,
971            ];
972        } catch (common_Exception $e) {
973            $response = $this->getErrorResponse($e);
974            $code = $this->getStatusCodeFromException($e);
975        }
976
977        $this->returnJson($response, $code);
978    }
979
980    /**
981     * @throws QtiRunnerClosedException
982     * @throws common_exception_NotFound
983     */
984    private function validateDeliveryExecutionInteractionAccessibility(): void
985    {
986        $executionId = $this->getSessionId();
987        $deliveryExecution = $this->getDeliveryExecutionService()->getDeliveryExecution($executionId);
988        if ($deliveryExecution->getState()->getUri() === DeliveryExecutionInterface::STATE_FINISHED) {
989            throw new QtiRunnerClosedException();
990        }
991    }
992
993    /**
994     * @return QtiRunnerService
995     */
996    protected function getRunnerService()
997    {
998        /** @noinspection PhpIncompatibleReturnTypeInspection */
999        return $this->getServiceLocator()->get(QtiRunnerService::SERVICE_ID);
1000    }
1001
1002    /**
1003     *
1004     * For RunnerToolStates
1005     *
1006     * @param $name
1007     * @return mixed
1008     * @throws common_exception_MissingParameter
1009     */
1010    protected function getRawRequestParameter($name)
1011    {
1012        $parameters = $this->getRequest()->getRawParameters();
1013        if (!array_key_exists($name, $parameters)) {
1014            throw new common_exception_MissingParameter(sprintf('No such parameter "%s"', $name));
1015        }
1016        return $parameters[$name];
1017    }
1018
1019    /**
1020     * @param QtiRunnerServiceContext $serviceContext
1021     * @return array
1022     * @throws QtiRunnerClosedException
1023     * @throws \oat\oatbox\service\exception\InvalidServiceManagerException
1024     * @throws \qtism\runtime\storage\common\StorageException
1025     * @throws common_Exception
1026     * @throws common_exception_Error
1027     * @throws common_exception_InvalidArgumentType
1028     * @throws common_ext_ExtensionException
1029     */
1030    protected function getInitResponse(QtiRunnerServiceContext $serviceContext)
1031    {
1032        if (
1033            $this->hasRequestParameter('clientState')
1034            && $this->getRequestParameter('clientState') === 'paused'
1035        ) {
1036            $this->getRunnerService()->pause($serviceContext);
1037            $this->getRunnerService()->check($serviceContext);
1038        }
1039
1040        $result = $this->getRunnerService()->init($serviceContext);
1041        $this->getRunnerService()->persist($serviceContext);
1042
1043        if ($result) {
1044            return array_merge(...[
1045                $this->getInitSerializedResponse($serviceContext),
1046                [ 'success' => true ],
1047            ]);
1048        }
1049
1050        return [
1051            'success' => false,
1052        ];
1053    }
1054
1055    /**
1056     * Checks the storeId request parameter and returns the last store id if set, false otherwise
1057     *
1058     * @param QtiRunnerServiceContext $serviceContext
1059     * @return string|boolean
1060     * @throws common_exception_InvalidArgumentType
1061     */
1062    private function getClientStoreId(QtiRunnerServiceContext $serviceContext)
1063    {
1064        if (
1065            $this->hasRequestParameter('storeId')
1066            && preg_match('/^[a-z0-9\-]+$/i', $this->getRequestParameter('storeId'))
1067        ) {
1068            return $this->getRunnerService()->switchClientStoreId(
1069                $serviceContext,
1070                $this->getRequestParameter('storeId')
1071            );
1072        }
1073
1074        return false;
1075    }
1076
1077    /**
1078     * @param QtiRunnerServiceContext $serviceContext
1079     * @return array
1080     * @throws \oat\oatbox\service\exception\InvalidServiceManagerException
1081     * @throws common_Exception
1082     * @throws common_exception_InvalidArgumentType
1083     * @throws common_ext_ExtensionException
1084     */
1085    private function getInitSerializedResponse(QtiRunnerServiceContext $serviceContext)
1086    {
1087        return [
1088            'success' => true,
1089            'testData' => $this->getRunnerService()->getTestData($serviceContext),
1090            'testContext' => $this->getRunnerService()->getTestContext($serviceContext),
1091            'testMap' => $this->getRunnerService()->getTestMap($serviceContext),
1092            'toolStates' => $this->getToolStates(),
1093            'lastStoreId' => $this->getClientStoreId($serviceContext),
1094        ];
1095    }
1096
1097    private function getSessionService(): SessionService
1098    {
1099        return $this->getServiceLocator()->get(SessionService::class);
1100    }
1101
1102    private function getDeliveryExecutionService(): DeliveryExecutionService
1103    {
1104        return $this->getServiceLocator()->get(DeliveryExecutionService::SERVICE_ID);
1105    }
1106
1107    private function getRuntimeService(): RuntimeService
1108    {
1109        return $this->getServiceLocator()->get(RuntimeService::SERVICE_ID);
1110    }
1111
1112    private function getPauseService(): PauseService
1113    {
1114        return $this->getPsrContainer()->get(PauseService::class);
1115    }
1116
1117    private function getItemDuration(): ?float
1118    {
1119        if (!$this->hasRequestParameter('itemDuration')) {
1120            return null;
1121        }
1122
1123        return (float)$this->getRequestParameter('itemDuration');
1124    }
1125
1126    private function getItemState(): ?array
1127    {
1128        $params = $this->getRequest()->getRawParameters();
1129
1130        if (!isset($params['itemState'])) {
1131            return null;
1132        }
1133
1134        return (array)json_decode($params['itemState'], true);
1135    }
1136
1137    private function getItemResponse(): ?array
1138    {
1139        $params = $this->getRequest()->getRawParameters();
1140
1141        if (!isset($params['itemResponse'])) {
1142            return null;
1143        }
1144
1145        return (array)json_decode($params['itemResponse'], true);
1146    }
1147
1148    private function setNavigationContextToCommand(NavigationContextAwareInterface $command): void
1149    {
1150        $command->setNavigationContext(
1151            $this->getRequestParameter('direction'),
1152            $this->getRequestParameter('scope'),
1153            $this->getRequestParameter('ref')
1154        );
1155    }
1156
1157    private function setItemContextToCommand(ItemContextAwareInterface $command): void
1158    {
1159        if (empty($this->getRequestParameter('itemDefinition'))) {
1160            return;
1161        }
1162
1163        $command->setItemContext(
1164            $this->getRequestParameter('itemDefinition'),
1165            $this->getItemState(),
1166            $this->getItemDuration(),
1167            $this->getItemResponse()
1168        );
1169    }
1170
1171    private function setToolsStateContextToCommand(ToolsStateAwareInterface $command): void
1172    {
1173        $command->setToolsState($this->getToolStatesFromRequest());
1174    }
1175
1176    private function createErrorResponseFromException(Exception $exception): void
1177    {
1178        $this->returnJson(
1179            $this->getErrorResponse($exception),
1180            $this->getStatusCodeFromException($exception)
1181        );
1182    }
1183
1184    private function checkExceptionForTestSessionConflict($exception): void
1185    {
1186        if (!$exception instanceof common_exception_Unauthorized) {
1187            return;
1188        }
1189
1190        try {
1191            if ($this->getServiceContext()->getTestSession()->getState() === AssessmentTestSessionState::INTERACTING) {
1192                $this->getConcurringSessionService()->setConcurringSession($this->getSessionId());
1193            }
1194        } catch (common_Exception $exception) {
1195        }
1196    }
1197
1198    private function getConcurringSessionService(): ConcurringSessionService
1199    {
1200        return $this->getPsrContainer()->get(ConcurringSessionService::class);
1201    }
1202}