Skip to content

Conversation

@GautierDele
Copy link
Member

@GautierDele GautierDele commented Aug 11, 2025

closes #185

Summary by CodeRabbit

  • New Features
    • Search now supports filtering soft-deleted records. You can include soft-deleted items or return only soft-deleted items using the trashed option.
  • Validation
    • Added validation for the trashed option; invalid values return a clear error response.
  • Tests
    • Added feature and unit tests covering searches including and limited to soft-deleted records.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Warning

Rate limit exceeded

@GautierDele has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 18 minutes and 45 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 488e993 and 9284c49.

📒 Files selected for processing (2)
  • src/Query/ScoutBuilder.php (3 hunks)
  • tests/Feature/Controllers/SearchScoutOperationsTest.php (1 hunks)

Walkthrough

Adds soft-delete filtering support to Scout searches by introducing applyTrashed handling in ScoutBuilder, validates a new search.text.trashed parameter, and adds unit/feature tests for 'with' and 'only' trashed cases and validation for unknown values.

Changes

Cohort / File(s) Summary of Changes
Scout search trashed handling
src/Query/ScoutBuilder.php
Adds applyTrashed(string $trashed) mapping to withTrashed/onlyTrashed; invokes trashed handling during search when parameters['text']['trashed'] is set.
Validation rules
src/Rules/Search/SearchText.php
Adds Rule::in('with','only') for search.text.trashed; imports Illuminate\Validation\Rule; retains existing rules.
Feature tests for trashed search
tests/Feature/Controllers/SearchScoutOperationsTest.php
Adds tests for unknown trashed (422), with trashed, and only trashed search flows.
Unit tests for ScoutBuilder flow
tests/Unit/LaravelScoutTest.php
Refactors text input shape; adds tests verifying applyTrashed('with'/'only') invocation; ensures filters/sorts/instructions not called in these cases.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as Search Controller
  participant SB as ScoutBuilder
  participant Scout as Laravel Scout
  participant Index as Search Index/DB

  Client->>API: POST /.../search { search.text.value, search.text.trashed }
  API->>SB: search(parameters)
  SB->>SB: applyFilters(...)
  SB->>SB: applyTrashed(trashed)
  alt trashed == "with"
    SB->>Scout: withTrashed()
  else trashed == "only"
    SB->>Scout: onlyTrashed()
  else
    SB->>Scout: (no trashed modifier)
  end
  SB->>SB: applySorts(...), applyInstructions(...)
  Scout->>Index: Execute query
  Index-->>API: Results
  API-->>Client: Paginated response
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Assessment against linked issues

