> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing

> Three-line tests, an Action for any method, and a sweep of a real site.

## Three lines

```php tests/Feature/BlogTest.php theme={"dark"}
use Fetchski\AttractCmsLaravel\Facades\Attract;

public function test_it_reads_a_post(): void
{
    Attract::fake();
    Attract::blogPosts()->getBlog(id: 'example-id');
    Attract::assertCalled('blogPosts.getBlog');
}
```

> [!TIP]
>
> Nothing is sent: no URL patterns, keys or JSON, and no config needed. Every call answers a valid model,
> and `Attract::site('any-slug')` is a faked site. It works the same in Pest.

## Answers

Give a method its answer by key, `<accessor>.<method>`:

```php A model theme={"dark"}
use Fetchski\AttractCmsLaravel\Data\Blog;

Attract::fake(['blogPosts.getBlog' => Blog::factory()->make(['title' => 'Hi'])]);
```

```php A refusal theme={"dark"}
Attract::fake(['testimonials.updateTestimonial' => Attract::validationError(['quote' => 'Required.'])]);
```

```php A closure theme={"dark"}
Attract::fake(['blogPosts.getBlog' => fn (array $args, string $site) => $args['id'] === 'gone'
    ? Attract::notFound()
    : Blog::factory()->make()]);
```

```php Pages in turn theme={"dark"}
use Fetchski\AttractCmsLaravel\Data\BlogList;

Attract::fake(['blogPosts.listBlogs' => Attract::sequence(
    BlogList::factory()->make(['pages' => 2]),
    BlogList::factory()->make(['pages' => 2, 'currentPage' => 2]),
)]);
```

> [accordion] Every kind of answer
>
> A model, a wire array, a closure `fn (array $args, string $site) => …`, an exception (thrown), or
> `Attract::sequence($first, $second)` (each call takes the next). `blogPosts.*` answers a whole accessor. A key
> that is no method throws, suggesting the closest one.

> [accordion] Answers in turn: sequence()
>
> `Attract::sequence($page1, $page2)` takes each answer as its own argument (not an array); each call takes
> the next. One call past the end throws a `LogicException`: "The sequence for blogPosts.listBlogs ran
> out." Give one answer per call you expect.

> [accordion] Errors, as the real SDK throws them
>
> `Attract::validationError([...])`, `notFound()`, `forbidden()`, `rateLimited(30)`,
> `unreachable()`, `siteError(500)`: the real exception classes, for the faked site and route.

> [accordion] Factories
>
> Every answer class has `::factory()->make([...])`, `makeMany(3)` and `raw()` (the wire array),
> filled with values the SDK's own answer check accepts. Override by property name, with SDK values
> (Carbon, a model, a Collection, an enum case) or as the API sends them: a date as an ISO 8601
> string or `now()`, an enum as its string, a list as a plain array. A plain array for a nested model
> or a list of them is filled in over that model's own example, so only what you name changes.

> [accordion] Walks, finds, jobs and request()
>
> A `listBlogsAll()` walk is answered through `blogPosts.listBlogs`, a `findBlog()` through
> `blogPosts.getBlog`, `request()` through the key `request`. A job's `wait()` needs the management
> SDK, under the fake too.

## Your own values, nested

Fake one answer by key, override a nested field with a plain array, and assert the call:

```php tests/Feature/MediaTest.php theme={"dark"}
use Fetchski\AttractCmsLaravel\Data\ListMediaImagesResponse;
use Fetchski\AttractCmsLaravel\Facades\Attract;

public function test_it_lists_office_images(): void
{
    Attract::fake([
        'fetchskiMedia.listMediaImages' => ListMediaImagesResponse::factory()->make([
            'results' => [['title' => 'Office', 'urls' => ['full' => 'https://cdn.example/office.jpg']]],
        ]),
    ]);

    $images = Attract::fetchskiMedia()->listMediaImages(q: 'office');

    $this->assertSame('https://cdn.example/office.jpg', $images->results->first()->urls->full);
    Attract::assertCalled('fetchskiMedia.listMediaImages', fn (array $args) => $args['q'] === 'office');
}
```

Each item of `results` is a plain array filled in over `MediaImage`'s example, like any nested model,
so every field you don't name is still valid.

## Assertions

| Assertion | Passes when |
| - | - |
| `Attract::assertCalled($key, fn (array $args, string $site) => …)` | the method was called (and the closure agrees) |
| `Attract::assertCalledTimes($key, 2)` | it was called exactly that often |
| `Attract::assertNotCalled($key)` | it was never called |
| `Attract::assertNothingCalled()` | no method was |

A failure lists what was called.

## Scaffold an Action

```bash theme={"dark"}
php artisan make:attract-action blogPosts.getBlog
```

> [tab] The Action
>
> ```php app/Actions/Attract/GetBlog.php theme={"dark"}
> final class GetBlog
> {
>     public function __construct(private readonly AttractManager $attract) {}
>
>     public function __invoke(
>         string $id,
>         AposMode|string|null $aposMode = null,
>         ?string $aposLocale = null,
>         ?bool $renderAreas = null,
>     ): Blog {
>         return $this->attract->blogPosts()->getBlog(
>             id: $id,
>             aposMode: $aposMode,
>             aposLocale: $aposLocale,
>             renderAreas: $renderAreas,
>         );
>     }
> }
> ```

> [tab] Its test
>
> `tests/Feature/Actions/Attract/GetBlogTest.php`: fakes the SDK, calls the Action, asserts the call
> and the answer's type. Pest when it is installed (or `--pest`), else PHPUnit (`--phpunit`).

> [tab] Options
>
> No method: search them all. `--site=acme` targets one site; `--force` overwrites. The stubs are
> yours to change. Publish them into your app's `stubs/` with:
>
> ```bash Terminal theme={"dark"}
> php artisan vendor:publish --tag=attract-stubs
> ```
>
> `--module=Billing` writes into `modules/Billing/` instead (`Actions/Attract/`, its test under
> `Tests/Feature/Actions/Attract/`), as does the config's `module` (`FETCHSKI_ATTRACT_MODULE`) when
> set; `--app` writes into the app anyway. A module that doesn't exist yet is refused.

## Engine calls

`Attract::fake()` fakes the SDK's methods, not the engine: an `api()` call under it throws, saying so.
For those, use `Http::fake()` with `Sleep::fake()` (the retry waits go through Laravel's `Sleep`), and
a test key that keeps to a real key's characters:

```text API key theme={"dark"}
fsk_acme_0a1b2c3d_not-a-real-secret
```

```text Signing secret theme={"dark"}
fss_not-a-real-secret
```

## Sweeping a real site

```bash theme={"dark"}
php artisan attract:sweep --site=acme
```

It calls every read the site offers (each list, then up to three documents by id) and reports ok,
refused or broken for each; it exits 1 when something broke. A health check.

> [card] [Next: keeping keys secret](https://docs.fetchski-attract.com/laravel/security.md)
>
> Where a key never shows up, and the one place it does.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.