diff --git a/ProcessMaker/Http/Controllers/Api/V1_1/ProcessVariableController.php b/ProcessMaker/Http/Controllers/Api/V1_1/ProcessVariableController.php index 2b5f462923..7f10efaf51 100644 --- a/ProcessMaker/Http/Controllers/Api/V1_1/ProcessVariableController.php +++ b/ProcessMaker/Http/Controllers/Api/V1_1/ProcessVariableController.php @@ -4,17 +4,18 @@ namespace ProcessMaker\Http\Controllers\Api\V1_1; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use ProcessMaker\Http\Controllers\Controller; -use ProcessMaker\Managers\ExportManager; use ProcessMaker\Models\Process; -use ProcessMaker\Models\Screen; use ProcessMaker\Package\SavedSearch\Models\SavedSearch; use ProcessMaker\Package\VariableFinder\Models\ProcessVariable; +use ProcessMaker\Services\ProcessScreenVariableService; class ProcessVariableController extends Controller { @@ -119,15 +120,22 @@ public function index(Request $request) $processIds = !empty($validated['processIds']) ? array_map('intval', explode(',', $validated['processIds'])) : []; - $perPage = $validated['per_page'] ?? 20; - $page = $validated['page'] ?? 1; + $perPage = (int) ($validated['per_page'] ?? 20); + $page = (int) ($validated['page'] ?? 1); $excludeSavedSearch = $validated['savedSearchId'] ?? 0; - // Generate mock data - if (static::$mockData) { + // Available columns and process variables have independent pagination. + if ($request->has('onlyAvailable')) { + $paginator = $this->getAvailableColumnsPaginator( + $excludeSavedSearch, + $page, + $perPage, + $request + ); + } elseif (static::$mockData) { $paginator = $this->getProcessesVariablesFromMock($processIds, $excludeSavedSearch, $page, $perPage, $request); } else { - $paginator = $this->getProcessesVariables($processIds, $excludeSavedSearch, $page, $perPage, $request); + $paginator = $this->getProcessesVariables($processIds, $excludeSavedSearch, $page, $perPage); } return response()->json([ @@ -203,10 +211,9 @@ private function getProcessesVariablesFromMock(array $processIds, $excludeSavedS * @param bool $excludeSavedSearch Flag to exclude saved searches. * @param int $page The page number for pagination. * @param int $perPage The number of items per page for pagination. - * @param Request $request The HTTP request instance. - * @return \Illuminate\Http\JsonResponse JSON response containing the process variables. + * @return LengthAwarePaginator */ - public function getProcessesVariables(array $processIds, $excludeSavedSearch, $page, $perPage, $request) + public function getProcessesVariables(array $processIds, $excludeSavedSearch, $page, $perPage) { // Determine which columns to exclude based on the saved search $activeColumns = []; @@ -224,12 +231,11 @@ public function getProcessesVariables(array $processIds, $excludeSavedSearch, $p || !Schema::hasTable('process_variables') || !self::$useVarFinder ) { - $paginator = $this->getProcessesVariablesFrom($processIds); - if ($request->has('onlyAvailable')) { - return $this->mergeOnlyAvailableColumns($paginator, $savedSearch, $activeColumns); - } + return $this->getProcessesVariablesFrom($processIds, $activeColumns); + } - return $paginator; + if ($processIds === []) { + return new LengthAwarePaginator([], 0, $perPage, $page); } // Build a single query that joins asset_variables, and var_finder_variables @@ -249,33 +255,57 @@ public function getProcessesVariables(array $processIds, $excludeSavedSearch, $p DB::raw('NULL AS `default`'), ]); - // Return the paginated result - $paginator = $query->paginate($perPage, ['*'], 'page', $page); + $activeDataColumns = collect($activeColumns) + ->filter(fn ($column) => str_starts_with($column, 'data.')) + ->map(fn ($column) => substr($column, 5)) + ->values() + ->all(); + if ($activeDataColumns !== []) { + $query->whereNotIn('vfv.field', $activeDataColumns); + } - if ($request->has('onlyAvailable')) { - return $this->mergeOnlyAvailableColumns($paginator, $savedSearch, $activeColumns); + try { + $paginator = $query->paginate($perPage, ['*'], 'page', $page); + } catch (QueryException $exception) { + if ((int) ($exception->errorInfo[1] ?? 0) !== 1038) { + throw $exception; + } + + Log::warning('Variable Finder exceeded MySQL sort memory; using screen variables', [ + 'process_count' => count($processIds), + 'page' => $page, + 'per_page' => $perPage, + ]); + $paginator = $this->getProcessesVariablesFrom($processIds, $activeColumns); } - return $query->paginate($perPage, ['*'], 'page', $page); + return $paginator; } /** - * Merge only available columns with collection items - * - * @param LengthAwarePaginator $paginator - * @param SavedSearch|null $savedSearch - * @param array $activeColumns + * Paginate the saved search columns separately from process variables. * * @return LengthAwarePaginator */ - private function mergeOnlyAvailableColumns($paginator, $savedSearch, $activeColumns) + private function getAvailableColumnsPaginator($savedSearchId, int $page, int $perPage, Request $request) { + $savedSearch = $savedSearchId ? SavedSearch::find($savedSearchId) : null; + $activeColumns = $savedSearch?->current_columns?->pluck('field')->toArray() ?? []; $availableColumns = $this->mergeAvailableColumns($savedSearch); - $availableColumns = $availableColumns->merge($paginator->items()); - $availableColumns = $this->filterActiveColumns($availableColumns, $activeColumns); - $paginator->setCollection($availableColumns); + $availableColumns = $this->filterActiveColumns($availableColumns, $activeColumns) + ->unique('field') + ->values(); - return $paginator; + return new LengthAwarePaginator( + $availableColumns->forPage($page, $perPage)->values(), + $availableColumns->count(), + $perPage, + $page, + [ + 'path' => $request->url(), + 'query' => $request->query(), + ] + ); } /** @@ -285,15 +315,13 @@ private function mergeOnlyAvailableColumns($paginator, $savedSearch, $activeColu */ private function mergeAvailableColumns(?SavedSearch $savedSearch = null) { - $availableColumns = collect(); - - if ($savedSearch?->available_columns) { - $availableColumns = $savedSearch->available_columns->merge( - $savedSearch->getDataColumnsAttribute() ?? collect() - ); + if (!$savedSearch) { + return collect(); } - return $availableColumns; + $availableColumns = $savedSearch->available_columns; + + return $availableColumns->merge($savedSearch->getDataColumnsAttribute() ?? collect()); } /** @@ -333,10 +361,11 @@ public static function useVarFinder(bool $value = true) * Retrieve process variables from its screens. * * @param array $processIds + * @param array $activeColumns * * @return LengthAwarePaginator */ - private function getProcessesVariablesFrom(array $processIds) + private function getProcessesVariablesFrom(array $processIds, array $activeColumns = []) { $perPage = request()->get('per_page', 20); // Validate processIds input is required @@ -344,30 +373,11 @@ private function getProcessesVariablesFrom(array $processIds) return new LengthAwarePaginator([], 0, $perPage, 1); } - // Get screens used in the processes $processes = Process::whereIn('id', $processIds)->get(); - $ids = collect([]); - foreach ($processes as $process) { - $manager = app(ExportManager::class); - try { - $ids = $ids->merge($manager->getDependenciesOfType(Screen::class, $process)); - } catch (\Exception $e) { - $ids = collect([]); - } - } - - // Get columns from screens - $columns = collect([]); - $screens = Screen::whereIn('id', $ids->unique())->where('type', '!=', 'DISPLAY')->get(); - foreach ($screens as $screen) { - $screenColumns = $screen->fields->map(function ($item) { - $item->field = "data.{$item->field}"; - - return $item; - }); - - $columns = $columns->merge($screenColumns); - } + $columns = app(ProcessScreenVariableService::class)->forProcesses($processes); + $columns = $this->filterActiveColumns($columns, $activeColumns) + ->unique('field') + ->values(); // Paginate the result $page = request()->get('page', 1); diff --git a/ProcessMaker/Services/ProcessScreenVariableService.php b/ProcessMaker/Services/ProcessScreenVariableService.php new file mode 100644 index 0000000000..6d39d54731 --- /dev/null +++ b/ProcessMaker/Services/ProcessScreenVariableService.php @@ -0,0 +1,148 @@ +filter(fn ($process) => $process instanceof Process) + ->unique('id') + ->sortBy('id') + ->values(); + + if ($processes->isEmpty()) { + return collect(); + } + + $processSignature = $processes->map(function (Process $process) { + return implode(':', [ + $process->id, + (string) $process->getRawOriginal('updated_at'), + sha1((string) $process->getRawOriginal('bpmn')), + ]); + })->implode('|'); + $cacheKey = 'process-screen-variables:v1:' . sha1($processSignature); + $lastKnownKey = $cacheKey . ':last-known-good'; + $payload = Cache::flexible( + $cacheKey, + [self::CACHE_FRESH_SECONDS, self::CACHE_STALE_SECONDS], + fn () => $this->refresh($processes, $cacheKey, $lastKnownKey) + ); + + return collect(is_array($payload) ? $payload : []) + ->map(fn (array $column) => new Column($column)); + } + + private function refresh(Collection $processes, string $cacheKey, string $lastKnownKey): array + { + $lastKnown = Cache::get($lastKnownKey); + $lock = Cache::lock($cacheKey . ':lock', self::CACHE_LOCK_SECONDS); + + if (!$lock->get()) { + try { + return $lock->block( + 5, + fn () => Cache::get($lastKnownKey, is_array($lastKnown) ? $lastKnown : []) + ); + } catch (LockTimeoutException) { + return is_array($lastKnown) ? $lastKnown : []; + } + } + + $startedAt = microtime(true); + $failed = false; + + try { + $resolver = new ScreensInProcess(); + $screenIds = collect(); + + foreach ($processes as $process) { + try { + foreach ($resolver->referencesToExport($process) as [$class, $id]) { + if ($class === Screen::class) { + $screenIds->push($id); + } + } + } catch (Throwable $exception) { + $failed = true; + Log::warning('Unable to resolve process screens for variable discovery', [ + 'process_id' => $process->id, + 'message' => $exception->getMessage(), + ]); + } + } + + $columns = collect(); + $screens = Screen::whereIn('id', $screenIds->unique()) + ->where('type', '!=', 'DISPLAY') + ->get(); + + foreach ($screens as $screen) { + try { + $columns = $columns->merge($screen->fields->map(function ($column) { + $column->field = 'data.' . $column->field; + + return $column; + })); + } catch (Throwable $exception) { + $failed = true; + Log::warning('Unable to resolve screen fields for variable discovery', [ + 'screen_id' => $screen->id, + 'message' => $exception->getMessage(), + ]); + } + } + + $payload = $columns + ->unique('field') + ->values() + ->map(fn ($column) => get_object_vars($column)) + ->all(); + + if ($failed && is_array($lastKnown)) { + return $lastKnown; + } + + if (!$failed) { + Cache::put($lastKnownKey, $payload, self::CACHE_STALE_SECONDS); + } + + return $payload; + } catch (Throwable $exception) { + Log::warning('Screen variable discovery failed', [ + 'process_count' => $processes->count(), + 'message' => $exception->getMessage(), + ]); + + return is_array($lastKnown) ? $lastKnown : []; + } finally { + $durationMs = round((microtime(true) - $startedAt) * 1000, 2); + Log::debug('Process screen variable discovery timing', [ + 'process_count' => $processes->count(), + 'duration_ms' => $durationMs, + ]); + $lock->release(); + } + } +} diff --git a/tests/Feature/Api/V1_1/ProcessVariableControllerTest.php b/tests/Feature/Api/V1_1/ProcessVariableControllerTest.php index 0b013681de..54dcdfe843 100644 --- a/tests/Feature/Api/V1_1/ProcessVariableControllerTest.php +++ b/tests/Feature/Api/V1_1/ProcessVariableControllerTest.php @@ -2,17 +2,23 @@ namespace Tests\Feature\Api\V1_1; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use ProcessMaker\Http\Controllers\Api\V1_1\ProcessVariableController; +use ProcessMaker\Models\Column; use ProcessMaker\Models\Process; +use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\Screen; +use ProcessMaker\Models\Setting; use ProcessMaker\Models\User; use ProcessMaker\Package\SavedSearch\Models\SavedSearch; use ProcessMaker\Package\VariableFinder\Models\AssetVariable; use ProcessMaker\Package\VariableFinder\Models\ProcessVariable; use ProcessMaker\Package\VariableFinder\Models\VarFinderVariable; +use ProcessMaker\Services\ProcessScreenVariableService; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -23,15 +29,15 @@ class ProcessVariableControllerTest extends TestCase private bool $isVariablesFinderEnabled; /** - * Set up test environment by creating a test user and authenticating as them + * Set up Variable Finder fixtures after the test user exists. + * + * Named withUserSetup so TestCase does not run it before the database + * snapshot (methods starting with "setup" are auto-invoked too early). * * @return void */ - public function setupCreateUser() + public function withUserSetup() { - $this->user = User::factory()->create(); - $this->actingAs($this->user); - // Check if the VariableFinder package is enabled $this->isVariablesFinderEnabled = class_exists(ProcessVariable::class) && Schema::hasTable('process_variables'); @@ -243,7 +249,7 @@ private function loadVariableFinderData(array $processIds) // Generate data similarly to mockVariableFinder $format = $this->getRandomDataType(); $label = "Variable {$i} for Process {$processId}"; - $field = "data.var_{$processId}_{$i}"; + $field = "var_{$processId}_{$i}"; // 3. Create the VarFinderVariable record linked to the same AssetVariable VarFinderVariable::create([ @@ -361,8 +367,8 @@ public function test_saved_search_id_filtering(): void $this->assertFalse($filteredFields->contains('data.var_1_1')); $this->assertFalse($filteredFields->contains('data.var_1_2')); - // Check that the total count matches the actual number of variables - $this->assertEquals(10, $responseData['meta']['total']); // Total number of variables + // The two active fields are excluded from the available variable pages. + $this->assertEquals(8, $responseData['meta']['total']); } /** @@ -435,6 +441,298 @@ public function test_saved_search_with_all_available_columns(): void $this->assertTrue($filteredFields->contains('completed_at')); } + public function test_fulltext_saved_search_with_empty_process_ids_loads_available_columns(): void + { + ProcessVariableController::mock(false); + ProcessVariableController::useVarFinder(false); + Setting::updateOrCreate( + ['key' => 'indexed-search'], + ['config' => ['enabled' => false]] + ); + + $process = Process::factory()->create(); + ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'data' => ['four_33197_variable' => 'value'], + ]); + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => [ + 'icon' => 'search', + 'columns' => [], + ], + 'pmql' => '(fulltext LIKE "%test%")', + ]); + + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=&savedSearchId=' . $savedSearch->id . '&onlyAvailable=' + ); + + $response->assertStatus(200); + $fields = collect($response->json('data'))->pluck('field'); + $this->assertContains('case_number', $fields); + $this->assertContains('data.four_33197_variable', $fields); + } + + public function test_only_available_columns_are_paginated_without_losing_fields(): void + { + ProcessVariableController::mock(false); + ProcessVariableController::useVarFinder(false); + + $process = Process::factory()->create(); + $data = collect(range(1, 125))->mapWithKeys(function ($index) { + return ["four_33197_paginated_{$index}" => 'value']; + })->all(); + ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'data' => $data, + ]); + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => [ + 'icon' => 'search', + 'columns' => [ + [ + 'label' => 'Active paginated variable', + 'field' => 'data.four_33197_paginated_1', + ], + ], + ], + 'pmql' => '', + ]); + $url = '/api/1.1/processes/variables?processIds=' . $process->id + . '&savedSearchId=' . $savedSearch->id + . '&onlyAvailable=&per_page=50&page='; + + $firstPage = $this->apiCall('GET', $url . '1'); + $firstPage->assertStatus(200); + $total = $firstPage->json('meta.total'); + $lastPage = $firstPage->json('meta.last_page'); + $fields = collect($firstPage->json('data'))->pluck('field'); + + $this->assertGreaterThan(100, $total); + $this->assertCount(50, $firstPage->json('data')); + $this->assertSame(50, $firstPage->json('meta.per_page')); + $this->assertSame(1, $firstPage->json('meta.from')); + $this->assertSame(50, $firstPage->json('meta.to')); + $this->assertSame((int) ceil($total / 50), $lastPage); + + for ($page = 2; $page <= $lastPage; $page++) { + $response = $this->apiCall('GET', $url . $page); + $expectedCount = min(50, $total - (($page - 1) * 50)); + + $response->assertStatus(200); + $this->assertCount($expectedCount, $response->json('data')); + $this->assertSame($page, $response->json('meta.current_page')); + $this->assertSame($total, $response->json('meta.total')); + $this->assertSame((($page - 1) * 50) + 1, $response->json('meta.from')); + $this->assertSame(($page - 1) * 50 + $expectedCount, $response->json('meta.to')); + $fields = $fields->merge(collect($response->json('data'))->pluck('field')); + } + + $this->assertCount($total, $fields); + $this->assertCount($total, $fields->unique()); + $this->assertNotContains('data.four_33197_paginated_1', $fields); + foreach (range(2, 125) as $index) { + $this->assertContains("data.four_33197_paginated_{$index}", $fields); + } + } + + public function test_process_screen_variable_pages_exclude_active_columns(): void + { + ProcessVariableController::mock(false); + ProcessVariableController::useVarFinder(false); + + $bpmn = file_get_contents(base_path('tests/Feature/Api/bpmnPatterns/SimpleTaskProcess.bpmn')); + $screen = $this->createScreenWithFields(91, 12); + $process = Process::factory()->create([ + 'bpmn' => str_replace('pm:screenRef="2"', 'pm:screenRef="' . $screen->id . '"', $bpmn), + ]); + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => [ + 'icon' => 'search', + 'columns' => [ + [ + 'label' => 'Variable 1 for Process 91', + 'field' => 'data.var_91_1', + ], + ], + ], + 'pmql' => '', + ]); + $url = '/api/1.1/processes/variables?processIds=' . $process->id + . '&savedSearchId=' . $savedSearch->id + . '&per_page=5&page='; + $fields = collect(); + + for ($page = 1; $page <= 3; $page++) { + $response = $this->apiCall('GET', $url . $page); + + $response->assertStatus(200); + $this->assertLessThanOrEqual(5, count($response->json('data'))); + $this->assertSame(11, $response->json('meta.total')); + $fields = $fields->merge(collect($response->json('data'))->pluck('field')); + } + + $this->assertCount(11, $fields); + $this->assertCount(11, $fields->unique()); + $this->assertNotContains('data.var_91_1', $fields); + } + + public function test_only_available_without_saved_search_returns_an_empty_typed_page(): void + { + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=1&page=2&per_page=7&onlyAvailable=' + ); + + $response->assertStatus(200); + $this->assertSame([], $response->json('data')); + $this->assertSame(2, $response->json('meta.current_page')); + $this->assertSame(7, $response->json('meta.per_page')); + $this->assertSame(0, $response->json('meta.total')); + $this->assertSame(1, $response->json('meta.last_page')); + $this->assertNull($response->json('meta.from')); + $this->assertNull($response->json('meta.to')); + $this->assertNull($response->json('meta.links.next')); + } + + public function test_only_available_out_of_range_page_preserves_pagination_query(): void + { + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => ['columns' => []], + 'pmql' => '', + ]); + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=1&savedSearchId=' . $savedSearch->id + . '&page=99&per_page=4&onlyAvailable=1' + ); + + $response->assertStatus(200); + $total = $response->json('meta.total'); + $this->assertGreaterThan(0, $total); + $this->assertSame([], $response->json('data')); + $this->assertSame(99, $response->json('meta.current_page')); + $this->assertSame(4, $response->json('meta.per_page')); + $this->assertSame((int) ceil($total / 4), $response->json('meta.last_page')); + $this->assertNull($response->json('meta.from')); + $this->assertNull($response->json('meta.to')); + $this->assertNull($response->json('meta.links.next')); + + parse_str(parse_url($response->json('meta.links.prev'), PHP_URL_QUERY), $previousQuery); + $this->assertSame('98', $previousQuery['page']); + $this->assertSame((string) $savedSearch->id, $previousQuery['savedSearchId']); + $this->assertSame('1', $previousQuery['onlyAvailable']); + $this->assertSame('4', $previousQuery['per_page']); + } + + public function test_only_available_does_not_execute_variable_finder_query(): void + { + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => ['columns' => []], + 'pmql' => '', + ]); + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=1&savedSearchId=' . $savedSearch->id + . '&page=1&per_page=5&onlyAvailable=' + ); + $queries = collect(DB::getQueryLog())->pluck('query')->implode("\n"); + } finally { + DB::disableQueryLog(); + } + + $response->assertStatus(200); + $this->assertStringNotContainsString('var_finder_variables', strtolower($queries)); + } + + public function test_active_default_column_does_not_hide_namespaced_variable(): void + { + if (!$this->isVariablesFinderEnabled) { + $this->markTestSkipped('Variable Finder is not enabled.'); + } + + $assetVariable = AssetVariable::where('process_id', 1)->firstOrFail(); + VarFinderVariable::create([ + 'uuid' => (string) Str::uuid(), + 'asset_variable_id' => $assetVariable->id, + 'data_type' => 'string', + 'label' => 'Variable named like a default column', + 'field' => 'case_number', + ]); + $savedSearch = SavedSearch::factory()->create([ + 'type' => SavedSearch::TYPE_REQUEST, + 'meta' => [ + 'columns' => [[ + 'label' => 'Case Number', + 'field' => 'case_number', + ]], + ], + 'pmql' => '', + ]); + + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=1&savedSearchId=' . $savedSearch->id . '&per_page=100' + ); + + $response->assertStatus(200); + $this->assertContains('data.case_number', collect($response->json('data'))->pluck('field')); + } + + public function test_variable_finder_sort_memory_error_falls_back_to_screen_variables(): void + { + if (!$this->isVariablesFinderEnabled) { + $this->markTestSkipped('Variable Finder is not enabled.'); + } + + ProcessVariableController::mock(false); + ProcessVariableController::useVarFinder(true); + $this->mock(ProcessScreenVariableService::class, function ($mock) { + $mock->shouldReceive('forProcesses') + ->once() + ->andReturn(collect([ + new Column([ + 'label' => 'Fallback Field', + 'field' => 'data.fallback_field', + 'sortable' => true, + 'default' => false, + 'format' => 'string', + 'mask' => null, + ]), + ])); + }); + DB::beforeExecuting(function ($query, $bindings) { + if (!str_contains($query, 'var_finder_variables')) { + return; + } + + $previous = new \PDOException('Out of sort memory', 1038); + $previous->errorInfo = ['HY001', 1038, 'Out of sort memory']; + + throw new QueryException('processmaker', $query, $bindings, $previous); + }); + + $response = $this->apiCall( + 'GET', + '/api/1.1/processes/variables?processIds=1&page=1&per_page=5' + ); + + $response->assertStatus(200); + $this->assertSame(['data.fallback_field'], collect($response->json('data'))->pluck('field')->all()); + $this->assertSame(1, $response->json('meta.total')); + $this->assertSame(5, $response->json('meta.per_page')); + } + public function test_saved_search_with_remaining_available_columns(): void { ProcessVariableController::mock(false); diff --git a/tests/TestCase.php b/tests/TestCase.php index 587ba3b53f..630fc17b75 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -96,6 +96,13 @@ protected function setUp(): void $this->disableSetContentMiddleware(); + // Snapshot the migrated/seeded database before setup* fixtures write + // inside the RefreshDatabase transaction. A locking dump after those + // writes waits forever on this connection's uncommitted changes. + if (!self::$databaseSnapshotFile) { + self::$databaseSnapshotFile = $this->takeDatabaseSnapshot('non-transactional-test'); + } + $classMethods = get_class_methods($this); foreach (array_diff($classMethods, $this->skipSetupMethods) as $method) { $imethod = strtolower($method); @@ -104,10 +111,6 @@ protected function setUp(): void } } - if (!self::$databaseSnapshotFile) { - self::$databaseSnapshotFile = $this->takeDatabaseSnapshot('non-transactional-test'); - } - if ($this->withPermissions === true) { $this->initializePermissions(); } @@ -303,11 +306,11 @@ private function getDumpCommand() $result = \Illuminate\Support\Facades\Process::run('command -v mariadb-dump'); if ($result->successful()) { - return 'mariadb-dump '; + return 'mariadb-dump --single-transaction --quick '; } // Fall back to mysqldump - return 'mysqldump '; + return 'mysqldump --single-transaction --quick '; } private function getMysqlCommand()