Objective Addressed Explanation
Enable withTrashed in Laravel Scout searches (#185)

Possibly related PRs

Poem

I twitch my ears at buried rows,
Now searches find what darkness chose.
With or only, softly tread—
We query whispers of the dead.
Hop, hop—the index knows!
A carrot for each ghostly thread.
🥕👻

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests

✅ Copyable Unit Test edits generated.

  • Create PR with unit tests
  • Commit unit tests in branch feature/scout-trashed
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
src/Query/ScoutBuilder.php (2)

132-139: Consider handling unknown trashed values explicitly.

The current implementation silently ignores unknown trashed values. While validation should prevent this, consider adding explicit handling for robustness.

 public function applyTrashed(string $trashed): void
 {
     if ($trashed === 'only') {
         $this->queryBuilder->onlyTrashed();
     } elseif ($trashed === 'with') {
         $this->queryBuilder->withTrashed();
+    } else {
+        throw new InvalidArgumentException("Invalid trashed parameter: {$trashed}. Expected 'with' or 'only'.");
     }
 }

25-32: Update docstring to include trashed parameter.

The method docstring should be updated to document the new trashed functionality.

  * @param array $parameters An associative array of search criteria, which may include:
- *                          - 'text': The search query string.
+ *                          - 'text': An array containing 'value' (search string) and optionally 'trashed' ('with'|'only').
  *                          - 'filters': An array of filter conditions.
  *                          - 'sorts': An array of sorting directives.
  *                          - 'instructions': Additional query instructions.
  *                          - 'limit': Maximum number of results to return (defaults to 50 if not provided).
tests/Feature/Controllers/SearchScoutOperationsTest.php (1)

430-455: Address the TODO comment and enhance test coverage.

The TODO comment indicates this test doesn't verify that the 'only' functionality works correctly. Consider enhancing the test to actually verify soft-delete behavior.

 public function test_getting_a_list_of_resources_only_trashed(): void
 {
-    ModelFactory::new()->count(2)->create();
+    $activeModels = ModelFactory::new()->count(2)->create();
+    $trashedModels = ModelFactory::new()->count(2)->create();
+    $trashedModels->each->delete(); // Soft delete these models

     Gate::policy(Model::class, GreenPolicy::class);

     $response = $this->post(
         '/api/searchable-models/search',
         [
             'search' => [
                 'text' => [
                     'trashed' => 'only',
                 ],
             ],
         ],
         ['Accept' => 'application/json']
     );

-    // @TODO: test only correctly applyed
-
     $this->assertResourcePaginated(
         $response,
-        [],
+        $trashedModels->toArray(), // Should only return trashed models
         new ModelResource()
     );
 }

Do you want me to help create a more comprehensive test that actually verifies the soft-delete filtering behavior?

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between acf8050 and 488e993.

📒 Files selected for processing (4)
  • src/Query/ScoutBuilder.php (2 hunks)
  • src/Rules/Search/SearchText.php (2 hunks)
  • tests/Feature/Controllers/SearchScoutOperationsTest.php (1 hunks)
  • tests/Unit/LaravelScoutTest.php (3 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
tests/Feature/Controllers/SearchScoutOperationsTest.php (2)
tests/Support/Policies/GreenPolicy.php (1)
  • GreenPolicy (8-100)
tests/Support/Traits/InteractsWithResource.php (1)
  • assertResourcePaginated (13-47)
src/Rules/Search/SearchText.php (2)
src/Rules/Search/SearchFilter.php (2)
  • buildValidationRules (12-59)
  • SearchFilter (10-60)
src/Rules/Search/Search.php (1)
  • buildValidationRules (11-36)
tests/Unit/LaravelScoutTest.php (1)
src/Query/ScoutBuilder.php (3)
  • toBase (200-203)
  • ScoutBuilder (11-204)
  • search (34-77)
🔇 Additional comments (6)
src/Rules/Search/SearchText.php (1)

21-23: Add 'sometimes' validation rule for optional trashed parameter.

The trashed parameter should be optional since not all searches need to filter by soft-deleted records. Add 'sometimes' to the validation rules to make it optional.

-            $attribute.'.trashed' => [
-                Rule::in('with', 'only'),
-            ],
+            $attribute.'.trashed' => [
+                'sometimes',
+                Rule::in('with', 'only'),
+            ],

Likely an incorrect or invalid review comment.

tests/Unit/LaravelScoutTest.php (2)

99-143: LGTM! Comprehensive unit test coverage for trashed functionality.

The new unit tests properly cover both 'with' and 'only' trashed scenarios. The tests correctly:

  • Mock ScoutBuilder dependencies
  • Verify applyTrashed is called with the correct parameter
  • Ensure other apply methods are not invoked when not needed
  • Follow existing test patterns in the file

50-50: LGTM! Input format refactoring improves consistency.

The change from text => [['value' => 'test']] to text => ['value' => 'test'] makes the input format more intuitive and consistent with the new trashed parameter structure.

Also applies to: 70-70, 90-90

src/Query/ScoutBuilder.php (1)

44-46: LGTM! Proper integration of trashed functionality.

The trashed parameter is correctly integrated into the search flow after filters and before sorts, which is the appropriate order for applying soft-delete filtering.

tests/Feature/Controllers/SearchScoutOperationsTest.php (2)

383-403: LGTM! Proper validation test for unknown trashed values.

The test correctly verifies that unknown trashed values result in a 422 validation error with the appropriate error structure.


405-428: LGTM! Test structure is correct.

The test properly verifies that the 'with' trashed parameter is accepted and returns a successful paginated response.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Note

Unit test generation is an Early Access feature. Expect some limitations and changes as we gather feedback and continue to improve it.


Generating unit tests... This may take up to 20 minutes.

coderabbitai bot added a commit that referenced this pull request Aug 11, 2025
Docstrings generation was requested by @GautierDele.

* #186 (comment)

The following files were modified:

* `src/Query/ScoutBuilder.php`
* `src/Rules/Search/SearchText.php`
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Note

Generated docstrings for this pull request at #187

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 11, 2025

Here are the copyable unit test edits:

Copyable Edits

tests/Feature/Controllers/SearchScoutOperationsAdditionalTest.php

This is a new file.

<?php

namespace Lomkit\Rest\Tests\Feature\Controllers;

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Gate;
use Lomkit\Rest\Tests\Feature\TestCase;
use Lomkit\Rest\Tests\Support\Database\Factories\ModelFactory;
use Lomkit\Rest\Tests\Support\Models\Model;
use Lomkit\Rest\Tests\Support\Policies\GreenPolicy;
use Lomkit\Rest\Tests\Support\Rest\Resources\ModelResource;

class SearchScoutOperationsAdditionalTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        // Use the null scout driver as in the primary test suite to ensure deterministic behavior.
        Config::set('scout.driver', 'null');
    }

    public function test_getting_a_list_of_resources_requires_search_root_object(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Missing 'search' entirely
        $response = $this->post(
            '/api/searchable-models/search',
            [],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        // Expect a top-level validation error referencing 'search'
        $response->assertExactJsonStructure(['message', 'errors' => ['search']]);
    }

    public function test_getting_a_list_of_resources_with_invalid_text_value_type(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Provide non-scalar type for 'value'
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => ['not', 'a', 'string'],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.text.value']]);
    }

    public function test_getting_a_list_of_resources_with_missing_filter_operator(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Missing operator in filter
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'filters' => [
                        ['field' => 'allowed_scout_field', 'value' => 2],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.filters.0.operator']]);
    }

    public function test_getting_a_list_of_resources_with_missing_filter_field(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Missing field in filter
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'filters' => [
                        ['operator' => '=', 'value' => 2],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.filters.0.field']]);
    }

    public function test_getting_a_list_of_resources_with_invalid_sort_direction(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Direction not in the accepted set (e.g., asc/desc)
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'sorts' => [
                        ['field' => 'allowed_scout_field', 'direction' => 'sideways'],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.sorts.0.direction']]);
    }

    public function test_getting_a_list_of_resources_with_allowed_sort_direction_desc(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Using allowed field with a typical 'desc' direction should succeed
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'sorts' => [
                        ['field' => 'allowed_scout_field', 'direction' => 'desc'],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $this->assertResourcePaginated(
            $response,
            [],
            new ModelResource()
        );
    }

    public function test_getting_a_list_of_resources_with_mixed_instructions_including_invalid(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // One allowed instruction ('numbered') and one invalid
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'instructions' => [
                        ['name' => 'numbered'],
                        ['name' => 'not_authorized_instruction'],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.instructions.1.name']]);
    }

    public function test_getting_a_list_of_resources_with_boolean_trashed_is_invalid(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Trashed accepts specific string values in the original tests; boolean should fail
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'trashed' => true,
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        $response->assertExactJsonStructure(['message', 'errors' => ['search.text.trashed']]);
    }

    public function test_getting_a_list_of_resources_with_empty_search_object(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // No text value provided at all; system may default to a regular listing
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    // Intentionally empty
                ],
            ],
            ['Accept' => 'application/json']
        );

        $this->assertResourcePaginated(
            $response,
            [],
            new ModelResource()
        );
    }

    public function test_getting_a_list_of_resources_with_unknown_field_and_operator_in_filter(): void
    {
        ModelFactory::new()->count(2)->create();
        Gate::policy(Model::class, GreenPolicy::class);

        // Simultaneously invalid field and invalid operator; expect at least field error
        $response = $this->post(
            '/api/searchable-models/search',
            [
                'search' => [
                    'text' => [
                        'value' => 'text',
                    ],
                    'filters' => [
                        ['field' => 'non_existing_field', 'operator' => '!=', 'value' => 1],
                    ],
                ],
            ],
            ['Accept' => 'application/json']
        );

        $response->assertStatus(422);
        // Validate we at least receive an error for the field; the implementation may also include operator error.
        $response->assertJsonStructure(['message', 'errors' => ['search.filters.0.field']]);
    }
}

tests/Unit/LaravelScoutTest.php

@@ -141,4 +141,208 @@
 
         ($scoutQueryBuilderMock->toBase()->queryCallback)(Model::query());
     }
+}
+    public function test_building_scout_with_all_parts_combined()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $expectedFilters = [
+            ['field' => 'status', 'value' => 'active'],
+            ['field' => 'priority', 'value' => 5],
+        ];
+        $expectedSorts = [
+            ['field' => 'created_at', 'direction' => 'desc'],
+            ['field' => 'id', 'direction' => 'asc'],
+        ];
+        $expectedInstructions = [
+            ['name' => 'boost_recent'],
+            ['name' => 'exclude_flagged'],
+        ];
+        $expectedTrashed = 'with';
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with($expectedFilters)->once();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with($expectedSorts)->once();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with($expectedInstructions)->once();
+        $scoutQueryBuilderMock->shouldReceive('applyTrashed')->with($expectedTrashed)->once();
+
+        $scoutQueryBuilderMock->search([
+            'text'         => ['value' => 'combine all'],
+            'filters'      => $expectedFilters,
+            'sorts'        => $expectedSorts,
+            'instructions' => $expectedInstructions,
+            // trashed lives under text
+            'text_extra'   => 'ignored',
+            'unknown'      => 'also_ignored',
+            'text_details' => 'also_ignored',
+            'text_options' => ['foo' => 'bar'],
+            'meta'         => ['should' => 'not break'],
+            'text'         => [
+                'value'   => 'combine all',
+                'trashed' => $expectedTrashed,
+            ],
+        ]);
+
+        // Verify the underlying base query text and the callback are still accessible
+        $this->assertSame('combine all', $scoutQueryBuilderMock->toBase()->query);
+        $this->assertIsCallable($scoutQueryBuilderMock->toBase()->queryCallback);
+    }
+
+    public function test_building_scout_with_multiple_filters_only()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $expectedFilters = [
+            ['field' => 'category', 'value' => 'books'],
+            ['field' => 'stock', 'value' => true],
+            ['field' => 'rating', 'value' => 4.5],
+        ];
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with($expectedFilters)->once();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with([])->never();
+
+        $scoutQueryBuilderMock->search([
+            'text'    => ['value' => 'inventory'],
+            'filters' => $expectedFilters,
+        ]);
+
+        $this->assertSame('inventory', $scoutQueryBuilderMock->toBase()->query);
+    }
+
+    public function test_building_scout_with_multiple_sorts_only()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $expectedSorts = [
+            ['field' => 'price', 'direction' => 'desc'],
+            ['field' => 'name', 'direction' => 'asc'],
+        ];
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with($expectedSorts)->once();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with([])->never();
+
+        $scoutQueryBuilderMock->search([
+            'text'  => ['value' => 'gadgets'],
+            'sorts' => $expectedSorts,
+        ]);
+
+        $this->assertSame('gadgets', $scoutQueryBuilderMock->toBase()->query);
+    }
+
+    public function test_building_scout_with_multiple_instructions_only()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $expectedInstructions = [
+            ['name' => 'prefer_in_stock'],
+            ['name' => 'demote_backordered'],
+        ];
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with($expectedInstructions)->once();
+
+        $scoutQueryBuilderMock->search([
+            'text'         => ['value' => 'status'],
+            'instructions' => $expectedInstructions,
+        ]);
+
+        // Invoke the callback to mirror existing pattern
+        ($scoutQueryBuilderMock->toBase()->queryCallback)(\Lomkit\Rest\Tests\Support\Models\Model::query());
+    }
+
+    public function test_building_scout_with_invalid_trashed_value_should_not_call_applyTrashed()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with([])->never();
+        // Invalid value should result in no call to applyTrashed
+        $scoutQueryBuilderMock->shouldReceive('applyTrashed')->never();
+
+        $scoutQueryBuilderMock->search([
+            'text' => [
+                'value'   => 'invalid trashed',
+                'trashed' => 'invalid_option',
+            ],
+        ]);
+
+        // The query should still be set to provided text
+        $this->assertSame('invalid trashed', $scoutQueryBuilderMock->toBase()->query);
+    }
+
+    public function test_building_scout_without_text_value_sets_empty_query_and_skips_mutations()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyTrashed')->never();
+
+        // Provide an empty 'text' array or missing 'value'
+        $scoutQueryBuilderMock->search([
+            'text' => [],
+        ]);
+
+        // We cannot be certain of default behavior; assert it's either empty string or null.
+        $this->assertTrue(
+            $scoutQueryBuilderMock->toBase()->query === '' ||
+            $scoutQueryBuilderMock->toBase()->query === null,
+            'Expected empty or null query when no text value provided.'
+        );
+    }
+
+    public function test_building_scout_ignores_unknown_top_level_keys()
+    {
+        // Framework: PHPUnit with Mockery
+        \Illuminate\Support\Facades\Auth::setUser(\Mockery::mock(\Lomkit\Rest\Tests\Support\Models\User::class));
+        \Illuminate\Support\Facades\Gate::policy(\Lomkit\Rest\Tests\Support\Models\Model::class, \Lomkit\Rest\Tests\Support\Policies\GreenPolicy::class);
+
+        $scoutQueryBuilderMock = \Mockery::mock(\Lomkit\Rest\Query\ScoutBuilder::class, [new \Lomkit\Rest\Tests\Support\Rest\Resources\SearchableModelResource()])->makePartial();
+
+        $scoutQueryBuilderMock->shouldReceive('applyFilters')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applySorts')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyInstructions')->with([])->never();
+        $scoutQueryBuilderMock->shouldReceive('applyTrashed')->never();
+
+        $scoutQueryBuilderMock->search([
+            'text'     => ['value' => 'just text'],
+            'page'     => 5,
+            'perPage'  => 50,
+            'cursor'   => 'abc123',
+            'random'   => ['nested' => 'value'],
+            'metadata' => 'ignored',
+        ]);
+
+        $this->assertSame('just text', $scoutQueryBuilderMock->toBase()->query);
+        $this->assertIsCallable($scoutQueryBuilderMock->toBase()->queryCallback);
+    }
+
 }

