diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php index 334c109077..3b90cb0ca5 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/TaskController.php @@ -15,9 +15,16 @@ use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\ProcessTranslations\TranslationManager; +use ProcessMaker\Services\TaskCompletionRawService; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class TaskController extends Controller { + public function __construct( + private readonly TaskCompletionRawService $taskCompletionRawService, + ) { + } + protected $defaultFields = [ 'id', 'element_id', @@ -151,4 +158,26 @@ public function showInterstitial($taskId) return $response; } + + /** + * Complete a task using the raw-query optimized path. + */ + public function update(Request $request, int $taskId) + { + if ($request->input('status') !== 'COMPLETED') { + abort(422, __('Only task completion is supported on this endpoint. Use PUT /api/1.0/tasks/{id} for other updates.')); + } + + try { + $task = $this->taskCompletionRawService->completeTask( + $taskId, + json_optimize_decode($request->getContent(), true) ?: [], + $request->user(), + ); + } catch (NotFoundHttpException $exception) { + return response()->json(['message' => $exception->getMessage()], 404); + } + + return response()->json($task); + } } diff --git a/ProcessMaker/Jobs/BpmnAction.php b/ProcessMaker/Jobs/BpmnAction.php index f9fcdab5ce..a2e6d21238 100644 --- a/ProcessMaker/Jobs/BpmnAction.php +++ b/ProcessMaker/Jobs/BpmnAction.php @@ -123,7 +123,7 @@ public function handle() return $response; } - public function transferInternalContext(BpmnAction $action): void + public function transferInternalContext(self $action): void { $action->engine = $this->engine; $action->instance = $this->instance; diff --git a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php index 126abeafa7..2e171bb7bd 100644 --- a/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php +++ b/ProcessMaker/Nayra/Managers/WorkflowManagerDefault.php @@ -265,6 +265,7 @@ public function runScripTask(ScriptTaskInterface $scriptTask, Token $token) if ($this->canRunInlineTask($token, $scriptTask)) { $this->runInlineTask($token, RunScriptTask::class); + return; } @@ -285,6 +286,7 @@ public function runServiceTask(ServiceTaskInterface $serviceTask, Token $token) if ($this->canRunInlineTask($token, $serviceTask)) { $this->runInlineTask($token, RunServiceTask::class); + return; } diff --git a/ProcessMaker/Repositories/ExecutionInstanceRepository.php b/ProcessMaker/Repositories/ExecutionInstanceRepository.php index 96b83e4236..ccad705191 100644 --- a/ProcessMaker/Repositories/ExecutionInstanceRepository.php +++ b/ProcessMaker/Repositories/ExecutionInstanceRepository.php @@ -14,6 +14,7 @@ use ProcessMaker\Nayra\Contracts\Repositories\ExecutionInstanceRepositoryInterface; use ProcessMaker\Nayra\Contracts\Repositories\StorageInterface; use ProcessMaker\Nayra\RepositoryTrait; +use ProcessMaker\Repositories\TokenPersistenceRawRepository; use ProcessMaker\SanitizeHelper; /** @@ -236,6 +237,16 @@ public function persistInstanceUpdated(ExecutionInstanceInterface $instance) return; } + if ( + config('app.token_persistence_raw_enabled', false) + && $instance instanceof ProcessRequest + ) { + app(TokenPersistenceRawRepository::class)->persistInstanceUpdated($instance); + CaseUpdateStatus::dispatchSync($instance); + + return; + } + // Save updated instance if (!$instance->status) { $instance->status = 'ACTIVE'; diff --git a/ProcessMaker/Repositories/TaskCompletionRawRepository.php b/ProcessMaker/Repositories/TaskCompletionRawRepository.php new file mode 100644 index 0000000000..8913415302 --- /dev/null +++ b/ProcessMaker/Repositories/TaskCompletionRawRepository.php @@ -0,0 +1,160 @@ +is_self_service = (bool) $row->is_self_service; + $row->self_service_groups = $this->decodeJson($row->self_service_groups); + + return $row; + } + + public function findProcessForComplete(int $processId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, bpmn, start_events, properties, status, name, process_category_id + FROM processes + WHERE id = ? AND deleted_at IS NULL + LIMIT 1', + [$processId] + ); + + if ($row === null) { + return null; + } + + $row->properties = $this->decodeJson($row->properties) ?? []; + $row->manager_id = $this->decodeManagerIds($row->properties['manager_id'] ?? null); + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function findProcessRequestForComplete(int $processRequestId): ?stdClass + { + $row = DB::selectOne( + 'SELECT id, process_id, process_version_id, status, do_not_sanitize, user_id, + parent_request_id, process_collaboration_id + FROM process_requests + WHERE id = ? + LIMIT 1', + [$processRequestId] + ); + + if ($row === null) { + return null; + } + + $row->do_not_sanitize = $this->decodeJson($row->do_not_sanitize) ?? []; + + return $row; + } + + public function findProcessVersionForComplete(?int $processVersionId): ?stdClass + { + if ($processVersionId === null) { + return null; + } + + $row = DB::selectOne( + 'SELECT id, process_id, bpmn, start_events, status, name, alternative + FROM process_versions + WHERE id = ? + LIMIT 1', + [$processVersionId] + ); + + if ($row === null) { + return null; + } + + $row->start_events = $this->decodeJson($row->start_events); + + return $row; + } + + public function taskHasDraft(int $taskId): bool + { + return $this->executionRawRepository->taskHasDraftRaw($taskId); + } + + public function findTaskForResponse(int $taskId): ?stdClass + { + return DB::selectOne( + 'SELECT id, element_name, element_id, element_type, status, due_at, process_request_id, + user_id, process_id, is_self_service, self_service_groups, token_properties, + created_at, updated_at, completed_at + FROM process_request_tokens + WHERE id = ? + LIMIT 1', + [$taskId] + ); + } + + /** + * @return list + */ + private function decodeManagerIds(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_array($value)) { + return array_map('intval', $value); + } + + if (is_numeric($value)) { + return [(int) $value]; + } + + $decoded = $this->decodeJson($value); + + if (is_array($decoded)) { + return array_map('intval', $decoded); + } + + return []; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Repositories/TokenPersistenceRawRepository.php b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php new file mode 100644 index 0000000000..726b7ff2f8 --- /dev/null +++ b/ProcessMaker/Repositories/TokenPersistenceRawRepository.php @@ -0,0 +1,162 @@ +updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'user_id', + 'is_self_service', + 'self_service_groups', + 'due_at', + 'initiated_at', + 'riskchanges_at', + 'token_properties', + 'stage_id', + 'stage_name', + ]); + } + + /** + * Persist token fields after persistActivityCompleted. + */ + public function saveCompletedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'process_request_id', + 'completed_at', + 'token_properties', + ]); + } + + /** + * Persist token fields after persistActivityClosed. + */ + public function saveClosedToken(ProcessRequestToken $token): void + { + $this->updateToken($token, [ + 'status', + 'element_id', + 'element_type', + 'element_name', + 'process_id', + 'process_request_id', + 'data', + 'token_properties', + ]); + } + + /** + * Analog to ExecutionInstanceRepository::persistInstanceUpdated without Eloquent save. + */ + public function persistInstanceUpdated(ProcessRequest $instance): void + { + $store = $instance->getDataStore(); + $row = DB::selectOne( + 'SELECT data, execution_revision FROM process_requests WHERE id = ? LIMIT 1', + [$instance->getKey()] + ); + + if (!$instance->status) { + $instance->status = 'ACTIVE'; + } + + $storedData = $row && $row->data ? json_decode((string) $row->data, true) : []; + $mergedData = $store->updateArray(is_array($storedData) ? $storedData : []); + $newRevision = (int) ($row->execution_revision ?? 0) + 1; + + $instance->data = $mergedData; + $instance->execution_revision = $newRevision; + + $payload = [ + 'data' => json_encode($mergedData), + 'execution_revision' => $newRevision, + 'updated_at' => Carbon::now(), + ]; + + foreach (['status', 'last_stage_id', 'last_stage_name', 'progress', 'completed_at'] as $field) { + if (array_key_exists($field, $instance->getDirty())) { + $payload[$field] = $instance->getAttributes()[$field]; + } + } + + $this->runUpdate('process_requests', (int) $instance->getKey(), $payload); + $instance->syncChanges(); + } + + /** + * @param list $fields + */ + private function updateToken(ProcessRequestToken $token, array $fields): void + { + $payload = []; + foreach ($fields as $field) { + if (!array_key_exists($field, $token->getAttributes())) { + continue; + } + $payload[$field] = $this->serializeColumnValue($field, $token->getAttributes()[$field]); + } + + $payload['updated_at'] = Carbon::now(); + + $tokenId = (int) $token->getKey(); + if ($tokenId <= 0) { + $token->saveOrFail(); + + return; + } + + $this->runUpdate('process_request_tokens', $tokenId, $payload); + $token->syncChanges(); + } + + /** + * @param array $payload + */ + private function runUpdate(string $table, int $id, array $payload): void + { + if ($payload === []) { + return; + } + + $columns = array_keys($payload); + $assignments = implode(', ', array_map(static fn (string $column): string => "`{$column}` = ?", $columns)); + $values = array_values($payload); + $values[] = $id; + + DB::update("UPDATE `{$table}` SET {$assignments} WHERE `id` = ?", $values); + } + + private function serializeColumnValue(string $field, mixed $value): mixed + { + if (in_array($field, ['self_service_groups', 'token_properties', 'data'], true)) { + return $value === null ? null : json_encode($value); + } + + if ($value instanceof Carbon) { + return $value->format('Y-m-d H:i:s'); + } + + return $value; + } +} diff --git a/ProcessMaker/Repositories/TokenRepository.php b/ProcessMaker/Repositories/TokenRepository.php index 33da49a451..aa32d789e5 100644 --- a/ProcessMaker/Repositories/TokenRepository.php +++ b/ProcessMaker/Repositories/TokenRepository.php @@ -174,7 +174,7 @@ public function persistActivityActivated(ActivityInterface $activity, TokenInter $token->riskchanges_at = $due ? Carbon::now()->addHours($due * 0.7) : null; $token->updateTokenProperties(); $token->getInstance()->updateCatchEvents(); - $token->saveOrFail(); + $this->saveToken($token); $token->setId($token->getKey()); $request = $token->getInstance(); $request->last_stage_id = $token->stage_id; @@ -357,7 +357,7 @@ public function persistActivityCompleted(ActivityInterface $activity, TokenInter $token->process_request_id = $token->getInstance()->getKey(); $token->completed_at = Carbon::now(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'completed'); $token->setId($token->getKey()); $this->updateCaseStartedTask($token); @@ -391,7 +391,7 @@ public function persistActivityClosed(ActivityInterface $activity, TokenInterfac $token->process_request_id = $token->getInstance()->getKey(); $token->data = $token->getInstance()->getDataStore()->getData(); $token->updateTokenProperties(); - $token->save(); + $this->saveToken($token, 'closed'); $token->setId($token->getKey()); } @@ -719,4 +719,26 @@ private function updateCaseStartedTask(TokenInterface $token): void $caseTaskRepo->updateCaseStartedTaskStatus(); $caseTaskRepo->updateCaseParticipatedTaskStatus(); } + + private function tokenPersistenceUsesRawSql(): bool + { + return (bool) config('app.token_persistence_raw_enabled', false); + } + + private function saveToken(TokenInterface $token, string $context = 'activated'): void + { + if (!$this->tokenPersistenceUsesRawSql() || !$token instanceof ProcessRequestToken) { + $context === 'activated' ? $token->saveOrFail() : $token->save(); + + return; + } + + $repository = app(TokenPersistenceRawRepository::class); + + match ($context) { + 'completed' => $repository->saveCompletedToken($token), + 'closed' => $repository->saveClosedToken($token), + default => $repository->saveActivatedToken($token), + }; + } } diff --git a/ProcessMaker/Services/TaskCompletionRawService.php b/ProcessMaker/Services/TaskCompletionRawService.php new file mode 100644 index 0000000000..ca56244a9c --- /dev/null +++ b/ProcessMaker/Services/TaskCompletionRawService.php @@ -0,0 +1,126 @@ + $payload + * @return array + */ + public function completeTask(int $taskId, array $payload, User $user): array + { + if (!$this->isEnabled()) { + throw new NotFoundHttpException( + __('Task update API v1.1 is disabled. Use PUT /api/1.0/tasks/{id} instead.') + ); + } + + $taskRow = $this->repository->findTaskForUpdate($taskId); + + if ($taskRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + if ($taskRow->status === 'CLOSED') { + abort(422, __('Task already closed')); + } + + $processRow = $this->repository->findProcessForComplete((int) $taskRow->process_id); + + if ($processRow === null) { + throw new NotFoundHttpException(__('Process not found')); + } + + Gate::forUser($user)->authorize( + 'update', + $this->engineBridge->hydrateTokenForPolicy($taskRow, $processRow) + ); + + $requestRow = $this->repository->findProcessRequestForComplete((int) $taskRow->process_request_id); + + if ($requestRow === null) { + throw new NotFoundHttpException(__('Process request not found')); + } + + $versionRow = $this->repository->findProcessVersionForComplete( + $requestRow->process_version_id ? (int) $requestRow->process_version_id : null + ); + + $data = SanitizeHelper::sanitizeData( + $payload['data'] ?? [], + null, + $requestRow->do_not_sanitize ?? [] + ); + + $this->engineBridge->complete( + $taskRow, + $processRow, + $requestRow, + $versionRow, + $data, + $this->repository->taskHasDraft($taskId), + ); + + $responseRow = $this->repository->findTaskForResponse($taskId); + + if ($responseRow === null) { + throw new NotFoundHttpException(__('Task not found')); + } + + return $this->formatTaskResponse($responseRow); + } + + /** + * @return array + */ + private function formatTaskResponse(object $row): array + { + return [ + 'id' => (int) $row->id, + 'element_name' => $row->element_name, + 'element_id' => $row->element_id, + 'element_type' => $row->element_type, + 'status' => $row->status, + 'due_at' => $row->due_at, + 'process_request_id' => (int) $row->process_request_id, + 'is_self_service' => (bool) $row->is_self_service, + 'token_properties' => $this->decodeJson($row->token_properties ?? null), + ]; + } + + private function decodeJson(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + $decoded = json_decode((string) $value, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } +} diff --git a/ProcessMaker/Support/TaskCompletionEngineBridge.php b/ProcessMaker/Support/TaskCompletionEngineBridge.php new file mode 100644 index 0000000000..b3f697ceb1 --- /dev/null +++ b/ProcessMaker/Support/TaskCompletionEngineBridge.php @@ -0,0 +1,150 @@ +hydrateProcess($processRow); + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + $task->setRelation('process', $process); + + return $task; + } + + public function complete( + stdClass $taskRow, + stdClass $processRow, + stdClass $requestRow, + ?stdClass $versionRow, + array $data, + bool $hasDraft, + ): void { + if ($hasDraft && TaskDraft::draftsEnabled()) { + $task = $this->hydrateToken($taskRow, $requestRow, $processRow); + TaskDraft::moveDraftFiles($task); + } + + $process = $this->hydrateProcess($processRow); + $processVersion = $versionRow ? $this->hydrateProcessVersion($versionRow, $processRow) : null; + $instance = $this->hydrateProcessRequest($requestRow, $process, $processVersion); + $task = $this->hydrateToken($taskRow, $requestRow, $processRow, $instance, $process); + + WorkflowManager::completeTask($process, $instance, $task, $data); + } + + private function hydrateProcess(stdClass $row): Process + { + $attributes = (array) $row; + $properties = is_array($attributes['properties'] ?? null) + ? $attributes['properties'] + : []; + + if (!empty($row->manager_id)) { + $properties['manager_id'] = $row->manager_id; + } + + $attributes['properties'] = $properties; + unset($attributes['manager_id']); + + return $this->hydrateModel(Process::class, $this->encodeArrayCasts($attributes, ['properties', 'start_events'])); + } + + private function hydrateProcessVersion(stdClass $row, stdClass $processRow): ProcessVersion + { + $process = $this->hydrateProcess($processRow); + $version = $this->hydrateModel( + ProcessVersion::class, + $this->encodeArrayCasts((array) $row, ['start_events']) + ); + $version->setRelation('process', $process); + + return $version; + } + + private function hydrateProcessRequest( + stdClass $row, + Process $process, + ?ProcessVersion $processVersion, + ): ProcessRequest { + $instance = $this->hydrateModel( + ProcessRequest::class, + $this->encodeArrayCasts((array) $row, ['do_not_sanitize']) + ); + $instance->setRelation('process', $process); + + if ($processVersion !== null) { + $instance->setRelation('processVersion', $processVersion); + } + + return $instance; + } + + private function hydrateToken( + stdClass $taskRow, + stdClass $requestRow, + stdClass $processRow, + ?ProcessRequest $instance = null, + ?Process $process = null, + ): ProcessRequestToken { + $task = $this->hydrateModel( + ProcessRequestToken::class, + $this->encodeArrayCasts((array) $taskRow, ['self_service_groups']) + ); + + if ($instance === null || $process === null) { + $process ??= $this->hydrateProcess($processRow); + $instance ??= $this->hydrateProcessRequest($requestRow, $process, null); + } + + $task->setRelation('processRequest', $instance); + $task->setRelation('process', $process); + + return $task; + } + + private function hydrateModel(string $class, array $attributes): mixed + { + return $this->executionRawRepository->hydrateModelFromRowRaw($class, (object) $attributes); + } + + /** + * Eloquent array casts expect JSON strings in raw attributes. + * + * @param list $fields + */ + private function encodeArrayCasts(array $attributes, array $fields): array + { + foreach ($fields as $field) { + if (isset($attributes[$field]) && is_array($attributes[$field])) { + $attributes[$field] = json_encode($attributes[$field]); + } + } + + return $attributes; + } +} diff --git a/config/app.php b/config/app.php index b3c5891f98..a5477a2c6a 100644 --- a/config/app.php +++ b/config/app.php @@ -264,6 +264,12 @@ 'task_drafts_enabled' => env('TASK_DRAFTS_ENABLED', true), + // Raw-query PUT /api/1.1/tasks/{id} for optimized task completion (FOUR-32800). + 'task_update_v1_1_enabled' => env('TASK_UPDATE_V1_1_ENABLED', false), + + // Raw SQL for TokenRepository persistActivity* and getNextUser (FOUR-32800 option 3). + 'token_persistence_raw_enabled' => env('TOKEN_PERSISTENCE_RAW_ENABLED', false), + 'force_https' => env('FORCE_HTTPS', true), 'nayra_docker_network' => env('NAYRA_DOCKER_NETWORK', 'host'), diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index 10547dc52f..6da1cd0c6d 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -29,6 +29,10 @@ // Route to show the interstitial screen of a task Route::get('/{taskId}/interstitial', [TaskController::class, 'showInterstitial']) ->name('show.interstitial'); + + // Optimized task completion using raw queries (FOUR-32800). + Route::put('/{taskId}', [TaskController::class, 'update']) + ->name('update'); }); // Cases Endpoints diff --git a/tests/Feature/Api/InlineTaskExecutionTest.php b/tests/Feature/Api/InlineTaskExecutionTest.php index 98a315f49b..71407c6eaf 100644 --- a/tests/Feature/Api/InlineTaskExecutionTest.php +++ b/tests/Feature/Api/InlineTaskExecutionTest.php @@ -48,7 +48,6 @@ public function setupInlineTaskExecution(): void foreach ($implementations as $implementation => $class) { $this->assertTrue(WorkflowManager::registerServiceImplementation($implementation, $class)); } - } public function teardownInlineTaskExecution(): void diff --git a/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php b/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php new file mode 100644 index 0000000000..31ed8fcec7 --- /dev/null +++ b/tests/Feature/Api/V1_1/TaskControllerUpdateTest.php @@ -0,0 +1,115 @@ +create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ]); + + $response->assertStatus(404); + $response->assertJsonFragment([ + 'message' => 'Task update API v1.1 is disabled. Use PUT /api/1.0/tasks/{id} instead.', + ]); + } + + public function testUpdateRejectsNonCompletionStatus(): void + { + Config::set('app.task_update_v1_1_enabled', true); + + $task = ProcessRequestToken::factory()->create([ + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'user_id' => User::factory()->create()->id, + ]); + + $response->assertStatus(422); + } + + public function testUpdateCompletesTaskWhenEnabled(): void + { + Config::set('app.task_update_v1_1_enabled', true); + Config::set('app.token_persistence_raw_enabled', true); + + $request = ProcessRequest::factory()->create(); + $task = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'user_id' => $this->user->id, + 'status' => 'ACTIVE', + ]); + + WorkflowManager::shouldReceive('completeTask') + ->once() + ->with(Mockery::any(), Mockery::any(), Mockery::any(), ['foo' => 'bar']); + + $response = $this->apiCall('PUT', route('api.1.1.tasks.update', $task->id), [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ]); + + $response->assertStatus(200); + $response->assertJsonFragment([ + 'id' => $task->id, + 'status' => $task->status, + ]); + } + + public function testUpdateDeniesUnauthorizedUser(): void + { + Config::set('app.task_update_v1_1_enabled', true); + + $caller = User::factory()->create(['is_administrator' => false]); + $assignee = User::factory()->create(['is_administrator' => false]); + $task = ProcessRequestToken::factory()->create([ + 'user_id' => $assignee->id, + 'status' => 'ACTIVE', + ]); + + $response = $this->actingAs($caller, 'api')->json( + 'PUT', + '/api/' . preg_replace('/^.*\/api\//i', '', route('api.1.1.tasks.update', $task->id)), + [ + 'status' => 'COMPLETED', + 'data' => ['foo' => 'bar'], + ] + ); + + $response->assertStatus(403); + } +} diff --git a/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php index f7e17405ad..5c0f5887c4 100644 --- a/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php +++ b/tests/unit/ProcessMaker/Repositories/ProcessExecutionRawRepositoryTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit\ProcessMaker\Repositories; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; use ProcessMaker\Repositories\ProcessExecutionRawRepository; use Tests\TestCase; @@ -34,4 +36,24 @@ public function testGetProcessRequestForResponseRawIncludesDataColumn(): void $this->assertIsArray($hydrated->data); $this->assertSame('persisted', $hydrated->data['marker']); } + + public function testTaskHasDraftRawReturnsFalseWhenNoDraftExists(): void + { + $task = ProcessRequestToken::factory()->create(); + + $repository = new ProcessExecutionRawRepository(); + + $this->assertFalse($repository->taskHasDraftRaw($task->id)); + } + + public function testHydrateModelFromRowRawPreservesAttributes(): void + { + $user = User::factory()->create(); + + $repository = new ProcessExecutionRawRepository(); + $hydrated = $repository->hydrateModelFromRowRaw(User::class, (object) $user->getAttributes()); + + $this->assertSame($user->id, $hydrated->id); + $this->assertTrue($hydrated->exists); + } } diff --git a/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php b/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php new file mode 100644 index 0000000000..4d6cf01911 --- /dev/null +++ b/tests/unit/ProcessMaker/Repositories/TokenPersistenceRawRepositoryTest.php @@ -0,0 +1,64 @@ +create(); + $token = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'status' => 'CLOSED', + 'element_name' => 'Old', + ]); + + $token->status = 'ACTIVE'; + $token->element_name = 'Updated Task'; + $token->user_id = $request->user_id; + + app(TokenPersistenceRawRepository::class)->saveActivatedToken($token); + + $this->assertDatabaseHas('process_request_tokens', [ + 'id' => $token->id, + 'status' => 'ACTIVE', + 'element_name' => 'Updated Task', + ]); + } + + public function testPersistInstanceUpdatedMergesDataWithoutEloquentSave(): void + { + $request = ProcessRequest::factory()->create([ + 'data' => ['existing' => 'value'], + 'execution_revision' => 2, + ]); + $request->loadProcessRequestInstance(); + + $request->getDataStore()->putData('new_key', 'new_value'); + $request->last_stage_name = 'Review'; + + app(TokenPersistenceRawRepository::class)->persistInstanceUpdated($request); + + $request->refresh(); + + $this->assertSame('value', $request->data['existing']); + $this->assertSame('new_value', $request->data['new_key']); + $this->assertSame(3, (int) $request->execution_revision); + $this->assertSame('Review', $request->last_stage_name); + } +}