tests/Unit/ScoutBuilderTest.php

This is a new file.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use Lomkit\Rest\Query\ScoutBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * NOTE: Test framework: PHPUnit (standard Laravel package setup).
 *
 * These tests use PHPUnit mocks/doubles to isolate ScoutBuilder from Laravel/Scout and Eloquent.
 * We model just the methods ScoutBuilder calls on the underlying builder and resource.
 *
 * If the project uses Pest, these tests remain compatible since Pest runs PHPUnit tests.
 */
class ScoutBuilderTest extends TestCase
{
    /**
     * Minimal interface for the methods used on the underlying Laravel Scout Builder.
     */
    interface MinimalScoutLikeBuilder
    {
        // property access 'query' is used by class under test; we simulate via public prop on a stdClass.
        public function take($limit);
        public function query(callable $callback);
        public function where($field, $value);
        public function whereIn($field, $values);
        public function whereNotIn($field, $values);
        public function orderBy($field, $direction);
        public function onlyTrashed();
        public function withTrashed();
    }

    /**
     * Minimal interface for the QueryBuilder dependency resolved inside search().
     * We only care about disableSecurity()->search($params).
     */
    interface MinimalInnerQueryBuilder
    {
        public function disableSecurity();
        public function search(array $parameters = []);
    }

    /**
     * Minimal Resource contract as exercised by ScoutBuilder.
     */
    interface MinimalResource
    {
        public static function newModel();
        public function searchScoutQuery($restRequest, $builder);
        public function scoutInstruction($restRequest, string $name);
        // defaultLimit may exist as a public property; we emulate via magic or stdClass composition.
    }

    /**
     * Helper to create a stub "model" class with static search() returning our mocked builder.
     */
    private function makeModelClassReturningBuilder(object $builderInstance): string
    {
        $className = 'ModelFake_' . uniqid();
        eval('
            namespace Tests\Unit;
            class ' . $className . ' {
                public static $builder;
                public static function search() {
                    return self::$builder;
                }
            }
        ');
        $fqcn = __NAMESPACE__ . '\\' . $className;
        $fqcn::$builder = $builderInstance;
        return $fqcn;
    }

    /**
     * Helper to create a Resource stub class pointing to our model class and exposing optional defaultLimit.
     */
    private function makeResourceClass(string $modelFqcn, ?int $defaultLimit = null, ?object $resourceStateCarrier = null): string
    {
        $className = 'ResourceFake_' . uniqid();
        $limitCode = $defaultLimit !== null ? 'public $defaultLimit = ' . $defaultLimit . ';' : '';
        $statePropCode = '$this->stateCarrier = $stateCarrier;';
        eval('
            namespace Tests\Unit;
            class ' . $className . ' implements \Tests\Unit\ScoutBuilderTest\MinimalResource {
                ' . $limitCode . '
                public $stateCarrier;
                public function __construct($stateCarrier = null) { ' . $statePropCode . ' }

                public static function newModel() {
                    return \\' . ltrim($modelFqcn, '\\') . ';
                }

                public function searchScoutQuery($restRequest, $builder) {
                    if ($this->stateCarrier) { $this->stateCarrier->searchScoutQueryCalled = [$restRequest, $builder]; }
                    return $builder;
                }

                public function scoutInstruction($restRequest, string $name) {
                    // Return an object with handleScout method
                    $stateCarrier = $this->stateCarrier;
                    return new class($stateCarrier, $name) {
                        public $stateCarrier;
                        public $name;
                        public function __construct($stateCarrier, $name) { $this->stateCarrier = $stateCarrier; $this->name = $name; }
                        public function handleScout(array $fields, $builder) {
                            if ($this->stateCarrier) {
                                $this->stateCarrier->lastInstruction = [
                                    'name' => $this->name,
                                    'fields' => $fields,
                                    'builder' => $builder,
                                ];
                            }
                            return $builder;
                        }
                    };
                }
            }
        ');
        return __NAMESPACE__ . '\\' . $className;
    }

    /**
     * Create a minimal builder double with spies on method calls and a public "query" property.
     */
    private function makeBuilderSpy(): object
    {
        $spy = new class implements \Tests\Unit\ScoutBuilderTest\MinimalScoutLikeBuilder {
            public array $calls = [];
            public $query = null; // Simulate the property used by ScoutBuilder

            public function take($limit) { $this->calls[] = ['take', $limit]; return $this; }
            public function query(callable $callback) { $this->calls[] = ['query', $callback]; $callback(new class {
                // Represents Eloquent\Builder passed into callback; it is forwarded into app()->make(QueryBuilder...)
            }); return $this; }
            public function where($field, $value) { $this->calls[] = ['where', $field, $value]; return $this; }
            public function whereIn($field, $values) { $this->calls[] = ['whereIn', $field, $values]; return $this; }
            public function whereNotIn($field, $values) { $this->calls[] = ['whereNotIn', $field, $values]; return $this; }
            public function orderBy($field, $direction) { $this->calls[] = ['orderBy', $field, $direction]; return $this; }
            public function onlyTrashed() { $this->calls[] = ['onlyTrashed']; return $this; }
            public function withTrashed() { $this->calls[] = ['withTrashed']; return $this; }
        };

        return $spy;
    }

    /**
     * Since the class under test calls app()->make(...), we provide a tiny app() shim
     * that returns a container with make() handling RestRequest and QueryBuilder.
     *
     * We implement a global function app() in this namespace scope for the tests.
     */
    public static $container = null;

    public static function setUpBeforeClass(): void
    {
        // Define app() only once for this test namespace, if not already defined.
        if (!function_exists(__NAMESPACE__ . '\\app')) {
            eval('
                namespace ' . __NAMESPACE__ . ';
                function app() {
                    return \\' . __CLASS__ . '::$container;
                }
            ');
        }
    }

    protected function setUp(): void
    {
        self::$container = new class {
            public $bindings = [];
            public function make($abstract, $parameters = [])
            {
                if ($abstract === \Lomkit\Rest\Http\Requests\RestRequest::class) {
                    // Minimal RestRequest double
                    return (object)['__type' => 'RestRequestDouble'];
                }
                if ($abstract === \Lomkit\Rest\Contracts\QueryBuilder::class) {
                    // Provide a MinimalInnerQueryBuilder double that captures search() parameters
                    $double = new class implements \Tests\Unit\ScoutBuilderTest\MinimalInnerQueryBuilder {
                        public array $calls = [];
                        public function disableSecurity() { $this->calls[] = ['disableSecurity']; return $this; }
                        public function search(array $parameters = []) { $this->calls[] = ['search', $parameters]; return $this; }
                    };
                    // Provide access for assertions
                    $this->bindings['innerQueryBuilder'] = $double;
                    return $double;
                }
                // Otherwise return null by default; tests shouldn't request other abstractions.
                return null;
            }
        };
    }

    public function test_construct_initializes_query_builder_from_resource_model_search(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);

        $resource = new $resourceClass();
        $sut = new ScoutBuilder($resource);

        $this->assertSame($builder, $sut->toBase());
    }

    public function test_search_sets_query_text_from_parameters_and_applies_default_limit_when_none_provided(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $state = (object)[];
        $resourceClass = $this->makeResourceClass($modelClass, null, $state);
        $resource = new $resourceClass($state);

        $sut = new ScoutBuilder($resource);

        $params = [
            'text' => ['value' => 'telescope'],
            // no limit provided -> should default to 50 or resource->defaultLimit if set
        ];

        $sut->search($params);

        $this->assertSame('telescope', $builder->query, 'Query text should be set on builder->query');
        // take() must be called with default 50 (resource has no defaultLimit here)
        $this->assertContains(['take', 50], $builder->calls, 'Default limit 50 should be applied');
        // Verify searchScoutQuery was invoked
        $this->assertArrayHasKey(0, $state);
        $this->assertNotEmpty($state->searchScoutQueryCalled ?? null);
    }

    public function test_search_uses_resource_default_limit_when_provided(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $state = (object)[];
        $resourceClass = $this->makeResourceClass($modelClass, 25, $state);
        $resource = new $resourceClass($state);

        $sut = new ScoutBuilder($resource);

        $params = [
            'text' => ['value' => 'laravel'],
            // no limit -> should use defaultLimit=25 on resource
        ];

        $sut->search($params);

        $this->assertSame('laravel', $builder->query);
        $this->assertContains(['take', 25], $builder->calls, 'Resource defaultLimit should be applied');
    }

    public function test_search_honors_explicit_limit_parameter_over_default(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass, 25);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $params = [
            'text' => ['value' => 'scout'],
            'limit' => 5,
        ];

        $sut->search($params);

        $this->assertSame('scout', $builder->query);
        $this->assertContains(['take', 5], $builder->calls, 'Explicit limit should override defaults');
    }

    public function test_apply_filters_supports_where_in_and_where_not_in_and_default_where(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $sut->applyFilters([
            ['field' => 'status', 'operator' => 'in', 'value' => ['active', 'pending']],
            ['field' => 'id', 'operator' => 'not in', 'value' => [1, 2, 3]],
            ['field' => 'type', 'operator' => '=', 'value' => 'post'], // will fall to default where(field, value)
        ]);

        $this->assertContains(['whereIn', 'status', ['active', 'pending']], $builder->calls);
        $this->assertContains(['whereNotIn', 'id', [1, 2, 3]], $builder->calls);
        $this->assertContains(['where', 'type', 'post'], $builder->calls);
    }

    public function test_apply_sorts_chains_order_by_with_default_direction_asc_and_custom_desc(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $sut->applySorts([
            ['field' => 'created_at'], // default asc
            ['field' => 'name', 'direction' => 'desc'],
        ]);

        $this->assertContains(['orderBy', 'created_at', 'asc'], $builder->calls);
        $this->assertContains(['orderBy', 'name', 'desc'], $builder->calls);
    }

    public function test_apply_trashed_with_and_only(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $sut->applyTrashed('with');
        $sut->applyTrashed('only');
        $sut->applyTrashed('invalid'); // should do nothing

        $this->assertContains(['withTrashed'], $builder->calls);
        $this->assertContains(['onlyTrashed'], $builder->calls);

        // ensure no extra trashed calls beyond the two expected
        $trashedCalls = array_values(array_filter($builder->calls, function ($c) {
            return $c[0] === 'withTrashed' || $c[0] === 'onlyTrashed';
        }));
        $this->assertCount(2, $trashedCalls);
    }

    public function test_instruction_maps_fields_and_calls_resource_instruction_handler(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $state = (object)[];
        $resourceClass = $this->makeResourceClass($modelClass, null, $state);
        $resource = new $resourceClass($state);

        $sut = new ScoutBuilder($resource);

        $sut->instruction('boost', [
            ['name' => 'title_boost', 'value' => 3],
            ['name' => 'body_boost', 'value' => 1],
        ]);

        $this->assertNotEmpty($state->lastInstruction ?? null, 'Expected instruction handler to be called');
        $this->assertSame('boost', $state->lastInstruction['name'] ?? null);
        $this->assertSame(['title_boost' => 3, 'body_boost' => 1], $state->lastInstruction['fields'] ?? null);
        $this->assertSame($builder, $state->lastInstruction['builder'] ?? null);
    }

    public function test_apply_instructions_invokes_instruction_for_each_entry(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $state = (object)[];
        $resourceClass = $this->makeResourceClass($modelClass, null, $state);
        $resource = new $resourceClass($state);

        $sut = new ScoutBuilder($resource);

        $sut->applyInstructions([
            ['name' => 'boost', 'fields' => [['name' => 'score', 'value' => 10]]],
            ['name' => 'filter_tags', 'fields' => [['name' => 'tag', 'value' => 'php']]],
        ]);

        // Only the last instruction remains recorded; assert it is the second one and mapping is correct.
        $this->assertSame('filter_tags', $state->lastInstruction['name'] ?? null);
        $this->assertSame(['tag' => 'php'], $state->lastInstruction['fields'] ?? null);
    }

    public function test_search_passes_non_reserved_parameters_to_inner_query_builder_search(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $params = [
            'text' => ['value' => 'eloquent'],
            'filters' => [['field' => 'status', 'operator' => '=', 'value' => 'active']],
            'instructions' => [
                ['name' => 'boost', 'fields' => [['name' => 'title', 'value' => 5]]],
            ],
            'sorts' => [['field' => 'created_at', 'direction' => 'desc']],
            'limit' => 10,
            // extras:
            'foo' => 'bar',
            'page' => 3,
            'include' => ['comments', 'author'],
        ];

        $sut->search($params);

        // The inner query builder double is stored in container bindings
        $inner = self::$container->bindings['innerQueryBuilder'];
        $calls = $inner->calls;
        // We expect disableSecurity then search with only the extras (foo, page, include)
        $this->assertSame('disableSecurity', $calls[0][0] ?? null);
        $this->assertSame('search', $calls[1][0] ?? null);
        $forwarded = $calls[1][1] ?? [];
        $this->assertSame(['foo' => 'bar', 'page' => 3, 'include' => ['comments', 'author']], $forwarded);
    }

    public function test_search_applies_text_trashed_variants(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $sut->search(['text' => ['value' => 'eloquent', 'trashed' => 'with']]);
        $sut->search(['text' => ['value' => 'eloquent', 'trashed' => 'only']]);

        // ensure trashed variants called at least once each over the two searches
        $with = array_filter($builder->calls, fn($c) => $c[0] === 'withTrashed');
        $only = array_filter($builder->calls, fn($c) => $c[0] === 'onlyTrashed');
        $this->assertNotEmpty($with, 'withTrashed should be called when trashed=with');
        $this->assertNotEmpty($only, 'onlyTrashed should be called when trashed=only');
    }

    public function test_to_base_returns_underlying_builder(): void
    {
        $builder = $this->makeBuilderSpy();
        $modelClass = $this->makeModelClassReturningBuilder($builder);
        $resourceClass = $this->makeResourceClass($modelClass);
        $resource = new $resourceClass();

        $sut = new ScoutBuilder($resource);

        $this->assertSame($builder, $sut->toBase());
    }
}

tests/Unit/SearchTextTest.php

This is a new file.

<?php

declare(strict_types=1);

namespace Tests\Unit;

use Illuminate\Validation\Rule;
use PHPUnit\Framework\TestCase;
use Lomkit\Rest\Rules\Search\SearchText;

/**
 * Note: Testing library/framework: PHPUnit.
 *
 * These tests focus specifically on SearchText::buildValidationRules behavior as surfaced in the diff.
 * We validate both branches (searchable vs not-searchable) and multiple value scenarios.
 *
 * We avoid external Laravel bootstrapping by testing pure array outputs of buildValidationRules.
 */
final class SearchTextTest extends TestCase
{
    /**
     * Create a SearchText with a stubbed resource that controls isModelSearchable() returns.
     *
     * We rely on RestRule exposing a resource property or a compatible constructor/setter.
     * If RestRule changes, adjust this helper accordingly.
     */
    private function makeRuleWithSearchableFlag(bool $isSearchable): SearchText
    {
        // Anonymous class to stub resource contract with isModelSearchable()
        $resourceStub = new class($isSearchable) {
            private bool $flag;
            public function __construct(bool $flag) { $this->flag = $flag; }
            public function isModelSearchable(): bool { return $this->flag; }
        };

        // Instantiate rule
        $rule = new class extends SearchText {
            // Expose a way to inject resource even if RestRule keeps it protected
            public function forceSetResource($resource): void
            {
                // Assuming RestRule defines $resource as protected; adjust if API differs
                $this->resource = $resource;
            }
        };

        $rule->forceSetResource($resourceStub);

        return $rule;
    }

    public function test_returns_prohibited_when_resource_is_not_searchable(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(false);

        $attribute = 'search';
        $result = $rule->buildValidationRules($attribute, null);

        // Expect structure: ['search' => ['prohibited']]
        $this->assertIsArray($result);
        $this->assertArrayHasKey($attribute, $result);
        $this->assertSame(['prohibited'], $result[$attribute]);

        // Must not include nested keys when prohibited
        $this->assertArrayNotHasKey($attribute . '.value', $result);
        $this->assertArrayNotHasKey($attribute . '.trashed', $result);
    }

    public function test_returns_validation_rules_when_resource_is_searchable(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(true);

        $attribute = 'search';
        $result = $rule->buildValidationRules($attribute, null);

        // Base key exists and is an array rule with 'sometimes' and 'array'
        $this->assertIsArray($result);
        $this->assertArrayHasKey($attribute, $result);
        $this->assertIsArray($result[$attribute]);
        $this->assertContains('sometimes', $result[$attribute], 'Expected sometimes rule at top-level attribute');
        $this->assertContains('array', $result[$attribute], 'Expected array rule at top-level attribute');

        // Value rule
        $this->assertArrayHasKey($attribute . '.value', $result);
        $this->assertIsArray($result[$attribute . '.value']);
        $this->assertContains('nullable', $result[$attribute . '.value']);
        $this->assertContains('string', $result[$attribute . '.value']);

        // Trashed rule (Rule::in)
        $this->assertArrayHasKey($attribute . '.trashed', $result);
        $trashedRule = $result[$attribute . '.trashed'];
        $this->assertIsArray($trashedRule);
        // Ensure Rule::in is present and configured with the expected values "with" and "only"
        $this->assertNotEmpty($trashedRule, 'Trashed rule should not be empty');
        $foundInRule = false;
        foreach ($trashedRule as $r) {
            // We cannot directly assert equality on Rule::in due to internal state,
            // but we can at least assert the generated object is of the correct type.
            if ($r instanceof \Illuminate\Validation\Rules\In || $r instanceof Rule) {
                $foundInRule = true;
                break;
            }
            // Some Illuminate versions may store Rule::in(...) as a stringable or array callback; keep a broad check.
        }
        $this->assertTrue($foundInRule, 'Expected Rule::in to be part of trashed rules');
    }

    public function test_different_attribute_names_are_supported(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(true);

        $attribute = 'filters.searchText';
        $result = $rule->buildValidationRules($attribute, null);

        $this->assertArrayHasKey($attribute, $result);
        $this->assertArrayHasKey($attribute . '.value', $result);
        $this->assertArrayHasKey($attribute . '.trashed', $result);
    }

    public function test_null_value_is_allowed_for_value_when_searchable(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(true);

        $attribute = 'search';
        $result = $rule->buildValidationRules($attribute, null);

        // Just checks rules indicate 'nullable' for value
        $this->assertContains('nullable', $result[$attribute . '.value']);
    }

    public function test_trashed_accepts_only_with_or_only_when_searchable(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(true);

        $attribute = 'search';
        $result = $rule->buildValidationRules($attribute, null);
        $trashedRule = $result[$attribute . '.trashed'];

        // We can't evaluate Rule::in contents without running Validator,
        // but we ensure Rule::in type is present. For completeness, verify no extraneous rules exist.
        $this->assertIsArray($trashedRule);
        $this->assertNotEmpty($trashedRule);
        $this->assertCount(1, $trashedRule, 'Expected a single Rule::in constraint for trashed.');
    }

    public function test_when_not_searchable_no_other_rules_are_emitted(): void
    {
        $rule = $this->makeRuleWithSearchableFlag(false);
        $attribute = 'search';
        $result = $rule->buildValidationRules($attribute, 'ignored');

        $this->assertSame(['prohibited'], $result[$attribute]);
        $this->assertCount(1, $result, 'No additional nested keys should be provided when prohibited');
    }
}

@GautierDele GautierDele merged commit 394f6e9 into main Aug 11, 2025
21 checks passed
@GautierDele GautierDele deleted the feature/scout-trashed branch August 11, 2025 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to use withTrashed with Laravel Scout

3 participants