diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index 6883abded3..9c49a35465 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -67,6 +67,7 @@ jobs: services: mysql: image: mysql:8.4 + command: --gtid-mode=ON --enforce-gtid-consistency=ON env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing @@ -167,6 +168,7 @@ jobs: services: mariadb: image: mariadb:10.11 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing @@ -214,6 +216,7 @@ jobs: services: mariadb: image: mariadb:11 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing @@ -261,6 +264,7 @@ jobs: services: mariadb: image: mariadb:12.3 + command: --log-bin --server-id=1 env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing diff --git a/AGENTS.md b/AGENTS.md index 510310e205..cfd7f66525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,11 +121,11 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. -**At the end of a coherent implementation slice**, run `composer analyse` and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection. +At the end of a coherent implementation slice, run `composer analyse` and any outstanding tests needed to verify the changed behavior and affected callers. Use PHPUnit for one test file or `composer test:parallel -- --processes=4` for multiple files, selecting the relevant files or using `--filter`. Reuse passing results unless subsequent changes invalidate them. Run `composer lint:fix` before a scheduled code review or commit checkpoint, not after individual changes within a batch. -Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows. +Run only the local tests needed to give strong confidence in the change. Leave full suites and CI matrices to CI, unless a specific unresolved issue requires broader local testing. After a failure or review correction, fix the cause, repeat only checks affected by the correction, and finish any outstanding verification. Do not restart the entire sequence merely because another review round occurred. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790220bbba..972388451b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2445,8 +2445,8 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -5090,7 +5090,7 @@ snapshots: axios@1.20.0(debug@4.4.3): dependencies: - follow-redirects: 1.16.1(debug@4.4.3) + follow-redirects: 1.16.2(debug@4.4.3) form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -5335,7 +5335,7 @@ snapshots: flatted@3.4.4: {} - follow-redirects@1.16.1(debug@4.4.3): + follow-redirects@1.16.2(debug@4.4.3): optionalDependencies: debug: 4.4.3 diff --git a/src/ai/src/Gateway/Concerns/AnswersQuestions.php b/src/ai/src/Gateway/Concerns/AnswersQuestions.php index d1e8198597..1595018b44 100644 --- a/src/ai/src/Gateway/Concerns/AnswersQuestions.php +++ b/src/ai/src/Gateway/Concerns/AnswersQuestions.php @@ -65,14 +65,14 @@ public function classify( } } - return new ClassificationResponse( + return (new ClassificationResponse( $answers, new TextUsage( inputTokens: $data['usage']['input_tokens'] ?? 0, outputTokens: $data['usage']['output_tokens'] ?? 0, ), new Meta($provider->name(), $this->answeringModel($data, $model)), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php b/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php index 23d5ececae..1a32f36f9a 100644 --- a/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php +++ b/src/ai/src/Gateway/Concerns/HandlesFailoverErrors.php @@ -41,16 +41,16 @@ protected function withErrorHandling(string $providerName, Closure $callback): m } catch (RequestException $requestException) { $status = $requestException->response->status(); - if ($status === 429) { - throw RateLimitedException::forProvider( + if ($this->isInsufficientCredits($requestException)) { + throw InsufficientCreditsException::forProvider( $providerName, $requestException->getCode(), $requestException ); } - if ($status === 402) { - throw InsufficientCreditsException::forProvider( + if ($status === 429) { + throw RateLimitedException::forProvider( $providerName, $requestException->getCode(), $requestException @@ -65,8 +65,10 @@ protected function withErrorHandling(string $providerName, Closure $callback): m ); } - if ($patterns = $this->insufficientCreditPatterns()) { - $message = strtolower($requestException->response->json('error.message', '')); + $message = $requestException->response->json('error.message'); + + if (is_string($message) && $patterns = $this->insufficientCreditPatterns()) { + $message = strtolower($message); foreach ($patterns as $pattern) { if (str_contains($message, (string) $pattern)) { @@ -92,6 +94,10 @@ protected function retryAfterSeconds(Throwable $exception): ?int return null; } + if ($this->isInsufficientCredits($exception)) { + return null; + } + $status = $exception->response->status(); if ($status !== 429 && ! in_array($status, $this->overloadedStatusCodes(), true)) { @@ -103,6 +109,27 @@ protected function retryAfterSeconds(Throwable $exception): ?int return ctype_digit($retryAfter) && (int) $retryAfter <= 10 ? (int) $retryAfter : null; } + /** + * Determine if the response is a 402, or a 429 insufficient credits or quota error rather than a rate limit. + */ + protected function isInsufficientCredits(RequestException $exception): bool + { + $status = $exception->response->status(); + + if ($status === 402) { + return true; + } + + if ($status !== 429) { + return false; + } + + $codes = $this->insufficientCreditCodes(); + + return in_array($exception->response->json('error.code'), $codes, true) + || in_array($exception->response->json('error.type'), $codes, true); + } + /** * Get the status codes that indicate a provider is transiently unavailable and the request should fail over. * @@ -122,4 +149,21 @@ protected function insufficientCreditPatterns(): array { return []; } + + /** + * Get the error codes or types that indicate insufficient credits or quota on a 429 response. + * + * @return list + */ + protected function insufficientCreditCodes(): array + { + // OpenAI sends these with a 429 status, and may still use "insufficient_quota" as the error type. + return [ + 'insufficient_quota', + 'credit_balance_exhausted', + 'organization_spend_limit_exceeded', + 'project_spend_limit_exceeded', + 'organization_usage_limit_exceeded', + ]; + } } diff --git a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php index 20da95169c..4748b47a2f 100644 --- a/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php +++ b/src/ai/src/Gateway/OpenAi/OpenAiClassificationGateway.php @@ -73,14 +73,14 @@ public function classify( } } - return new ClassificationResponse( + return (new ClassificationResponse( $answers, new TextUsage( inputTokens: $data['usage']['input_tokens'] ?? 0, outputTokens: $data['usage']['output_tokens'] ?? 0, ), new Meta($provider->name(), $data['model'] ?? $model), - ); + ))->withRawResponse($response); } /** diff --git a/src/ai/src/Providers/MistralProvider.php b/src/ai/src/Providers/MistralProvider.php index 050559a8fd..2645315e0d 100644 --- a/src/ai/src/Providers/MistralProvider.php +++ b/src/ai/src/Providers/MistralProvider.php @@ -90,7 +90,7 @@ public function transcriptionGateway(): TranscriptionGateway */ public function defaultTextModel(): string { - return $this->config['models']['text']['default'] ?? 'mistral-large-2512'; + return $this->config['models']['text']['default'] ?? 'mistral-large-4-0'; } /** @@ -106,7 +106,7 @@ public function cheapestTextModel(): string */ public function smartestTextModel(): string { - return $this->config['models']['text']['smartest'] ?? 'mistral-medium-3-5'; + return $this->config['models']['text']['smartest'] ?? 'mistral-large-4-0'; } /** @@ -114,7 +114,7 @@ public function smartestTextModel(): string */ public function defaultAudioModel(): string { - return $this->config['models']['audio']['default'] ?? 'voxtral-mini-tts-2603'; + return $this->config['models']['audio']['default'] ?? 'voxtral-mini-tts-3-0'; } /** @@ -122,7 +122,7 @@ public function defaultAudioModel(): string */ public function defaultTranscriptionModel(): string { - return $this->config['models']['transcription']['default'] ?? 'voxtral-mini-2602'; + return $this->config['models']['transcription']['default'] ?? 'voxtral-mini-transcribe-3-0'; } /** diff --git a/src/ai/src/Responses/ClassificationResponse.php b/src/ai/src/Responses/ClassificationResponse.php index 3cc7701ade..1f9d4da098 100644 --- a/src/ai/src/Responses/ClassificationResponse.php +++ b/src/ai/src/Responses/ClassificationResponse.php @@ -6,6 +6,7 @@ use ArrayAccess; use Countable; +use Hypervel\Ai\Responses\Concerns\HasRawResponse; use Hypervel\Ai\Responses\Data\Answer; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\TextUsage; @@ -19,6 +20,8 @@ class ClassificationResponse implements Arrayable, ArrayAccess, Countable, IteratorAggregate, JsonSerializable { + use HasRawResponse; + /** * Create a new classification response instance. * diff --git a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php index 6bcafe48eb..6f84e91072 100644 --- a/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php +++ b/src/ai/src/Streaming/Protocols/AgentUserInteractionProtocol.php @@ -234,16 +234,18 @@ protected function stepFinishedPart(): array */ protected function runFinishedPart(array $attributes = []): array { - $assistantMessageId = $this->response?->assistantMessageId; - $userMessageId = $this->response?->userMessageId; + $messageIds = array_filter([ + 'messageId' => $this->response?->assistantMessageId, + 'userMessageId' => $this->response?->userMessageId, + ]); return [ 'type' => 'RUN_FINISHED', 'threadId' => $this->threadId, 'runId' => $this->runId, - ...($assistantMessageId ? ['messageId' => $assistantMessageId] : []), - ...($userMessageId ? ['userMessageId' => $userMessageId] : []), + ...$messageIds, ...$attributes, + ...($messageIds ? ['metadata' => [...($attributes['metadata'] ?? []), ...$messageIds]] : []), ]; } diff --git a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php index e05957a2b8..202e056627 100644 --- a/src/broadcasting/src/Broadcasters/PusherBroadcaster.php +++ b/src/broadcasting/src/Broadcasters/PusherBroadcaster.php @@ -133,12 +133,18 @@ public function broadcast(array $channels, string $event, array $payload = []): $parameters = $socket !== null ? ['socket_id' => $socket] : []; - $channels = new Collection($this->formatChannels($channels)); + [$encryptedChannels, $channels] = (new Collection($this->formatChannels($channels))) + ->partition(fn ($channel) => str_starts_with($channel, 'private-encrypted-')); try { $channels->chunk(100)->each(function ($channels) use ($event, $payload, $parameters) { ConnectionResolver::releaseIdleConnections(); - $this->pusher->trigger($channels->toArray(), $event, $payload, $parameters); + $this->pusher->trigger($channels->values()->all(), $event, $payload, $parameters); + }); + + $encryptedChannels->each(function ($channel) use ($event, $payload, $parameters) { + ConnectionResolver::releaseIdleConnections(); + $this->pusher->trigger([$channel], $event, $payload, $parameters); }); } catch (ApiErrorException $e) { throw new BroadcastException( diff --git a/src/collections/src/helpers.php b/src/collections/src/helpers.php index ab113dadb0..fdccb44e87 100644 --- a/src/collections/src/helpers.php +++ b/src/collections/src/helpers.php @@ -91,9 +91,10 @@ function data_get($target, $key, $default = null) } if ($segment === '*') { + // Unlike upstream, wildcards also select stdClass properties, since they may target any key of an array or object. if ($target instanceof Collection) { $target = $target->all(); - } elseif (! is_iterable($target)) { + } elseif (! is_iterable($target) && ! $target instanceof stdClass) { return value($default); } @@ -143,7 +144,8 @@ function data_set(&$target, $key, $value, $overwrite = true) $segments = is_array($key) ? $key : explode('.', (string) $key); if (($segment = array_shift($segments)) === '*') { - if (! Arr::accessible($target)) { + // Unlike upstream, wildcards also set stdClass properties instead of replacing the object with an array. + if (! Arr::accessible($target) && ! $target instanceof stdClass) { $target = []; } @@ -202,7 +204,8 @@ function data_forget(&$target, $key) { $segments = is_array($key) ? $key : explode('.', (string) $key); - if (($segment = array_shift($segments)) === '*' && Arr::accessible($target)) { + // Unlike upstream, wildcards also remove values from stdClass properties. + if (($segment = array_shift($segments)) === '*' && (Arr::accessible($target) || $target instanceof stdClass)) { if ($segments) { foreach ($target as &$inner) { data_forget($inner, $segments); @@ -217,7 +220,8 @@ function data_forget(&$target, $key) } elseif (is_object($target)) { if ($segments && isset($target->{$segment})) { data_forget($target->{$segment}, $segments); - } elseif (isset($target->{$segment})) { + } elseif (! $segments) { + // Unlike upstream, this does not check isset(), so null properties are removed too. unset($target->{$segment}); } } diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 4a03ad7f7a..2a2d0af2de 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -448,8 +448,10 @@ protected function runSingleServerEvent(Event $event, CarbonInterface $startedAt if ($this->schedule->serverShouldRun($event, $startedAt)) { $this->runEvent($event); } else { + $this->dispatchTaskSkipped($event); + $this->components->info(sprintf( - 'Skipping [%s], as command already run on another server.', + 'Skipping [%s] because the command already ran on another server.', $event->getSummaryForDisplay() )); } @@ -488,16 +490,15 @@ protected function runEvent(Event $event): void $failed = false; $skippedBecauseOverlapping = false; + $runtime = null; try { $event->run($this->hypervel); + $runtime = round(microtime(true) - $start, 2); $skippedBecauseOverlapping = $event->wasSkippedDueToOverlapping(); if ($this->dispatcher->hasListeners(ScheduledTaskFinished::class)) { - $this->dispatcher->dispatch(new ScheduledTaskFinished( - $event, - round(microtime(true) - $start, 2) - )); + $this->dispatcher->dispatch(new ScheduledTaskFinished($event, $runtime)); } $this->eventsRan = true; @@ -524,10 +525,12 @@ protected function runEvent(Event $event): void CoroutineContext::set(self::TASK_FAILED_CONTEXT_KEY, true); if ($this->dispatcher->hasListeners(ScheduledTaskFailed::class)) { + // Unlike upstream, a run that fails after the task finished (a failing exit code or a throwing + // Finished listener) reports the task's own runtime, without the time its Finished listeners took. $this->dispatcher->dispatch(new ScheduledTaskFailed( $event, $e, - round(microtime(true) - $start, 2) + $runtime ?? round(microtime(true) - $start, 2) )); } diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 740ba00759..9a995b59a1 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -5,24 +5,31 @@ namespace Hypervel\Console\Scheduling; use BadMethodCallException; +use Carbon\CarbonInterval; use Closure; use DateTimeInterface; use DateTimeZone; use Hypervel\Bus\DispatchLockContext; use Hypervel\Bus\UniqueLock; +use Hypervel\Console\Events\ScheduledTaskFailed; +use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Container\BindingResolutionException; +use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; +use Hypervel\Support\InteractsWithTime; use Hypervel\Support\ProcessUtils; use Hypervel\Support\Traits\Macroable; use RuntimeException; +use stdClass; use Symfony\Component\Console\Command\Command as SymfonyCommand; use UnitEnum; @@ -33,6 +40,7 @@ */ class Schedule { + use InteractsWithTime; use Macroable { __call as macroCall; } @@ -51,6 +59,11 @@ class Schedule public const int SATURDAY = 6; + /** + * The context key prefix for the task a duration handler has reported in the current run. + */ + protected const string TASK_DURATION_HANDLED_CONTEXT_KEY_PREFIX = '__console.scheduling_task_duration_handled.'; + /** * All of the events on the schedule. * @@ -523,6 +536,50 @@ public static function alwaysOnOneServer(bool $value = true): void static::$alwaysOnOneServer = $value; } + /** + * Register a callback to be invoked when a scheduled task runs for longer than a given amount of time. + * + * Boot-only. The listener persists on the singleton event dispatcher for + * the worker lifetime and runs for every subsequent matching scheduler event. + * + * @param callable(Event, float): mixed $handler + */ + public function whenTaskRunsLongerThan(DateTimeInterface|CarbonInterval|float|int $threshold, callable $handler): void + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalSeconds + : $threshold; + + // Identifies this registration, even when the same handler is registered twice. The listener + // retains the object, so its id cannot be reused by another registration. + $registration = new stdClass; + + Container::getInstance()->make(EventDispatcher::class)->listen( + [ScheduledTaskFinished::class, ScheduledTaskFailed::class], + // ScheduledTaskFailed follows ScheduledTaskFinished when a command exits with an error or a + // Finished listener throws. Each run has its own coroutine, so this marker reports a run once. + function (ScheduledTaskFinished|ScheduledTaskFailed $event) use ($threshold, $handler, $registration): void { + if ($event->runtime === null || $event->runtime <= $threshold) { + return; + } + + $handledKey = self::TASK_DURATION_HANDLED_CONTEXT_KEY_PREFIX . spl_object_id($registration); + + if (CoroutineContext::get($handledKey) === $event->task) { + return; + } + + CoroutineContext::set($handledKey, $event->task); + + $handler($event->task, $event->runtime); + } + ); + } + /** * Flush all static state. */ diff --git a/src/cookie/src/CookieJar.php b/src/cookie/src/CookieJar.php index 2eb202c876..c6ce2b801a 100644 --- a/src/cookie/src/CookieJar.php +++ b/src/cookie/src/CookieJar.php @@ -145,7 +145,7 @@ public function queue(mixed ...$parameters): void if (isset($parameters[0]) && $parameters[0] instanceof Cookie) { $cookie = $parameters[0]; } else { - $cookie = $this->make(...array_values($parameters)); + $cookie = $this->make(...$parameters); } $cookies = $this->getQueuedCookiesRaw(); diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index afd6e80d4c..6c647f5125 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1921,6 +1921,38 @@ public function withoutTablePrefix(Closure $callback): mixed */ abstract public function getServerVersion(): string; + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + throw new RuntimeException('The database connection does not support retrieving the last commit ID.'); + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + throw new RuntimeException('The database connection does not support managing commit IDs.'); + } + + /** + * Determine if the given commit ID has been applied. + * + * @throws RuntimeException + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + throw new RuntimeException('The database connection does not support checking a commit ID.'); + } + /** * Determine whether the server supports skipping locked rows. */ diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 387ac6fb58..c58742ab5f 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -898,7 +898,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum if ($function === 'exists') { $this->selectRaw( - sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrapIdentifier($alias)), + sprintf('%s as %s', $this->getQuery()->grammar->compileExistsColumn($query->toSql()), $this->getQuery()->grammar->wrapIdentifier($alias)), $query->getBindings() )->withCasts([$alias => 'bool']); // @phpstan-ignore method.notFound (selectRaw returns Eloquent\Builder $this, not Query\Builder) } else { diff --git a/src/database/src/Events/MigrationEnded.php b/src/database/src/Events/MigrationEnded.php index cd9e766f49..d8d3d482f8 100644 --- a/src/database/src/Events/MigrationEnded.php +++ b/src/database/src/Events/MigrationEnded.php @@ -4,6 +4,22 @@ namespace Hypervel\Database\Events; +use Hypervel\Database\Migrations\Migration; + class MigrationEnded extends MigrationEvent { + /** + * The number of milliseconds the migration took to run. + */ + public ?float $duration; + + /** + * Create a new event instance. + */ + public function __construct(Migration $migration, string $method, ?string $name = null, ?float $duration = null) + { + parent::__construct($migration, $method, $name); + + $this->duration = $duration; + } } diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index b398fcda39..786e82dff1 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -117,6 +117,7 @@ public function causedByLostConnection(Throwable $e): bool 'no healthy tablet available', 'transaction pool connection limit exceeded', 'SSL operation failed with code 5', + 'vttablet: rpc error: code = Canceled desc = QueryList.TerminateAll()', // PlanetScale PostgreSQL / pg_bouncer related things... 'no primary available for branch', diff --git a/src/database/src/MariaDbConnection.php b/src/database/src/MariaDbConnection.php index 1fcf3525a0..186779576d 100755 --- a/src/database/src/MariaDbConnection.php +++ b/src/database/src/MariaDbConnection.php @@ -4,6 +4,7 @@ namespace Hypervel\Database; +use Brick\Math\BigInteger; use Hypervel\Database\Query\Grammars\MariaDbGrammar; use Hypervel\Database\Query\Processors\MariaDbProcessor; use Hypervel\Database\Schema\Grammars\MariaDbGrammar as MariaDbSchemaGrammar; @@ -12,6 +13,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Str; use Override; +use RuntimeException; class MariaDbConnection extends MySqlConnection { @@ -47,6 +49,108 @@ public function getServerVersion(): string return Str::between(parent::getServerVersion(), '5.5.5-', '-MariaDB'); } + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + $gtid = $this->usingWritePdo(fn () => $this->selectOne('select @@log_bin as log_bin, @@gtid_binlog_pos as gtid_binlog_pos')); + + if (! $gtid->log_bin) { + throw new RuntimeException("The MariaDB configuration 'log_bin = ON' is required to retrieve the last commit ID."); + } + + return $gtid->gtid_binlog_pos; + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + return implode(',', array_keys($this->parseCommitIds($values))); + } + + /** + * Determine if the given commit ID has been applied. + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + // MASTER_GTID_WAIT only sees replicated GTIDs, so fall back to @@gtid_current_pos for the server's own writes... + $check = $this->selectOne('select master_gtid_wait(?, 0) as master_gtid_wait, @@gtid_current_pos as gtid_current_pos', [$value]); + + if ((int) $check->master_gtid_wait === 0) { + return true; + } + + $commitIds = $this->parseCommitIds([$value]); + $expected = array_column($commitIds, 'sequence', 'domain'); + + foreach ($this->parseCommitIds([$check->gtid_current_pos]) as $gtid) { + if (isset($expected[$gtid['domain']]) && $gtid['sequence']->isGreaterThanOrEqualTo($expected[$gtid['domain']])) { + unset($expected[$gtid['domain']]); + } + } + + if ($expected === [] || $wait <= 0) { + return $expected === []; + } + + // Wait only for the missing domains, because MASTER_GTID_WAIT would never see the server's own writes. + $missing = array_filter($commitIds, fn (array $gtid): bool => isset($expected[$gtid['domain']])); + + return (int) $this->scalar('select master_gtid_wait(?, ?)', [implode(',', array_keys($missing)), $wait]) === 0; + } + + /** + * Parse a set of commit IDs (GTIDs) into the newest GTID of every MariaDB replication domain. + * + * @param string[] $values + * @return array + * + * @throws RuntimeException + */ + protected function parseCommitIds(array $values): array + { + $gtids = $parsed = []; + + foreach (explode(',', implode(',', $values)) as $gtid) { + $gtid = trim($gtid); + if ($gtid === '') { + continue; + } + + if (! preg_match('/^(?\d+)-(?\d+)-(?\d+)$/', $gtid, $matches)) { + throw new RuntimeException("The commit ID '{$gtid}' is not a valid MariaDB GTID."); + } + + $domain = $matches['domain']; + + // The sequence is an unsigned 64-bit integer and may exceed PHP's integer range... + $sequence = BigInteger::of($matches['sequence']); + + if (! isset($parsed[$domain]) || $sequence->isGreaterThan($parsed[$domain]['sequence'])) { + $gtids[$domain] = $gtid; + $parsed[$domain] = compact('domain', 'sequence'); + } + } + + ksort($gtids); + ksort($parsed); + + return array_combine($gtids, $parsed); + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 439d11dc10..20922b3794 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -440,10 +440,14 @@ protected function runMigration(object $migration, string $method, ?string $name $this->fireMigrationEvent(new MigrationStarted($migration, $method, $name)); } + $startedAt = microtime(true); + $this->runMethod($connection, $migration, $method); if ($this->hasMigrationEventListeners(MigrationEnded::class)) { - $this->fireMigrationEvent(new MigrationEnded($migration, $method, $name)); + $duration = round((microtime(true) - $startedAt) * 1000, 2); + + $this->fireMigrationEvent(new MigrationEnded($migration, $method, $name, $duration)); } } }; diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index 862412d170..c99a9e9de5 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -337,6 +337,51 @@ protected function resolveMaxBindings(): int return 65_535; } + /** + * Get the ID of the last committed transaction. + * + * @throws RuntimeException + */ + public function getLastCommitId(): string + { + $gtid = $this->usingWritePdo(fn () => $this->selectOne('select @@log_bin as log_bin, @@global.gtid_mode as mode, @@global.gtid_executed as executed')); + + if (! $gtid->log_bin || $gtid->mode !== 'ON') { + throw new RuntimeException("The MySQL configuration 'log_bin = ON' and 'gtid_mode = ON' are required to retrieve the last commit ID."); + } + + return $gtid->executed; + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + // Normalize the joined GTID sets with gtid_subtract to keep the result as small as possible... + return $this->scalar("select gtid_subtract(?, '')", [implode(',', $values)]); + } + + /** + * Determine if the given commit ID has been applied. + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + if ($wait <= 0) { + return (bool) $this->scalar('select gtid_subset(?, @@global.gtid_executed)', [$value]); + } + + return (int) $this->scalar('select wait_for_executed_gtid_set(?, ?)', [$value, $wait]) === 0; + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php index f4eef2c6fa..2b4097bbbb 100755 --- a/src/database/src/PostgresConnection.php +++ b/src/database/src/PostgresConnection.php @@ -14,6 +14,7 @@ use Hypervel\Filesystem\Filesystem; use Override; use PDO; +use RuntimeException; class PostgresConnection extends PdoConnection { @@ -157,6 +158,57 @@ protected function resolveMaxBindings(): int return 65_535; } + /** + * Get the ID of the last committed transaction. + */ + public function getLastCommitId(): string + { + // pg_current_wal_lsn() can precede an acknowledged asynchronous commit. The insert position covers it; + // at a page boundary it points past the next page header, which a replica passes with the next WAL record. + return $this->usingWritePdo(fn () => $this->scalar('select pg_current_wal_insert_lsn()')); + } + + /** + * Calculate the commit ID for a set of commit IDs that includes all of them. + * + * @param string[] $values + * + * @throws RuntimeException + */ + public function mergeCommitIds(array $values): string + { + if ($values === []) { + throw new RuntimeException("The list of commit IDs can't be empty."); + } + + // LSNs are linear, so the newest one includes all older ones... + return $this->scalar('select value::pg_lsn from json_array_elements_text(?) order by 1 desc limit 1', [json_encode(array_values($values))]); + } + + /** + * Determine if the given commit ID has been applied. + * + * @throws RuntimeException + */ + public function hasCommitId(string $value, int $wait = 0): bool + { + if ($wait > 0) { + // Check the server that runs the wait; getServerVersion() would also contact the primary. + if (version_compare($this->getPdoForSelect()->getAttribute(PDO::ATTR_SERVER_VERSION), '19', '<')) { + throw new RuntimeException('The PostgreSQL version does not support waiting for a commit ID. PostgreSQL 19 or newer is required.'); + } + + $status = $this->scalar("wait for lsn {$this->escapeString($value)} with (timeout {$this->escapeString("{$wait}s")}, no_throw)"); + + // A primary can't wait for its own LSNs, so fall back to the immediate check... + if ($status !== 'not in recovery') { + return $status === 'success'; + } + } + + return (bool) $this->scalar('select case when pg_is_in_recovery() then pg_last_wal_replay_lsn() else pg_current_wal_insert_lsn() end >= ?::pg_lsn', [$value]); + } + /** * Get the default query grammar instance. */ diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 8de91e5f9a..77772e8205 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -1071,6 +1071,14 @@ public function compileExists(Builder $query): string ); } + /** + * Compile an "exists" subquery as a selectable column. + */ + public function compileExistsColumn(string $sql): string + { + return 'exists(' . $sql . ')'; + } + /** * Compile an insert statement into SQL. */ diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index 3f5341f4f5..96f2c10d11 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -162,13 +162,19 @@ protected function compileIndexHint(Builder $query, IndexHint $indexHint): strin return ''; } - $index = $indexHint->index; + $indexes = array_map('trim', explode(',', $indexHint->index)); - if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { - throw new InvalidArgumentException('Index name contains invalid characters.'); + foreach ($indexes as $index) { + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + } + + if (count($indexes) > 1 || strtolower($indexes[0]) === 'primary') { + return ''; } - return "indexed by {$index}"; + return "indexed by {$indexes[0]}"; } /** diff --git a/src/docs/ai-sdk.md b/src/docs/ai-sdk.md index 9ac2ad957f..3122d43082 100644 --- a/src/docs/ai-sdk.md +++ b/src/docs/ai-sdk.md @@ -536,6 +536,8 @@ foreach ($response->steps as $step) { } ``` +[Classification](#classification) responses also expose the provider's raw HTTP response through their `raw` property. + > **Note:** The `raw` property is `null` when streaming a response, when using the Bedrock provider, and on faked responses unless one is provided explicitly via `withRawResponse`. @@ -1208,6 +1210,8 @@ The `threadId` and `runId` arguments are optional and default to the conversatio ); ``` +When the conversation is stored, the final `RUN_FINISHED` event includes the stored assistant and user message IDs as `messageId` and `userMessageId`, and repeats them in its `metadata`. + To use a protocol that the AI SDK does not implement, you may pass your own `Hypervel\Ai\Streaming\Protocols\StreamProtocol` implementation to the `usingProtocol` method: ```php diff --git a/src/docs/database.md b/src/docs/database.md index a560e7c0b9..9c162e7d99 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -11,6 +11,7 @@ - [Read and Write Connections](#read-and-write-connections) - [Preventing Stale Database Reads](#preventing-stale-database-reads) - [Using the Write Connection for Reads](#using-the-write-connection-for-reads) + - [Waiting for Read Replicas](#waiting-for-read-replicas) - [Connection Pooling](#connection-pooling) - [Releasing and Pinning Connections](#releasing-and-pinning-connections) - [Configuring Database Session State](#configuring-database-session-state) @@ -291,6 +292,47 @@ $users = DB::connection()->usingWritePdo(fn ($connection) => $connection->table( This also applies to Eloquent queries and relationship queries executed within the callback using the same connection. The method returns the callback's result and restores the connection's previous read preference when the callback finishes, even if an exception is thrown. + +#### Waiting for Read Replicas + +Read replicas apply each write shortly after it is committed. When a later read must see a particular write, you may record the write's commit ID and check it before reading. Retrieve the ID using the `getLastCommitId` method once the write's transaction has committed: + +```php +use Hypervel\Support\Facades\DB; + +DB::table('orders')->insert($order); + +$commitId = DB::getLastCommitId(); +``` + +Commit IDs are strings, so you may store them in the user's session or pass them to a queued job. The `hasCommitId` method determines whether the read connection has applied the commit. Its optional `wait` argument specifies how many seconds to wait for the commit to arrive. If the replica has not caught up, you may read from the write connection instead: + +```php +$order = DB::hasCommitId($commitId, wait: 2) + ? DB::table('orders')->find($orderId) + : DB::connection()->usingWritePdo(fn ($connection) => $connection->table('orders')->find($orderId)); +``` + +If you have recorded several commit IDs, the `mergeCommitIds` method combines them into one ID that is applied once all of them are: + +```php +$commitId = DB::mergeCommitIds([$profileCommitId, $orderCommitId]); +``` + +Commit IDs require MySQL with `log_bin` and `gtid_mode` enabled, MariaDB with its binary log enabled, or PostgreSQL. Waiting on PostgreSQL requires PostgreSQL 19 or newer and should happen before you start a transaction. Commit IDs can only be compared within the same replication setup. + +The check applies to the database session your connection currently holds. Since Hypervel may return idle sessions to the pool during [external calls](#releasing-and-pinning-connections), a later query may use a session on another replica. If reads after an external call depend on the check, perform the check and those reads within `withPinnedSession`: + +```php +$connection = DB::connection(); + +$connection->withPinnedSession(function () use ($connection, $commitId) { + $applied = $connection->hasCommitId($commitId, wait: 2); + + // External calls, then reads that rely on $applied... +}); +``` + ### Connection Pooling diff --git a/src/docs/mcp.md b/src/docs/mcp.md index c56140e438..de83db2ced 100644 --- a/src/docs/mcp.md +++ b/src/docs/mcp.md @@ -1697,6 +1697,27 @@ The `Library` enum includes pre-configured CDN scripts for common front-end libr For computed or dynamic configuration, override the `appMeta` method on your resource using the fluent `AppMeta`, `Csp`, and `Permissions` builders from the `Hypervel\Mcp\Server\Ui` namespace. +By default, the app's sandbox domain is derived from the current server URL in the format Claude expects. You may set the `domain` argument to use a specific sandbox domain. If the host rejects the derived domain, for example because the connector URL was saved with a trailing slash, set `domain` to `false` to leave it out so the host uses its default sandbox origin: + +```php +#[AppMeta(domain: false)] +class WeatherDashboardApp extends AppResource +{ + // ... +} +``` + +When overriding the `appMeta` method, call `withoutDomain` on the builder instead: + +```php +use Hypervel\Mcp\Server\Ui\AppMeta; + +public function appMeta(): AppMeta +{ + return AppMeta::make()->withoutDomain(); +} +``` + For the complete protocol reference, including the full client-side API and schema details, see the official [MCP Apps documentation](https://modelcontextprotocol.io/extensions/apps/overview). @@ -2490,6 +2511,18 @@ $response->assertTitle('Current Weather Tool'); $response->assertDescription('Fetches the current weather forecast for a specified location.'); ``` +You may assert a tool's structured content using the `assertStructuredContent` method, which accepts the expected array or a closure that receives an `AssertableJson` instance. Structured content equivalents of the HTTP test response's JSON assertions are also available, such as `assertStructuredContentCount`, `assertStructuredContentPath`, `assertExactStructuredContent`, `assertStructuredContentFragment`, `assertStructuredContentMissingPath`, and `assertStructuredContentStructure`: + +```php +$response + ->assertStructuredContentCount(2, 'data') + ->assertStructuredContentPath('data.0.id', 123) + ->assertStructuredContentStructure([ + 'data' => ['*' => ['id', 'status']], + 'meta' => ['total'], + ]); +``` + You may assert that notifications were sent using the `assertSentNotification` and `assertNotificationCount` methods: ```php @@ -2516,23 +2549,13 @@ $response->dump(); ### Testing OAuth Clients -`Http::fake()` replaces HTTP responses, but OAuth destination validation still runs before sending. When a test uses an invented hostname, replace `OAuthDestinationPolicy` with a test policy that supplies controlled DNS answers. The normal address validation still applies: +`Http::fake()` replaces HTTP responses, but OAuth destination validation still runs before sending. When a test uses an invented hostname, supply its DNS answers with `AuthServerDiscovery::resolveHostsUsing()`. The normal address validation still applies to the returned addresses: ```php -use Hypervel\Mcp\Client\OAuth\OAuthDestinationPolicy; +use Hypervel\Mcp\Client\OAuth\AuthServerDiscovery; use Hypervel\Support\Facades\Http; -$this->app->instance(OAuthDestinationPolicy::class, new class extends OAuthDestinationPolicy { - /** - * Resolve test endpoints without external DNS. - * - * @return list - */ - protected function resolveHost(string $host, float $timeoutSeconds): array - { - return ['93.184.216.34']; - } -}); +AuthServerDiscovery::resolveHostsUsing(fn (string $host): array => ['93.184.216.34']); Http::fake([ 'https://auth.test/token' => Http::response(['access_token' => 'test-token']), diff --git a/src/docs/middleware.md b/src/docs/middleware.md index 3254b6ec17..6720ce8eb4 100644 --- a/src/docs/middleware.md +++ b/src/docs/middleware.md @@ -131,6 +131,15 @@ use Hypervel\Foundation\Configuration\Middleware; The `$middleware` object provided to the `withMiddleware` closure is an instance of `Hypervel\Foundation\Configuration\Middleware` and is responsible for managing the middleware assigned to your application's routes. The `append` method adds the middleware to the end of the list of global middleware. If you would like to add a middleware to the beginning of the list, you should use the `prepend` method. +To place a middleware next to an existing global middleware, you may use the `insertBefore` and `insertAfter` methods: + +```php +use App\Http\Middleware\EnsureTokenIsValid; +use Hypervel\Http\Middleware\TrustProxies; + +$middleware->insertAfter(TrustProxies::class, EnsureTokenIsValid::class); +``` + #### Preferring JSON Responses @@ -335,6 +344,15 @@ $middleware->web(remove: [ ]); ``` +The `insertBeforeInGroup` and `insertAfterInGroup` methods place middleware next to an existing entry in a group: + +```php +use App\Http\Middleware\EnsureUserIsSubscribed; +use Hypervel\Session\Middleware\StartSession; + +$middleware->insertAfterInGroup('web', StartSession::class, EnsureUserIsSubscribed::class); +``` + #### Manually Managing Hypervel's Default Middleware Groups diff --git a/src/docs/migrations.md b/src/docs/migrations.md index bb9cfb5b5d..d10d9fad8c 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1884,4 +1884,4 @@ For convenience, each migration operation will dispatch an [event](/docs/{{versi -The `MigrationStarted` and `MigrationEnded` events expose the migration filename through their `$name` property. +The `MigrationStarted` and `MigrationEnded` events expose the migration filename through their `$name` property. The `MigrationEnded` event also exposes how long the migration took to run, in milliseconds, through its `$duration` property. diff --git a/src/docs/queries.md b/src/docs/queries.md index f89500c2dd..57c6d249d1 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -411,6 +411,8 @@ $users = DB::table('users') ->get(); ``` +Because SQLite's `indexed by` clause takes a single named index, SQLite ignores a `forceIndex` hint that names `primary` or several indexes. This lets queries written with MariaDB or MySQL hints run unchanged on SQLite. + Index hints apply to update statements as well. For example, MariaDB and MySQL may read a small table in full when an update names many of its rows, locking every row they read inside a transaction. Forcing the primary key keeps such an update to the rows it names: ```php diff --git a/src/docs/queues.md b/src/docs/queues.md index 3cd0904095..95100daf75 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -4144,6 +4144,17 @@ class AppServiceProvider extends ServiceProvider The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. It is `null` when the worker skips the job because it was already deleted, such as by a `JobProcessing` listener. +If you would like to be notified when a job takes too long to run, you may use the `whenJobRunsLongerThan` method. Provide a threshold in milliseconds, or a `CarbonInterval`, and a closure that receives the job and the number of milliseconds the attempt took. The closure is invoked for each attempt that exceeds the threshold, whether or not the attempt succeeds: + +```php +use Hypervel\Contracts\Queue\Job; +use Hypervel\Support\Facades\Queue; + +Queue::whenJobRunsLongerThan(1000, function (Job $job, float $duration) { + // Notify the development team... +}); +``` + Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. This event is not dispatched when coroutine cancellation interrupts the attempt, including when the exception was caused by cancellation. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay in seconds. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index 9fe0e775f2..0bb606cb54 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -788,3 +788,14 @@ Hypervel dispatches a variety of [events](/docs/{{version}}/events) during the s The `ScheduledTaskFailed` event's `runtime` property contains the elapsed execution time in seconds, or `null` when the runtime is unavailable. + +If you would like to be notified when a scheduled task takes too long to run, you may use the `whenTaskRunsLongerThan` method in your application's `routes/console.php` file. Provide a threshold in seconds, or a `CarbonInterval`, and a closure that receives the task and the number of seconds it ran. The closure is invoked once for each run that exceeds the threshold, including runs that fail: + +```php +use Hypervel\Console\Scheduling\Event; +use Hypervel\Support\Facades\Schedule; + +Schedule::whenTaskRunsLongerThan(60, function (Event $task, float $runtime) { + // Notify the development team... +}); +``` diff --git a/src/encryption/src/Commands/KeyGenerateCommand.php b/src/encryption/src/Commands/KeyGenerateCommand.php index 05772a3c80..3efad1869a 100644 --- a/src/encryption/src/Commands/KeyGenerateCommand.php +++ b/src/encryption/src/Commands/KeyGenerateCommand.php @@ -23,7 +23,7 @@ class KeyGenerateCommand extends Command * The name and signature of the console command. */ protected ?string $signature = 'key:generate - {--show : Display the key instead of modifying files} + {--show : Display a new key instead of modifying files} {--force : Force the operation to run when in production}'; /** diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index 6000490cbb..45563a0fe3 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -108,15 +108,25 @@ public function bootstrap(Application $app): void /** * Report PHP deprecations, or convert PHP errors to ErrorException instances. * + * Deprecations are handled here even when silenced with @. Other errors excluded by + * error_reporting() are passed on to PHP's standard handler so error_get_last() still + * reports them. + * * @throws ErrorException */ - public function handleError(int $level, string $message, string $file = '', int $line = 0): void + public function handleError(int $level, string $message, string $file = '', int $line = 0): bool { if ($this->isDeprecation($level)) { $this->handleDeprecationError($message, $file, $line, $level); - } elseif (error_reporting() & $level) { + + return true; + } + + if (error_reporting() & $level) { throw new ErrorException($message, 0, $level, $file, $line); } + + return false; } /** diff --git a/src/foundation/src/Configuration/Middleware.php b/src/foundation/src/Configuration/Middleware.php index e4c2dc29b5..53c2e9432c 100644 --- a/src/foundation/src/Configuration/Middleware.php +++ b/src/foundation/src/Configuration/Middleware.php @@ -153,13 +153,35 @@ public function remove(array|string $middleware): static /** * Specify a middleware that should be replaced with another middleware. */ - public function replace(string $search, string $replace): static + public function replace(string $search, array|string $replace): static { $this->replacements[$search] = $replace; return $this; } + /** + * Insert middleware before the given middleware in the global middleware stack. + */ + public function insertBefore(string $search, array|string $middleware): static + { + return $this->replace($search, array_merge( + Arr::wrap($middleware), + Arr::wrap($this->replacements[$search] ?? $search) + )); + } + + /** + * Insert middleware after the given middleware in the global middleware stack. + */ + public function insertAfter(string $search, array|string $middleware): static + { + return $this->replace($search, array_merge( + Arr::wrap($this->replacements[$search] ?? $search), + Arr::wrap($middleware) + )); + } + /** * Define the global middleware for the application. */ @@ -222,13 +244,35 @@ public function removeFromGroup(string $group, array|string $middleware): static /** * Replace the given middleware in the specified group with another middleware. */ - public function replaceInGroup(string $group, string $search, string $replace): static + public function replaceInGroup(string $group, string $search, array|string $replace): static { $this->groupReplacements[$group][$search] = $replace; return $this; } + /** + * Insert middleware before the given middleware in the specified group. + */ + public function insertBeforeInGroup(string $group, string $search, array|string $middleware): static + { + return $this->replaceInGroup($group, $search, array_merge( + Arr::wrap($middleware), + Arr::wrap($this->groupReplacements[$group][$search] ?? $search) + )); + } + + /** + * Insert middleware after the given middleware in the specified group. + */ + public function insertAfterInGroup(string $group, string $search, array|string $middleware): static + { + return $this->replaceInGroup($group, $search, array_merge( + Arr::wrap($this->groupReplacements[$group][$search] ?? $search), + Arr::wrap($middleware) + )); + } + /** * Modify the middleware in the "web" group. */ @@ -330,9 +374,9 @@ public function getGlobalMiddleware(): array \Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ])); - $middleware = array_map(function ($middleware) { + $middleware = Arr::flatten(array_map(function ($middleware) { return $this->replacements[$middleware] ?? $middleware; - }, $middleware); + }, $middleware)); return array_values(array_filter( array_diff( @@ -368,11 +412,9 @@ public function getMiddlewareGroups(): array $middleware = array_merge($middleware, $this->groups); foreach ($middleware as $group => $groupedMiddleware) { - foreach ($groupedMiddleware as $index => $groupMiddleware) { - if (isset($this->groupReplacements[$group][$groupMiddleware])) { - $middleware[$group][$index] = $this->groupReplacements[$group][$groupMiddleware]; - } - } + $middleware[$group] = Arr::flatten(array_map(function ($groupMiddleware) use ($group) { + return $this->groupReplacements[$group][$groupMiddleware] ?? $groupMiddleware; + }, $groupedMiddleware)); } foreach ($this->groupRemovals as $group => $removals) { diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index b0a5dc2b88..af389d9997 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -1812,22 +1812,6 @@ public function buildClient(): ClientInterface return $this->client ?? $this->createClient($this->buildHandlerStack()); } - /** - * Determine if a reusable client is required. - */ - protected function requestsReusableClient(): bool - { - return ! is_null($this->client) || $this->async; - } - - /** - * Retrieve a reusable Guzzle client. - */ - protected function getReusableClient(): ClientInterface - { - return $this->client ??= $this->createClient($this->buildHandlerStack()); - } - /** * Create a new Guzzle client. */ diff --git a/src/mcp/README.md b/src/mcp/README.md index 132e81af8c..3a55af00f3 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -15,7 +15,7 @@ Documentation: https://hypervel.org/docs/mcp - Anonymous registration persists an explicit scope ceiling from Passport's anonymous allowance plus `mcp:use`; custom client schemas require `scopes` and `third_party`. Wildcard callback domains permit HTTPS and HTTP loopback only; explicit administrator allowances and configured native schemes remain supported. See [registration policy](https://hypervel.org/docs/mcp#oauth). - Connect URLs containing `resource_metadata` or `scope` must be signed with `redirect()->signedRoute()` or `URL::signedRoute()` to prevent discovery-hint tampering; see [authorization challenges](https://hypervel.org/docs/mcp#client-oauth-scopes). - Custom client headers stay on the configured origin across redirects because they may contain credentials. Connect directly to a server on another origin if it needs those headers; see [custom client headers](https://hypervel.org/docs/mcp#client-custom-headers). -- OAuth clients require an explicit policy allowance for private DNS destinations and a DNS substitute for unresolved hostnames in faked tests. See [OAuth destinations](https://hypervel.org/docs/mcp#oauth-destinations) and [testing OAuth clients](https://hypervel.org/docs/mcp#testing-oauth). +- OAuth clients resolve DNS locally and pin each OAuth request through `OAuthDestinationPolicy`, including behind ordinary proxies. Private DNS destinations require an explicit policy allowance, even when the resource itself is private. See [OAuth destinations](https://hypervel.org/docs/mcp#oauth-destinations). - OAuth requests use capped response streams instead of Laravel's protected `oAuthCurlOptions()` helper, which is omitted because the transport owns protocol restrictions and rejects its raw cURL options. Customize `$maxOAuthResponseSize` or the request through `oAuthRequest()`. - OAuth clients use the remote server's advertised scopes instead of assuming `mcp:use`. Set `withOAuth(scope: 'mcp:use')` when that scope is required but not advertised; see [OAuth scopes](https://hypervel.org/docs/mcp#client-oauth-scopes). - The Inspector verifies HTTPS certificates. Configure `NODE_EXTRA_CA_CERTS` for locally issued certificates; see [MCP Inspector](https://hypervel.org/docs/mcp#mcp-inspector). diff --git a/src/mcp/src/Client/OAuth/AuthServerDiscovery.php b/src/mcp/src/Client/OAuth/AuthServerDiscovery.php index 4156135d81..a77370eeee 100644 --- a/src/mcp/src/Client/OAuth/AuthServerDiscovery.php +++ b/src/mcp/src/Client/OAuth/AuthServerDiscovery.php @@ -25,6 +25,35 @@ class AuthServerDiscovery { use InteractsWithOAuthEndpoints; + // Upstream's discovery-time DNS cache and address checks are omitted: OAuthDestinationPolicy + // resolves, validates and pins each request when pin() sends it. + + protected static ?Closure $hostResolver = null; + + /** + * Resolve OAuth destination hostnames with the given callback instead of DNS. + * + * Tests only. The callback persists in a static property for the worker lifetime and supplies the addresses for every OAuth request. + * + * @param null|Closure(string): array $callback + */ + public static function resolveHostsUsing(?Closure $callback): void + { + static::$hostResolver = $callback; + } + + /** + * Get the addresses the resolveHostsUsing() callback supplies for a hostname. + * + * @internal + * + * @return null|list + */ + public static function resolvedHostAddresses(string $host): ?array + { + return static::$hostResolver === null ? null : array_values((static::$hostResolver)($host)); + } + /** * Discover and validate the resource's authorization server. */ @@ -108,6 +137,27 @@ public function requireAdvertised(string $resourceUrl, string $resourceMetadataU } } + /** + * Validate each request hop against the OAuth destination policy and pin it to the vetted addresses. + */ + public function pin(PendingRequest $request): PendingRequest + { + return $request->withMiddleware(function (callable $handler): Closure { + return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { + // Resolve every hop here instead of through the generic destination-policy + // handler, which rejects the ordinary proxies OAuth requests may use. + $transportOptions = ['curl', 'proxy', 'timeout', 'connect_timeout']; + $hop = new PendingRequest(options: Arr::only($options, $transportOptions)); + $destination = $this->resolveOAuthDestination($hop, (string) $request->getUri()); + + return $handler($request->withUri($destination->uri, true), array_replace( + $options, + Arr::only($hop->getOptions(), $transportOptions), + )); + }; + }); + } + /** * Probe the resource for its unauthenticated authorization challenge. * @@ -115,24 +165,12 @@ public function requireAdvertised(string $resourceUrl, string $resourceMetadataU */ protected function probe(string $resourceUrl, array $headers): Response { + // Compare each hop's origin before pin() replaces its URI with the normalized destination. + $request = $this->oAuthRequest()->withMiddleware(new RestrictHeadersToOrigin($resourceUrl, array_keys($headers))); + try { - return $this->oAuthRequest() + return $this->pin($request) ->withHeaders($headers) - ->withMiddleware(new RestrictHeadersToOrigin($resourceUrl, array_keys($headers))) - ->withMiddleware(function (callable $handler): Closure { - return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { - // Resolve every hop through the same boundary as other OAuth requests, which - // preserves ordinary proxies that the generic destination-policy handler rejects. - $transportOptions = ['curl', 'proxy', 'timeout', 'connect_timeout']; - $hop = new PendingRequest(options: Arr::only($options, $transportOptions)); - $destination = $this->resolveOAuthDestination($hop, (string) $request->getUri()); - - return $handler($request->withUri($destination->uri, true), array_replace( - $options, - Arr::only($hop->getOptions(), $transportOptions), - )); - }; - }) ->withOptions(['allow_redirects' => [ 'max' => 5, 'strict' => true, @@ -158,9 +196,7 @@ protected function probe(string $resourceUrl, array $headers): Response */ protected function fetchResourceMetadata(string $metadataUrl, bool $explicit = false): array { - $request = $this->oAuthRequest(); - $destination = $this->resolveOAuthDestination($request, $metadataUrl); - $response = $request->get((string) $destination->uri); + $response = $this->pin($this->oAuthRequest())->get($metadataUrl); if (! $response->successful()) { if ($explicit) { @@ -226,9 +262,7 @@ protected function issuerFrom(array $resourceMetadata): ?string protected function fetchMetadata(string $issuer): AuthServerMetadata { foreach ($this->metadataUrls($issuer) as $metadataUrl) { - $request = $this->oAuthRequest(); - $destination = $this->resolveOAuthDestination($request, $metadataUrl); - $response = $request->get((string) $destination->uri); + $response = $this->pin($this->oAuthRequest())->get($metadataUrl); if (! $response->successful()) { continue; @@ -303,11 +337,14 @@ protected function requireSecure(string $url): void { $parts = $this->parse($url); - if ($parts['scheme'] === 'https') { + // URL schemes are case-insensitive, and Guzzle sends HTTP:// and HTTPS:// URLs as http and https. + $scheme = strtolower($parts['scheme']); + + if ($scheme === 'https') { return; } - if ($this->isLocalhost($this->normalizedHost($parts['host']))) { + if ($scheme === 'http' && $this->isLocalhost($this->normalizedHost($parts['host']))) { return; } @@ -317,7 +354,7 @@ protected function requireSecure(string $url): void /** * Validate a metadata endpoint before fetching it. */ - protected function requireFetchable(string $url, string $resourceUrl): void + public function requireFetchable(string $url, string $resourceUrl): void { $this->requireSecure($url); $this->requireNotInternal($url, $resourceUrl); @@ -394,4 +431,12 @@ protected function parse(string $url): array /** @var array{scheme: string, host: string, port?: int, path?: string, query?: string} $parts */ return $parts; } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$hostResolver = null; + } } diff --git a/src/mcp/src/Client/OAuth/DynamicClientRegistration.php b/src/mcp/src/Client/OAuth/DynamicClientRegistration.php index d71633189b..3b87704466 100644 --- a/src/mcp/src/Client/OAuth/DynamicClientRegistration.php +++ b/src/mcp/src/Client/OAuth/DynamicClientRegistration.php @@ -12,6 +12,13 @@ class DynamicClientRegistration { use InteractsWithOAuthEndpoints; + /** + * Create a new dynamic client registration instance. + */ + public function __construct(protected AuthServerDiscovery $discovery = new AuthServerDiscovery) + { + } + /** * Register a client with the authorization server. */ @@ -23,17 +30,17 @@ public function register( string $applicationType = 'web', TokenEndpointAuthMethod $tokenEndpointAuthMethod = TokenEndpointAuthMethod::ClientSecretPost, ): ClientRegistration { - $request = $this->oAuthRequest()->asJson(); - $destination = $this->resolveOAuthDestination($request, $registrationEndpoint); - $response = $request->post((string) $destination->uri, array_filter([ - 'client_name' => $clientName, - 'redirect_uris' => [$redirectUri], - 'grant_types' => ['authorization_code', 'refresh_token'], - 'response_types' => ['code'], - 'token_endpoint_auth_method' => $tokenEndpointAuthMethod->value, - 'application_type' => $applicationType, - 'scope' => $scope, - ], static fn (mixed $value): bool => $value !== null)); + $response = $this->discovery->pin($this->oAuthRequest()) + ->asJson() + ->post($registrationEndpoint, array_filter([ + 'client_name' => $clientName, + 'redirect_uris' => [$redirectUri], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'response_types' => ['code'], + 'token_endpoint_auth_method' => $tokenEndpointAuthMethod->value, + 'application_type' => $applicationType, + 'scope' => $scope, + ], static fn (mixed $value): bool => $value !== null)); if (! $response->successful()) { throw new OAuthException("Dynamic client registration failed with status [{$response->status()}]."); diff --git a/src/mcp/src/Client/OAuth/OAuthClient.php b/src/mcp/src/Client/OAuth/OAuthClient.php index a48450decb..bb071fdd68 100644 --- a/src/mcp/src/Client/OAuth/OAuthClient.php +++ b/src/mcp/src/Client/OAuth/OAuthClient.php @@ -216,8 +216,12 @@ protected function exchangeAuthorizationCode( $clientId = (string) $stored['client_id']; $clientSecret = isset($stored['client_secret']) ? (string) $stored['client_secret'] : null; + $tokenEndpoint = (string) $stored['token_endpoint']; + + $this->discovery->requireFetchable($tokenEndpoint, $this->resourceUrl); + $token = $this->requestToken( - (string) $stored['token_endpoint'], + $tokenEndpoint, [ 'grant_type' => 'authorization_code', 'code' => $code, @@ -271,7 +275,8 @@ protected function usesClientIdMetadataDocument(AuthServerMetadata $metadata): b $url = parse_url($this->clientIdMetadataUrl); - if (! is_array($url) || ($url['scheme'] ?? null) !== 'https' || ($url['path'] ?? '/') === '/') { + // Schemes are case-insensitive; the original URL is still sent as the exact client identifier. + if (! is_array($url) || strtolower($url['scheme'] ?? '') !== 'https' || ($url['path'] ?? '/') === '/') { return false; } @@ -309,7 +314,7 @@ protected function register(AuthServerMetadata $metadata, string $redirectUri): throw new OAuthException('No client_id was configured, no https client ID metadata document URL is available, and the authorization server does not support dynamic client registration.'); } - return (new DynamicClientRegistration)->register( + return (new DynamicClientRegistration($this->discovery))->register( $metadata->registrationEndpoint, $redirectUri, $this->authorizationScope(), @@ -331,7 +336,7 @@ protected function requestToken( ?string $clientSecret, TokenEndpointAuthMethod $authMethod, ): TokenSet { - $request = $this->oAuthRequest()->asForm(); + $request = $this->discovery->pin($this->oAuthRequest())->asForm(); $credentials = match ($authMethod) { TokenEndpointAuthMethod::ClientSecretBasic => [], @@ -344,8 +349,7 @@ protected function requestToken( $request = $request->withBasicAuth(rawurlencode((string) $clientId), rawurlencode((string) $clientSecret)); } - $destination = $this->resolveOAuthDestination($request, $tokenEndpoint); - $response = $request->post((string) $destination->uri, array_filter([...$params, ...$credentials], static fn (mixed $value): bool => $value !== null)); + $response = $request->post($tokenEndpoint, array_filter([...$params, ...$credentials], static fn (mixed $value): bool => $value !== null)); if (! $response->successful()) { throw new OAuthException("Token request to [{$tokenEndpoint}] failed with status [{$response->status()}]."); diff --git a/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php b/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php index fd4e77df16..0da7d0cdff 100644 --- a/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php +++ b/src/mcp/src/Client/OAuth/OAuthDestinationPolicy.php @@ -20,4 +20,18 @@ protected function allowsAddress(UriInterface $uri, string $address): bool return in_array(trim($uri->getHost(), '[]'), self::LOCALHOSTS, true) && IpUtils::checkIp($address, ['127.0.0.0/8', '::1']); } + + /** + * Resolve hostnames through AuthServerDiscovery::resolveHostsUsing() when a callback is set. + * + * @return list + */ + protected function resolveHost(string $host, float $timeoutSeconds): array + { + if (filter_var($host, FILTER_VALIDATE_IP) === false && ($addresses = AuthServerDiscovery::resolvedHostAddresses($host)) !== null) { + return $addresses; + } + + return parent::resolveHost($host, $timeoutSeconds); + } } diff --git a/src/mcp/src/Server/AppResource.php b/src/mcp/src/Server/AppResource.php index 3fd4d4bba2..057a04282b 100644 --- a/src/mcp/src/Server/AppResource.php +++ b/src/mcp/src/Server/AppResource.php @@ -33,9 +33,12 @@ public function appMeta(): AppMeta */ public function resolvedAppMeta(): array { - $appMeta = $this->appMeta()->toArray(); + $meta = $this->appMeta(); + $appMeta = $meta->toArray(); - $appMeta['domain'] ??= $this->toClaudeDomain(url()->current()); + if ($meta->usesDomain()) { + $appMeta['domain'] ??= $this->toClaudeDomain(url()->current()); + } return $appMeta; } diff --git a/src/mcp/src/Server/Attributes/AppMeta.php b/src/mcp/src/Server/Attributes/AppMeta.php index 7866cde223..a28430052f 100644 --- a/src/mcp/src/Server/Attributes/AppMeta.php +++ b/src/mcp/src/Server/Attributes/AppMeta.php @@ -22,6 +22,7 @@ class AppMeta * @param null|array $frameDomains domains the app may embed as nested iframes (CSP frame-src) * @param null|array $baseUriDomains allowed URLs for the document's base element (CSP base-uri) * @param null|array $permissions + * @param null|false|string $domain dedicated sandbox domain, or false to leave ui.domain out entirely * @param array $libraries */ public function __construct( @@ -31,7 +32,7 @@ public function __construct( public readonly ?array $baseUriDomains = null, public readonly ?array $permissions = null, public readonly ?bool $prefersBorder = null, - public readonly ?string $domain = null, + public readonly string|false|null $domain = null, public readonly array $libraries = [], ) { } @@ -55,7 +56,9 @@ public function toAppMeta(): AppMetaData $meta->prefersBorder($this->prefersBorder); } - if ($this->domain !== null) { + if ($this->domain === false) { + $meta->withoutDomain(); + } elseif ($this->domain !== null) { $meta->domain($this->domain); } diff --git a/src/mcp/src/Server/Testing/TestResponse.php b/src/mcp/src/Server/Testing/TestResponse.php index 354ff5196e..3ef744d8bf 100644 --- a/src/mcp/src/Server/Testing/TestResponse.php +++ b/src/mcp/src/Server/Testing/TestResponse.php @@ -13,13 +13,17 @@ use Hypervel\Mcp\Server\Resource; use Hypervel\Mcp\Server\Tool; use Hypervel\Mcp\Transport\JsonRpcResponse; +use Hypervel\Support\Json; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; +use Hypervel\Testing\AssertableJsonString; use Hypervel\Testing\Fluent\AssertableJson; use PHPUnit\Framework\Assert; use RuntimeException; use stdClass; +use function Hypervel\Support\enum_value; + class TestResponse { use Conditionable; @@ -146,6 +150,238 @@ public function assertStructuredContent(Closure|array $structuredContent): stati return $this; } + /** + * Assert that the structured content has the expected count of items at the given key. + */ + public function assertStructuredContentCount(int $count, ?string $key = null): static + { + $actual = $this->structuredContent(); + + Assert::assertNotNull($actual, 'The response does not contain any structured content.'); + + Assert::assertCount($count, data_get($actual, $key), "The expected structured content count [{$count}] does not match the actual count."); + + return $this; + } + + /** + * Assert that the expected value and type exists at the given path in the structured content. + * + * @param (Closure(mixed): bool)|mixed $expect + */ + public function assertStructuredContentPath(string $path, mixed $expect): static + { + $actual = $this->structuredContent(); + + Assert::assertNotNull($actual, 'The response does not contain any structured content.'); + + $value = data_get($actual, $path); + + if ($expect instanceof Closure) { + Assert::assertTrue($expect($value), "The structured content at [{$path}] does not pass the given truth test."); + + return $this; + } + + Assert::assertSame( + enum_value($expect), + $value, + "The expected structured content at [{$path}] does not match the actual value." + ); + + return $this; + } + + /** + * Assert that the expected values and types exist at the given paths in the structured content. + * + * @param array $paths + */ + public function assertStructuredContentPaths(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertStructuredContentPath($path, $expected); + } + + return $this; + } + + /** + * Assert that the given path in the structured content contains all of the expected values without looking at the order. + * + * @param array $expect + */ + public function assertStructuredContentPathCanonicalizing(string $path, array $expect): static + { + $this->assertableStructuredContent()->assertPathCanonicalizing($path, $expect); + + return $this; + } + + /** + * Assert that the given paths in the structured content contain all of the expected values without looking at the order. + * + * @param array> $paths + */ + public function assertStructuredContentPathsCanonicalizing(array $paths): static + { + foreach ($paths as $path => $expected) { + $this->assertStructuredContentPathCanonicalizing($path, $expected); + } + + return $this; + } + + /** + * Assert that the structured content is exactly the given JSON. + * + * @param array $data + */ + public function assertExactStructuredContent(array $data): static + { + $this->assertableStructuredContent()->assertExact($data); + + return $this; + } + + /** + * Assert that the structured content has the similar JSON as given. + * + * @param array $data + */ + public function assertSimilarStructuredContent(array $data): static + { + $this->assertableStructuredContent()->assertSimilar($data); + + return $this; + } + + /** + * Assert that the structured content contains the given JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentFragment(array $data): static + { + $this->assertableStructuredContent()->assertFragment($data); + + return $this; + } + + /** + * Assert that the structured content contains the given JSON fragments. + * + * @param array> $data + */ + public function assertStructuredContentFragments(array $data): static + { + foreach ($data as $fragment) { + $this->assertStructuredContentFragment($fragment); + } + + return $this; + } + + /** + * Assert that the structured content does not contain the given JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentMissing(array $data, bool $exact = false): static + { + $this->assertableStructuredContent()->assertMissing($data, $exact); + + return $this; + } + + /** + * Assert that the structured content does not contain the exact JSON fragment. + * + * @param array $data + */ + public function assertStructuredContentMissingExact(array $data): static + { + $this->assertableStructuredContent()->assertMissingExact($data); + + return $this; + } + + /** + * Assert that the structured content does not contain the given path. + */ + public function assertStructuredContentMissingPath(string $path): static + { + $this->assertableStructuredContent()->assertMissingPath($path); + + return $this; + } + + /** + * Assert that the structured content does not contain the given paths. + * + * @param array $paths + */ + public function assertStructuredContentMissingPaths(array $paths): static + { + foreach ($paths as $path) { + $this->assertStructuredContentMissingPath($path); + } + + return $this; + } + + /** + * Assert that the structured content has a given JSON structure. + * + * @param null|array $structure + * @param null|array $responseData + */ + public function assertStructuredContentStructure(?array $structure = null, ?array $responseData = null): static + { + $this->assertableStructuredContent()->assertStructure($structure, $responseData); + + return $this; + } + + /** + * Assert that the structured content has the exact JSON structure. + * + * @param null|array $structure + * @param null|array $responseData + */ + public function assertExactStructuredContentStructure(?array $structure = null, ?array $responseData = null): static + { + $this->assertableStructuredContent()->assertStructure($structure, $responseData, true); + + return $this; + } + + /** + * Assert that the given key in the structured content is a JSON array. + */ + public function assertStructuredContentIsArray(?string $key = null): static + { + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->assertableStructuredContent()->object($key); + + Assert::assertTrue(is_array($data), 'The structured content is not an array.'); + + return $this; + } + + /** + * Assert that the given key in the structured content is a JSON object. + */ + public function assertStructuredContentIsObject(?string $key = null): static + { + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->assertableStructuredContent()->object($key); + + Assert::assertTrue($data instanceof stdClass, 'The structured content is not an object.'); + + return $this; + } + /** * Assert the number of notifications received. */ @@ -488,6 +724,22 @@ protected function structuredContent(): ?array return $decoded; } + /** + * Get the response's structured content for JSON assertions. + */ + protected function assertableStructuredContent(): AssertableJsonString + { + $structuredContent = $this->response->toArray()['result']['structuredContent'] ?? null; + + Assert::assertTrue( + is_array($structuredContent) || $structuredContent instanceof stdClass, + 'The response does not contain any structured content.' + ); + + // Pass encoded JSON so object() can still tell empty and numeric-key objects from arrays. + return new AssertableJsonString(Json::encode($structuredContent)); + } + /** * Normalize a value through its JSON representation. */ diff --git a/src/mcp/src/Server/Ui/AppMeta.php b/src/mcp/src/Server/Ui/AppMeta.php index e8e918ddb5..e1777f7d4b 100644 --- a/src/mcp/src/Server/Ui/AppMeta.php +++ b/src/mcp/src/Server/Ui/AppMeta.php @@ -12,6 +12,8 @@ */ class AppMeta implements Arrayable { + protected bool $usesDomain = true; + /** * Create application metadata. * @@ -60,10 +62,30 @@ public function permissions(Permissions $permissions): static public function domain(string $domain): static { $this->domain = $domain; + $this->usesDomain = true; return $this; } + /** + * Leave the application domain out so the host uses its default sandbox origin. + */ + public function withoutDomain(): static + { + $this->domain = null; + $this->usesDomain = false; + + return $this; + } + + /** + * Determine if the application uses a domain. + */ + public function usesDomain(): bool + { + return $this->usesDomain; + } + /** * Set whether the application prefers a visible border. */ diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php index 522f8c75a7..6b420a003d 100644 --- a/src/queue/src/CallQueuedHandler.php +++ b/src/queue/src/CallQueuedHandler.php @@ -11,6 +11,7 @@ use Hypervel\Bus\DebounceLock; use Hypervel\Bus\Queueable; use Hypervel\Bus\UniqueLock; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher; use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\LockProvider; @@ -31,6 +32,11 @@ class CallQueuedHandler { + /** + * The context key for the job being failed because its command's models are missing. + */ + protected const string MISSING_MODEL_FAILURE_CONTEXT_KEY = '__queue.missing_model_failure'; + /** * The command currently being processed. */ @@ -304,9 +310,10 @@ protected function commandShouldBeUniqueUntilProcessing(mixed $command): bool */ protected function handleModelNotFound(Job $job, Throwable $e): void { - $this->ensureUniqueJobLockIsReleasedViaContext(); + $payload = $job->payload(); - if ($job->payload()['deleteWhenMissingModels'] ?? false) { + if ($payload['deleteWhenMissingModels'] ?? false) { + $this->ensureUniqueJobLockIsReleasedForMissingModel($job, $payload, beforeProcessing: true); $this->ensureSuccessfulBatchJobIsRecordedForMissingModel($job, $job->resolveQueuedJobClass()); $job->delete(); @@ -314,7 +321,95 @@ protected function handleModelNotFound(Job $job, Throwable $e): void return; } - $job->fail($e); + // failed() releases the unique lock; the marker tells it this attempt never began processing. + $previous = CoroutineContext::get(self::MISSING_MODEL_FAILURE_CONTEXT_KEY); + CoroutineContext::set(self::MISSING_MODEL_FAILURE_CONTEXT_KEY, $job); + + try { + $job->fail($e); + } finally { + if ($previous === null) { + CoroutineContext::forget(self::MISSING_MODEL_FAILURE_CONTEXT_KEY); + } else { + CoroutineContext::set(self::MISSING_MODEL_FAILURE_CONTEXT_KEY, $previous); + } + } + } + + /** + * Release the unique lock of a job whose command cannot be restored because its models are missing. + * + * @param array $payload the job's payload, or a partial payload containing only its data when the job is unavailable + * @param bool $beforeProcessing whether this attempt failed before its command began processing + */ + protected function ensureUniqueJobLockIsReleasedForMissingModel(?Job $job, array $payload, bool $beforeProcessing): void + { + if ($job === null) { + $owner = ContextRepository::hasInstance() + ? ContextRepository::getInstance()->getHidden('laravel_unique_job_lock_owner') + : null; + + if ((is_string($owner) && $owner !== '') + || $this->ownerlessUniqueLockMayBeReleasedForMissingModel(null, $payload, false) + ) { + $this->ensureUniqueJobLockIsReleasedViaContext(); + } + + return; + } + + // The payload identifies this job's lock even when the ambient context belongs to + // another job, such as in the worker's timeout monitor. + $hidden = $payload['illuminate:log:context']['hidden'] ?? []; + + [$store, $key, $owner] = array_map( + static fn (string $name): mixed => isset($hidden[$name]) + ? unserialize($hidden[$name], ['allowed_classes' => false]) + : null, + ['laravel_unique_job_cache_store', 'laravel_unique_job_key', 'laravel_unique_job_lock_owner'], + ); + + if (! is_string($store) || $store === '' + || ! is_string($key) || $key === '' + || ! $this->container->bound(CacheFactory::class) + ) { + return; + } + + $owner = is_string($owner) ? $owner : ''; + + if ($owner === '' && ! $this->ownerlessUniqueLockMayBeReleasedForMissingModel($job, $payload, $beforeProcessing)) { + return; + } + + UniqueLock::releaseOwned($this->container->make(CacheFactory::class)->store($store), $key, $owner); + } + + /** + * Determine if an ownerless unique lock still belongs to a job whose models are missing. + * + * @param array $payload the job's payload, or a partial payload containing only its data when the job is unavailable + * @param bool $beforeProcessing whether this attempt failed before its command began processing + */ + protected function ownerlessUniqueLockMayBeReleasedForMissingModel(?Job $job, array $payload, bool $beforeProcessing): bool + { + $class = $payload['data']['commandName'] ?? null; + + // Queued listeners take their uniqueness from the listener class named in the payload. + if (is_string($class) && is_a($class, CallQueuedListener::class, true)) { + $class = $payload['displayName'] ?? null; + } + + // A regular unique lock is held until the job finishes. An until-processing lock is released when + // an attempt begins processing, so after that an ownerless one may belong to a replacement dispatch. + if (is_string($class) + && is_a($class, ShouldBeUnique::class, true) + && ! is_a($class, ShouldBeUniqueUntilProcessing::class, true) + ) { + return true; + } + + return $beforeProcessing && $job !== null && $job->attempts() <= 1; } /** @@ -385,7 +480,19 @@ protected function ensureSuccessfulBatchJobIsRecordedForMissingModel(Job $job, s */ public function failed(array $data, ?Throwable $e, string $uuid, ?Job $job = null): void { - $command = $this->getCommand($data); + try { + $command = $this->getCommand($data); + } catch (ModelNotFoundException $exception) { + $this->ensureUniqueJobLockIsReleasedForMissingModel( + $job, + $job === null ? ['data' => $data] : $job->payload(), + beforeProcessing: $job !== null && CoroutineContext::get(self::MISSING_MODEL_FAILURE_CONTEXT_KEY) === $job, + ); + + $this->ensureFailedBatchJobIsRecordedForMissingModel($uuid, $data, $e); + + throw $exception; + } if (! is_null($job)) { $command = $this->setJobInstanceIfNecessary($job, $command); @@ -423,6 +530,26 @@ protected function ensureFailedBatchJobIsRecorded(string $uuid, mixed $command, } } + /** + * Record a potentially batched job as failed when its models are missing. + */ + protected function ensureFailedBatchJobIsRecordedForMissingModel(string $uuid, array $data, ?Throwable $e): void + { + if (! $this->container->bound(BatchRepository::class)) { + return; + } + + $batchId = $data['batchId'] ?? null; + + if ((! is_string($batchId) || $batchId === '') || $uuid === '') { + return; + } + + if ($batch = $this->container->make(BatchRepository::class)->find($batchId)) { + $batch->recordFailedJob($uuid, $e); + } + } + /** * Ensure the chained job catch callbacks are invoked. */ diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index a11b98ef7c..ad0969ab1c 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -346,18 +346,21 @@ protected function writeOutputForCli(Job $job, string $status): void $runTime = $this->runTimeForHumans($this->getLatestStartedAt()); $memory = $isVerbose ? round(memory_get_usage(true) / 1024 / 1024, 1) . 'MB' : ''; + [$result, $color] = match (true) { + $status === 'success' && $job->isReleased() => ['RELEASED', 'yellow'], + $status === 'success' => ['DONE', 'green'], + $status === 'released_after_exception' => ['FAIL', 'yellow'], + default => ['FAIL', 'red'], + }; + $dots = max(terminal()->width() - mb_strlen($jobName) - ( $isVerbose ? mb_strlen($displayJobId) + mb_strlen($job->getConnectionName()) + mb_strlen($job->getQueue()) + mb_strlen($memory) + 3 : 0 - ) - mb_strlen($runTime) - 31, 0); + ) - mb_strlen($runTime) - mb_strlen($result) - 27, 0); $this->output->write(' ' . str_repeat('.', $dots)); $this->output->write(" {$runTime}" . ($memory ? " {$memory}" : '') . ''); - $this->output->writeln(match ($status) { - 'success' => ' DONE', - 'released_after_exception' => ' FAIL', - default => ' FAIL', - }); + $this->output->writeln(" {$result}"); } /** diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 4a93fa9735..4ec0b78446 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -4,6 +4,7 @@ namespace Hypervel\Queue; +use Carbon\CarbonInterval; use Closure; use DateInterval; use DateTimeInterface; @@ -16,6 +17,7 @@ use Hypervel\ObjectPool\Concerns\HasPoolProxy; use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\Connectors\FailoverConnector; +use Hypervel\Queue\Events\JobAttempted; use Hypervel\Queue\Events\JobExceptionOccurred; use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobProcessed; @@ -28,6 +30,7 @@ use Hypervel\Queue\Events\WorkerStarting; use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Support\Arr; +use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use InvalidArgumentException; use UnitEnum; @@ -40,6 +43,7 @@ class QueueManager implements FactoryContract, MonitorContract { use HasPoolProxy; + use InteractsWithTime; use ResolvesQueueRoutes; /** @@ -164,6 +168,30 @@ public function stopping(mixed $callback): void ->listen(WorkerStopping::class, $callback); } + /** + * Register a callback to be invoked when a job runs for longer than a given amount of time. + * + * Boot-only. The listener persists on the singleton event dispatcher for + * the worker lifetime and runs for every subsequent matching queue event. + */ + public function whenJobRunsLongerThan(DateTimeInterface|CarbonInterval|float|int $threshold, callable $handler): void + { + $threshold = $threshold instanceof DateTimeInterface + ? $this->secondsUntil($threshold) * 1000 + : $threshold; + + $threshold = $threshold instanceof CarbonInterval + ? $threshold->totalMilliseconds + : $threshold; + + $this->app->make('events') + ->listen(JobAttempted::class, function (JobAttempted $event) use ($threshold, $handler): void { + if (! is_null($event->duration) && $event->duration > $threshold) { + $handler($event->job, $event->duration); + } + }); + } + /** * Register the default queue route for a given class. * diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 87b1da7b6b..9eee15680a 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -62,6 +62,7 @@ * @method static string getDriverTitle() * @method static int getErrorCount() * @method static \Hypervel\Contracts\Events\Dispatcher|null getEventDispatcher() + * @method static string getLastCommitId() * @method static string|int getLastInsertId(string|null $sequence = null) * @method static string|null getName() * @method static string|null getNameWithReadWriteType() @@ -77,6 +78,7 @@ * @method static string getTablePrefix() * @method static \Hypervel\Database\DatabaseTransactionsManager|null getTransactionManager() * @method static string|null getWritableName() + * @method static bool hasCommitId(string $value, int $wait = 0) * @method static bool hasModifiedRecords() * @method static bool hasPinnedSession() * @method static bool insert(string $query, array $bindings = []) @@ -84,6 +86,7 @@ * @method static void listen(\Closure $callback) * @method static bool logging() * @method static void logQuery(string $query, array $bindings, float|null $time = null) + * @method static string mergeCommitIds(string[] $values) * @method static array prepareBindings(array $bindings) * @method static array[] pretend(\Closure $callback) * @method static bool pretending() diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index ca7102b5b8..5ac2497a60 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -44,6 +44,7 @@ * @method static \Hypervel\Queue\QueueManager setReleaseCallback(string $driver, \Closure $callback) * @method static void starting(mixed $callback) * @method static void stopping(mixed $callback) + * @method static void whenJobRunsLongerThan(\DateTimeInterface|\Carbon\CarbonInterval|int|float $threshold, callable $handler) * @method static void withoutInterruptionPolling() * @method static mixed bulk(array $jobs, mixed $data = '', \UnitEnum|string|null $queue = null) * @method static int|null creationTimeOfOldestPendingJob(\UnitEnum|string|null $queue = null) diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index ca058b41f4..4e27cea40a 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -27,6 +27,7 @@ * @method static void mixin(object $mixin, bool $replace = true) * @method static bool serverShouldRun(\Hypervel\Console\Scheduling\Event $event, \DateTimeInterface $time) * @method static \Hypervel\Console\Scheduling\Schedule useCache(\UnitEnum|string|null $store) + * @method static void whenTaskRunsLongerThan(\DateTimeInterface|\Carbon\CarbonInterval|int|float $threshold, callable $handler) * @method static void withoutInterruptionPolling() * @method static \Hypervel\Console\Scheduling\PendingEventAttributes at(string $time) * @method static \Hypervel\Console\Scheduling\PendingEventAttributes between(string $startTime, string $endTime) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 7ecd79508d..ee83f46c6b 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1284,13 +1284,19 @@ public static function replaceMatches(string|array $pattern, Closure|array|strin * Remove any occurrence of the given string in the subject. * * @param iterable|string $search + * @param iterable|string $subject + * @return ($subject is string ? string : string[]) */ - public static function remove(string|iterable $search, string $subject, bool $caseSensitive = true): string + public static function remove(string|iterable $search, string|iterable $subject, bool $caseSensitive = true): string|array { if ($search instanceof Traversable) { $search = iterator_to_array($search); } + if ($subject instanceof Traversable) { + $subject = iterator_to_array($subject); + } + return $caseSensitive ? str_replace($search, '', $subject) : static::replaceWhileIgnoringCase($search, '', $subject); @@ -1645,11 +1651,7 @@ public static function substr(string $string, int $start, ?int $length = null, s */ public static function substrCount(string $haystack, string $needle, int $offset = 0, ?int $length = null): int { - if (! is_null($length)) { - return substr_count($haystack, $needle, $offset, $length); - } - - return substr_count($haystack, $needle, $offset); + return substr_count(static::substr($haystack, $offset, $length), $needle); } /** diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 770a3476ff..8251c0efc3 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -61,6 +61,20 @@ public function json(?string $key = null): mixed return data_get($this->decoded, $key); } + /** + * Return the decoded JSON with objects as stdClass instances. + * + * Unlike json(), this keeps empty and numeric-key JSON objects distinct from arrays. + * + * @throws JsonException + */ + public function object(?string $key = null): mixed + { + $json = is_string($this->json) ? $this->json : Json::encode($this->json); + + return data_get(Json::decode($json, false), $key); + } + /** * Assert that the response JSON has the expected count of items at the given key. */ diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 95c497bba0..470af95542 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -455,6 +455,7 @@ protected function flushMcpState(): void $this->callIfExists(\Hypervel\Mcp\Response::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\ResponseFactory::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Client\ClientManager::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Mcp\Client\OAuth\AuthServerDiscovery::class, 'flushState'); $this->callIfExists(\Hypervel\Mcp\Server\Registrar::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Server\Testing\TestResponse::class, 'flushMacros'); $this->callIfExists(\Hypervel\Mcp\Server\Testing\TestListResponse::class, 'flushMacros'); diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index 102ac9676b..cc037b60cd 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -32,6 +32,7 @@ use Hypervel\Testing\TestResponseAssert as PHPUnit; use JsonException; use LogicException; +use stdClass; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -1063,15 +1064,10 @@ public function assertJsonMissingValidationErrors(array|string|null $keys = null */ public function assertJsonIsArray(?string $key = null): static { - $data = $this->json($key); + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->decodeResponseJson()->object($key); - $encodedData = json_encode($data); - - PHPUnit::withResponse($this)->assertTrue( - is_array($data) - && str_starts_with($encodedData, '[') - && str_ends_with($encodedData, ']') - ); + PHPUnit::withResponse($this)->assertTrue(is_array($data)); return $this; } @@ -1081,15 +1077,10 @@ public function assertJsonIsArray(?string $key = null): static */ public function assertJsonIsObject(?string $key = null): static { - $data = $this->json($key); + // Associative decoding turns {} and {"0": ...} into PHP lists, so check the JSON's own types. + $data = $this->decodeResponseJson()->object($key); - $encodedData = json_encode($data); - - PHPUnit::withResponse($this)->assertTrue( - is_array($data) - && str_starts_with($encodedData, '{') - && str_ends_with($encodedData, '}') - ); + PHPUnit::withResponse($this)->assertTrue($data instanceof stdClass); return $this; } diff --git a/src/validation/src/ValidationData.php b/src/validation/src/ValidationData.php index b16c98251c..59f622b931 100644 --- a/src/validation/src/ValidationData.php +++ b/src/validation/src/ValidationData.php @@ -155,7 +155,12 @@ protected static function initializeAttributeOnData(string $attribute, array $ma return $data; } - return data_set($data, $attribute, null, true); + // Unlike upstream, fill only the copied arrays. data_set() would write through objects shared with the caller's input. + foreach (static::expandWildcardKeys($attribute, $data) as $key) { + Arr::set($data, $key, null); + } + + return $data; } /** diff --git a/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php b/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php index b81e324977..ebc27a635b 100644 --- a/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php +++ b/tests/Ai/Feature/AgentUserInteractionProtocolStreamTest.php @@ -243,6 +243,29 @@ public function testAStoredRunReportsTheRowItWroteForThePrompt(): void $this->assertNotSame($response->assistantMessageId, $response->userMessageId); } + public function testAStoredRunAlsoReportsItsMessageIdsInTheRunFinishedMetadata(): void + { + $this->loadMigrationsFrom(__DIR__ . '/../../../src/ai/database/migrations'); + RememberingAssistantAgent::fake(['Fake response']); + + $user = new class { + public int $id = 1; + }; + + $response = (new RememberingAssistantAgent)->forUser($user)->stream('Hello'); + + $events = agUiEvents($response->usingProtocol(new AgentUserInteractionProtocol)->toResponse(request())); + $finished = end($events); + + $this->assertSame([ + 'finishReason' => 'stop', + 'messageId' => $response->assistantMessageId, + 'userMessageId' => $response->userMessageId, + ], $finished['metadata']); + $this->assertSame($response->assistantMessageId, $finished['messageId']); + $this->assertSame($response->userMessageId, $finished['userMessageId']); + } + public function testAStreamThatPersistsNothingOmitsTheMessageId(): void { $events = agUiProtocolEvents([ diff --git a/tests/Ai/Feature/Providers/Mistral/AudioTest.php b/tests/Ai/Feature/Providers/Mistral/AudioTest.php index b52efa4414..4f4352a647 100644 --- a/tests/Ai/Feature/Providers/Mistral/AudioTest.php +++ b/tests/Ai/Feature/Providers/Mistral/AudioTest.php @@ -30,13 +30,13 @@ public function testAudioRequestIncludesModelInputVoiceIdAndResolvesDefaultFemal { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello world')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello world')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $request->url() === 'https://api.mistral.ai/v1/audio/speech' - && $body['model'] === 'voxtral-mini-tts-2603' + && $body['model'] === 'voxtral-mini-tts-3-0' && $body['input'] === 'Hello world' && $body['voice_id'] === 'gb_jane_neutral' && $body['response_format'] === 'mp3'; @@ -47,7 +47,7 @@ public function testAudioRequestResolvesDefaultMaleVoiceAlias(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->male()->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->male()->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice_id'] === 'en_paul_neutral'); } @@ -56,7 +56,7 @@ public function testAudioRequestPassesCustomVoiceIdThroughUnchanged(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->voice('my-custom-voice-id')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->voice('my-custom-voice-id')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice_id'] === 'my-custom-voice-id'); } @@ -65,7 +65,7 @@ public function testAudioRequestOmitsInstructionsSinceTheMistralApiDoesNotAccept { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->instructions('Speak warmly')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->instructions('Speak warmly')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => ! str_contains($request->body(), 'Speak warmly') && ! array_key_exists('instructions', json_decode($request->body(), true))); @@ -75,7 +75,7 @@ public function testAudioRequestSendsBearerToken(): void { Http::fake(['*' => $this->fakeMistralAudioResponse()]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'Bearer test-key')); } @@ -84,12 +84,12 @@ public function testAudioResponsePassesBase64AudioDataThroughWithAudioMpegMimeTy { Http::fake(['*' => $this->fakeMistralAudioResponse(base64_encode('raw-audio-bytes'))]); - $response = Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + $response = Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); $this->assertSame(base64_encode('raw-audio-bytes'), $response->audio); $this->assertSame('audio/mpeg', $response->mimeType()); $this->assertSame('mistral', $response->meta->provider); - $this->assertSame('voxtral-mini-tts-2603', $response->meta->model); + $this->assertSame('voxtral-mini-tts-3-0', $response->meta->model); } public function testAudioUsesDefaultModelWhenNoneSpecified(): void @@ -98,7 +98,7 @@ public function testAudioUsesDefaultModelWhenNoneSpecified(): void Audio::of('Hello')->generate(provider: 'mistral'); - Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'voxtral-mini-tts-2603'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'voxtral-mini-tts-3-0'); } public function testAudioThrowsWhenTheResponseContainsNoAudioData(): void @@ -107,7 +107,7 @@ public function testAudioThrowsWhenTheResponseContainsNoAudioData(): void $this->expectExceptionMessage('No audio data received from Mistral API.'); Http::fake(['*' => Http::response(['audio_data' => null])]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioThrowsWhenTheApiReturnsAnError(): void @@ -115,7 +115,7 @@ public function testAudioThrowsWhenTheApiReturnsAnError(): void $this->expectException(RequestException::class); Http::fake(['*' => Http::response(['message' => 'unauthorized'], 401)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioRateLimitResponseThrowsRateLimitedException(): void @@ -123,7 +123,7 @@ public function testAudioRateLimitResponseThrowsRateLimitedException(): void $this->expectException(RateLimitedException::class); Http::fake(['api.mistral.ai/*' => Http::response(['message' => 'rate limit exceeded'], 429)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioOverloadedResponseThrowsProviderOverloadedException(): void @@ -131,7 +131,7 @@ public function testAudioOverloadedResponseThrowsProviderOverloadedException(): $this->expectException(ProviderOverloadedException::class); Http::fake(['api.mistral.ai/*' => Http::response(['message' => 'service unavailable'], 503)]); - Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-2603'); + Audio::of('Hello')->generate(provider: 'mistral', model: 'voxtral-mini-tts-3-0'); } public function testAudioGatewayIsSharedWithTheOtherMistralGateways(): void diff --git a/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php b/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php index dffb1a48df..63e60f8925 100644 --- a/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php +++ b/tests/Ai/Feature/Providers/Mistral/TranscriptionTest.php @@ -51,7 +51,7 @@ public function testTranscriptionIncludesModelInRequest(): void Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'mistral'); - Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'voxtral-mini-2602')); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'voxtral-mini-transcribe-3-0')); } public function testTranscriptionSendsLanguageWhenProvided(): void diff --git a/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php b/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php index f05059d4e7..541b45377b 100644 --- a/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php +++ b/tests/Ai/Feature/Providers/OpenAi/ClassificationTest.php @@ -12,6 +12,7 @@ use Hypervel\Ai\Files\Image; use Hypervel\Contracts\Foundation\Application; use Hypervel\Http\Client\Request; +use Hypervel\Http\Client\Response; use Hypervel\Http\UploadedFile; use Hypervel\Support\Facades\Http; use Hypervel\Support\Facades\Storage; @@ -67,6 +68,16 @@ public function testClassificationPostsQuestionsToTheDecisionsEndpointInTheOpenA }); } + public function testTheRawDecisionsResponseIsExposedOnTheClassificationResponse(): void + { + Http::fake(['*' => Http::response($this->fakeOpenAiDecisionsResponse())]); + + $response = Classification::of('text')->question('is_urgent', new Boolean('Is this urgent?'))->classify(provider: 'openai'); + + $this->assertInstanceOf(Response::class, $response->raw); + $this->assertSame('gpt-6-luna', $response->raw->json('model')); + } + public function testStructuredStateIsSentAsJsonText(): void { Http::fake(['*' => Http::response($this->fakeOpenAiDecisionsResponse())]); diff --git a/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php b/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php index ea8a2d6a7d..886d56191a 100644 --- a/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php +++ b/tests/Ai/Feature/Providers/OpenAi/ErrorHandlingTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Ai\Feature\Providers\OpenAi; use Hypervel\Ai\Exceptions\AiException; +use Hypervel\Ai\Exceptions\InsufficientCreditsException; use Hypervel\Ai\Exceptions\ProviderOverloadedException; use Hypervel\Ai\Exceptions\RateLimitedException; use Hypervel\Contracts\Foundation\Application; @@ -53,6 +54,22 @@ public function testRateLimitResponseThrowsRateLimitedException(): void (new AssistantAgent)->prompt('Hi', provider: 'openai'); } + public function testInsufficientQuotaResponseThrowsInsufficientCreditsException(): void + { + $this->expectException(InsufficientCreditsException::class); + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'error' => [ + 'type' => 'insufficient_quota', + 'code' => 'insufficient_quota', + 'message' => 'You exceeded your current quota, please check your plan and billing details.', + ], + ], 429), + ]); + + (new AssistantAgent)->prompt('Hi', provider: 'openai'); + } + public function testOverloadedResponseThrowsProviderOverloadedException(): void { $this->expectException(ProviderOverloadedException::class); diff --git a/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php b/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php index 23e02410b9..6ed4d13525 100644 --- a/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php +++ b/tests/Ai/Feature/Providers/OpenRouter/ClassificationTest.php @@ -129,6 +129,15 @@ public function testDecisionsResponseIsParsedIntoTypedAnswers(): void $this->assertSame('typesafe/jev-1.13', $response->meta->model); } + public function testTheRawDecisionsResponseIsExposedOnTheClassificationResponse(): void + { + Http::fake(['*' => Http::response($this->fakeDecisionsResponse())]); + + $response = Classification::of('text')->question('is_urgent', new Boolean('Urgent?'))->classify(provider: 'openrouter'); + + $this->assertSame(0.000013, $response->raw->json('usage.cost')); + } + public function testClassificationUsesTheDefaultDecisionsModelWhenNoneIsSpecified(): void { Http::fake(['*' => Http::response($this->fakeDecisionsResponse())]); diff --git a/tests/Ai/Feature/ResponseSerializationTest.php b/tests/Ai/Feature/ResponseSerializationTest.php index 1c2dc1258d..a6b2cd0a70 100644 --- a/tests/Ai/Feature/ResponseSerializationTest.php +++ b/tests/Ai/Feature/ResponseSerializationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Ai\Feature; use GuzzleHttp\Psr7\Response as Psr7Response; +use Hypervel\Ai\Responses\ClassificationResponse; +use Hypervel\Ai\Responses\Data\BooleanAnswer; use Hypervel\Ai\Responses\Data\FinishReason; use Hypervel\Ai\Responses\Data\Meta; use Hypervel\Ai\Responses\Data\TextUsage; @@ -40,4 +42,17 @@ public function testSerializationPreservesPrivatePropertiesDeclaredOnStepSubclas $this->assertSame('Hello', $restored->text); $this->assertNull($restored->raw); } + + public function testSerializationPreservesClassificationAnswersAndDropsTheRawResponse(): void + { + $response = (new ClassificationResponse(['is_urgent' => new BooleanAnswer(0.9)], new TextUsage(1, 2), new Meta('openai', 'gpt-6-luna'))) + ->withRawResponse(new Response(new Psr7Response(200, [], '{}'))); + + $restored = unserialize(serialize($response)); + + $this->assertSame(0.9, $restored->answer('is_urgent')->probability); + $this->assertSame(2, $restored->usage->outputTokens); + $this->assertSame('gpt-6-luna', $restored->meta->model); + $this->assertNull($restored->raw); + } } diff --git a/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php b/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php index f905e440cd..6e5fa7f503 100644 --- a/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php +++ b/tests/Ai/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php @@ -44,6 +44,104 @@ public function test429TakesPrecedenceOverInsufficientCreditPatternMatching(): v ])); } + #[DataProvider('openAiBillingErrors')] + public function test429WithAnOpenAiBillingErrorThrowsInsufficientCreditsException(array $error): void + { + $this->expectException(InsufficientCreditsException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(429, ['error' => $error])); + } + + /** + * Provide OpenAI's billing and quota error codes and types. + */ + public static function openAiBillingErrors(): array + { + return [ + 'insufficient_quota code' => [['code' => 'insufficient_quota']], + 'insufficient_quota type' => [['type' => 'insufficient_quota']], + 'credit_balance_exhausted' => [['code' => 'credit_balance_exhausted']], + 'organization_spend_limit_exceeded' => [['code' => 'organization_spend_limit_exceeded']], + 'project_spend_limit_exceeded' => [['code' => 'project_spend_limit_exceeded']], + 'organization_usage_limit_exceeded' => [['code' => 'organization_usage_limit_exceeded']], + ]; + } + + public function test429WithAnOpenAiSlowDownErrorStillThrowsRateLimitedException(): void + { + $this->expectException(RateLimitedException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(429, [ + 'error' => ['code' => 'slow_down', 'type' => 'rate_limit_error'], + ])); + } + + public function testBillingErrorCodesAreOnlyMappedOnA429Response(): void + { + $this->expectException(ProviderOverloadedException::class); + + $this->withErrorHandling('openai', fn (): never => throw $this->failoverableException(529, [ + 'error' => ['code' => 'insufficient_quota'], + ])); + } + + public function testBillingErrorCodesAreIgnoredWhenInsufficientCreditCodesIsOverriddenEmpty(): void + { + $gateway = new class { + use HandlesFailoverErrors { + withErrorHandling as public; + } + + /** + * Disable the billing error codes. + */ + protected function insufficientCreditCodes(): array + { + return []; + } + }; + + $this->expectException(RateLimitedException::class); + + $gateway->withErrorHandling('groq', fn (): never => throw $this->failoverableException(429, [ + 'error' => ['code' => 'insufficient_quota', 'type' => 'insufficient_quota'], + ])); + } + + public function test429WithANonJsonBodyStillThrowsRateLimitedException(): void + { + $response = new Psr7Response(429, ['Content-Type' => 'text/html'], 'Too Many Requests'); + + $this->expectException(RateLimitedException::class); + + $this->withErrorHandling('openai', fn (): never => throw new RequestException(new Response($response))); + } + + public function test429WithAnInsufficientQuotaErrorIsNotRetried(): void + { + Sleep::fake(); + + $attempts = 0; + + $request = function () use (&$attempts): never { + ++$attempts; + + throw $this->failoverableException(429, [ + 'error' => ['code' => 'insufficient_quota'], + ], ['Retry-After' => '1']); + }; + + try { + $this->withErrorHandling('openai', $request); + $this->fail('Expected the insufficient credits exception.'); + } catch (InsufficientCreditsException) { + } + + $this->assertSame(1, $attempts); + + Sleep::assertNeverSlept(); + } + public function test402ThrowsInsufficientCreditsExceptionWithoutRequiringPatterns(): void { $gateway = new class { @@ -73,6 +171,20 @@ public function testNonMatchingMessageIsRethrownAsTheOriginalRequestException(): } } + public function testNonStringMessageIsRethrownAsTheOriginalRequestException(): void + { + $failure = $this->failoverableException(400, [ + 'error' => ['message' => ['code' => 'invalid_prompt']], + ]); + + try { + $this->withErrorHandling('anthropic', static fn (): never => throw $failure); + $this->fail('Expected the original request exception.'); + } catch (RequestException $exception) { + $this->assertSame($failure, $exception); + } + } + #[DataProvider('retryableStatuses')] public function testARateLimitedOrOverloadedRequestIsRetriedOnceAfterItsRetryAfterDelay(int $status): void { diff --git a/tests/Broadcasting/BroadcastConnectionReleaseTest.php b/tests/Broadcasting/BroadcastConnectionReleaseTest.php index ce7e5ffc1a..9a2ef66bfb 100644 --- a/tests/Broadcasting/BroadcastConnectionReleaseTest.php +++ b/tests/Broadcasting/BroadcastConnectionReleaseTest.php @@ -43,7 +43,7 @@ public function testPusherReleasesIdleSessionsAndPreservesTransactionsAndPins(): { $observed = []; $pusher = m::mock(Pusher::class); - $pusher->shouldReceive('trigger')->times(9)->andReturnUsing(function () use (&$observed): object { + $pusher->shouldReceive('trigger')->times(12)->andReturnUsing(function () use (&$observed): object { $observed[] = $this->borrowedConnections(); DB::connection('broadcast')->selectOne('select 1'); @@ -51,10 +51,11 @@ public function testPusherReleasesIdleSessionsAndPreservesTransactionsAndPins(): }); $broadcaster = new PusherBroadcaster($this->app, $pusher); - $channels = array_map(static fn (int $id): string => 'orders.' . $id, range(1, 201)); + // Encrypted channels are triggered one at a time after the chunks. + $channels = [...array_map(static fn (int $id): string => 'orders.' . $id, range(1, 201)), 'private-encrypted-orders']; $this->sendInEachScope(fn () => $broadcaster->broadcast($channels, 'created')); - $this->assertSame([0, 0, 0, 1, 1, 1, 1, 1, 1], $observed); + $this->assertSame([0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1], $observed); } public function testAblyReleasesAfterPreparingTheMessage(): void diff --git a/tests/Broadcasting/PusherBroadcasterTest.php b/tests/Broadcasting/PusherBroadcasterTest.php index 9ab7e052b8..d0e11966a3 100644 --- a/tests/Broadcasting/PusherBroadcasterTest.php +++ b/tests/Broadcasting/PusherBroadcasterTest.php @@ -175,6 +175,19 @@ function (object $authenticatedUser, PusherBroadcasterTestEloquentModelStub $ord $this->assertSame('5', $boundOrder->boundValue); } + public function testBroadcastTriggersEncryptedChannelsSeparately(): void + { + $this->pusher->expects('trigger')->with(['private-a', 'b'], 'event', ['foo' => 'bar'], []); + $this->pusher->expects('trigger')->with(['private-encrypted-c'], 'event', ['foo' => 'bar'], []); + $this->pusher->expects('trigger')->with(['private-encrypted-d'], 'event', ['foo' => 'bar'], []); + + $this->broadcaster->broadcast( + ['private-a', 'private-encrypted-c', 'b', 'private-encrypted-d'], + 'event', + ['foo' => 'bar'] + ); + } + public function testValidAuthenticationResponseCallPusherSocketAuthMethodWithPrivateChannel(): void { $request = $this->getMockRequestWithUserForChannel('private-test'); diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index 54c8818a5a..f940e26706 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -2395,7 +2395,7 @@ 'isArray' => false, 'acceptValue' => false, 'default' => false, - 'description' => 'Display the key instead of modifying files', + 'description' => 'Display a new key instead of modifying files', ], [ 'name' => 'force', diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index 7c83b95ef4..dbe13f8dc1 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -393,7 +393,7 @@ public function testSkippedBackgroundTaskDoesNotDispatchCompletion(bool $onAnoth if ($onAnotherServer) { $this->assertStringContainsString( - 'Skipping [test:skipped-background], as command already run on another server.', + 'Skipping [test:skipped-background] because the command already ran on another server.', $output->fetch(), ); } @@ -678,6 +678,85 @@ public function testNonZeroExitDispatchesAndReportsFailure(): void $this->assertStringContainsString('Failed', $output->fetch()); } + public function testFailureAfterTheTaskFinishesReportsTheTaskRuntime(): void + { + $finishedEvent = null; + $failedEvent = null; + $dispatcher = $this->app->make(Dispatcher::class); + $dispatcher->listen(ScheduledTaskFinished::class, function (ScheduledTaskFinished $event) use (&$finishedEvent): void { + $finishedEvent = $event; + + // A slow listener, such as one sending an alert, is not part of the task's runtime. + usleep(50_000); + }); + $dispatcher->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $event) use (&$failedEvent): void { + $failedEvent = $event; + }); + $this->dispatcher = $dispatcher; + + $this->handler->shouldReceive('report')->once(); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 1, 'test:slow-listener')); + + $this->assertInstanceOf(ScheduledTaskFinished::class, $finishedEvent); + $this->assertInstanceOf(ScheduledTaskFailed::class, $failedEvent); + $this->assertSame($finishedEvent->runtime, $failedEvent->runtime); + } + + public function testDurationHandlerReportsAFailingCommandOnce(): void + { + $this->dispatcher = $this->app->make(Dispatcher::class); + $this->handler->shouldReceive('report')->once(); + + $runtimes = []; + + // The fixture finishes instantly, so a negative threshold reports every run. + (new Schedule)->whenTaskRunsLongerThan(-1, function (Event $task, float $runtime) use (&$runtimes): void { + $runtimes[] = $runtime; + }); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 1, 'test:slow-failure')); + + $this->assertCount(1, $runtimes); + } + + public function testDurationHandlerThatThrowsIsNotCalledAgainForTheFailedEvent(): void + { + $exception = new RuntimeException('Alert transport failed.'); + $this->dispatcher = $this->app->make(Dispatcher::class); + $this->handler->shouldReceive('report')->once()->with($exception); + + $schedule = new Schedule; + $throwingCalls = 0; + $laterCalls = 0; + + $schedule->whenTaskRunsLongerThan(-1, function () use (&$throwingCalls, $exception): void { + ++$throwingCalls; + + throw $exception; + }); + + $schedule->whenTaskRunsLongerThan(-1, function () use (&$laterCalls): void { + ++$laterCalls; + }); + + $command = $this->makeCommand(); + $this->captureOutput($command); + + $this->invokeRunEvent($command, new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 0, 'test:throwing-handler')); + + $this->assertSame(1, $throwingCalls); + + // The exception stops the Finished dispatch, so the later registration reports the run through ScheduledTaskFailed. + $this->assertSame(1, $laterCalls); + } + public function testBackgroundNonZeroExitDispatchesAndReportsFailureBeforeBackgroundCompletion(): void { $this->handler->shouldReceive('report') @@ -1072,7 +1151,7 @@ public function testSingleServerEventClaimedElsewhereDoesNotReportThatNoCommands $display = $output->fetch(); $this->assertStringContainsString( - 'Skipping [test:single-server], as command already run on another server.', + 'Skipping [test:single-server] because the command already ran on another server.', $display, ); $this->assertStringNotContainsString('No scheduled commands are ready to run.', $display); diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index 7f1097d0b8..e900242d31 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Hypervel\Bus\Queueable; use Hypervel\Console\Command; +use Hypervel\Console\Events\ScheduledTaskFailed; +use Hypervel\Console\Events\ScheduledTaskFinished; use Hypervel\Console\Scheduling\CacheAware; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; @@ -16,8 +18,10 @@ use Hypervel\Console\Scheduling\SchedulingMutex; use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Support\CarbonImmutable; @@ -29,6 +33,9 @@ use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; + +use function Hypervel\Coroutine\parallel; enum ScheduleTestQueueStringEnum: string { @@ -578,6 +585,75 @@ public function testEveryEventMayRunOnOneServer(): void $this->assertFalse($unnamedClosure->onOneServer); } + public function testWhenTaskRunsLongerThan(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $called = false; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$called) { + $called = true; + }); + + $events->dispatch(new ScheduledTaskFinished($task, 1.0)); + + $this->assertFalse($called); + + $events->dispatch(new ScheduledTaskFinished($task, 10.0)); + + $this->assertTrue($called); + } + + public function testWhenTaskRunsLongerThanHandlesFailedTasks(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $called = false; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$called) { + $called = true; + }); + + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException)); + + $this->assertFalse($called); + + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException, 10.0)); + + $this->assertTrue($called); + } + + public function testWhenTaskRunsLongerThanReportsEachConcurrentRunOnce(): void + { + $this->container->instance(DispatcherContract::class, $events = new EventDispatcher($this->container)); + + $schedule = new Schedule; + $task = $schedule->command('inspire'); + + $calls = 0; + + $schedule->whenTaskRunsLongerThan(5, function () use (&$calls): void { + ++$calls; + }); + + // A failing run dispatches ScheduledTaskFailed after ScheduledTaskFinished. + $run = function () use ($events, $task): void { + $events->dispatch(new ScheduledTaskFinished($task, 10.0)); + usleep(5000); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException, 10.0)); + }; + + parallel([$run, $run]); + + $this->assertSame(2, $calls); + } + public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks(): void { Schedule::withoutInterruptionPolling(); diff --git a/tests/Cookie/CookieJarTest.php b/tests/Cookie/CookieJarTest.php index ff84d1cec3..54cf22ab14 100644 --- a/tests/Cookie/CookieJarTest.php +++ b/tests/Cookie/CookieJarTest.php @@ -330,6 +330,18 @@ public function testQueueWithCreatingNewCookie(): void ); } + public function testQueueWithNamedArguments(): void + { + $cookieJar = new CookieJar; + $cookieJar->queue('foo', 'bar', 60, secure: true, sameSite: 'none'); + $cookie = $cookieJar->queued('foo'); + + $this->assertSame('/', $cookie->getPath()); + $this->assertNull($cookie->getDomain()); + $this->assertTrue($cookie->isSecure()); + $this->assertSame('none', $cookie->getSameSite()); + } + public function testGetQueuedCookies(): void { $cookieJar = new CookieJar; diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index 4dd37215dc..a671002373 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -36,6 +36,7 @@ public static function shouldBeLostProvider(): array 'PostgreSQL connection refused' => ["SQLSTATE[08006] [7] connection to server at \"example.database.com\" (10.0.1.7), port 5432 failed: Connection refused\nIs the server running on that host and accepting TCP/IP connections? (Connection: pgsql, Host: example.database.com, Port: 5432, Database: forge, SQL: select * from \"cache\" where \"key\" in (hypervel:queue:restart))"], 'MySQL server gone away' => ['SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'], 'SSL/TLS alert' => ['SSL error: ssl/tls alert unexpected message'], + 'Vitess query list terminated' => ['SQLSTATE[HY000]: General error: 1105 unknown: target: example.-.primary: vttablet: rpc error: code = Canceled desc = QueryList.TerminateAll(), elapsed time: 33.391648184s, killing connection ID 12345'], ]; } diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index f25869cafd..9267e5bb1f 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -570,6 +570,33 @@ public function testConnectionIsReestablishedAfterTheConnectionIsLostDuringRollB $this->assertSame($newPdo, $connection->getPdo()); } + public function testNestedTransactionIsRolledBackToItsSavepointOnConcurrencyError(): void + { + $pdo = $this->createMock(PDOStub::class); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->exactly(2))->method('exec')->willReturnCallback(function (string $sql): int { + static $calls = []; + $calls[] = $sql; + $this->assertSame(['SAVEPOINT trans2', 'ROLLBACK TO SAVEPOINT trans2'][count($calls) - 1], $sql); + + return 0; + }); + + $connection = $this->getMockConnection([], $pdo); + $connection->setQueryGrammar(new Grammar($connection)); + $connection->beginTransaction(); + + try { + $connection->transaction(function () { + throw new QueryException('conn', '', [], new PDOException('Lock wait timeout exceeded; try restarting transaction')); + }); + + $this->fail('A DeadlockException was not thrown.'); + } catch (DeadlockException) { + $this->assertSame(1, $connection->transactionLevel()); + } + } + public function testBeginTransactionMethodNeverRetriesIfWithinTransaction(): void { $pdo = $this->createMock(PDOStub::class); diff --git a/tests/Database/DatabaseEloquentResourceCollectionTest.php b/tests/Database/DatabaseEloquentResourceCollectionTest.php index aa8ffac5c2..81a7c437d9 100644 --- a/tests/Database/DatabaseEloquentResourceCollectionTest.php +++ b/tests/Database/DatabaseEloquentResourceCollectionTest.php @@ -76,4 +76,22 @@ public function testItCanTransformToResourceViaUseResourceCollectionAttribute(): $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); $this->assertInstanceOf(EloquentResourceTestJsonResource::class, $resource[0]); } + + public function testItCanGuessResourceForCollectionWithoutZeroKey(): void + { + $first = new EloquentResourceTestResourceModelWithUseResourceAttribute; + $second = new EloquentResourceTestResourceModelWithUseResourceAttribute; + + $filtered = (new Collection([$first, $second]))->filter(fn ($model) => $model === $second); + $resource = $filtered->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertCount(1, $resource->collection); + + $keyed = (new Collection([$first, $second]))->keyBy(fn ($model) => spl_object_id($model)); + $resource = $keyed->toResourceCollection(); + + $this->assertInstanceOf(AnonymousResourceCollection::class, $resource); + $this->assertCount(2, $resource->collection); + } } diff --git a/tests/Database/DatabaseMariaDbConnectionTest.php b/tests/Database/DatabaseMariaDbConnectionTest.php new file mode 100644 index 0000000000..9866926345 --- /dev/null +++ b/tests/Database/DatabaseMariaDbConnectionTest.php @@ -0,0 +1,27 @@ +makePartial(); + $connection->expects('selectOne') + ->with('select master_gtid_wait(?, 0) as master_gtid_wait, @@gtid_current_pos as gtid_current_pos', ['0-1-5,1-2-9']) + ->andReturn((object) ['master_gtid_wait' => -1, 'gtid_current_pos' => '0-1-5,1-2-7']); + + // Domain 0 holds this server's own write, which MASTER_GTID_WAIT never sees. + $connection->expects('scalar') + ->with('select master_gtid_wait(?, ?)', ['1-2-9', 5]) + ->andReturn(0); + + $this->assertTrue($connection->hasCommitId('0-1-5,1-2-9', 5)); + } +} diff --git a/tests/Database/DatabasePostgresConnectionTest.php b/tests/Database/DatabasePostgresConnectionTest.php index 4882d0a513..5f82ebd6f0 100644 --- a/tests/Database/DatabasePostgresConnectionTest.php +++ b/tests/Database/DatabasePostgresConnectionTest.php @@ -7,7 +7,12 @@ use DateTimeImmutable; use Hypervel\Database\PostgresConnection; use Hypervel\Tests\TestCase; +use Mockery as m; +use Mockery\MockInterface; use PDO; +use PDOStatement; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class DatabasePostgresConnectionTest extends TestCase { @@ -88,6 +93,89 @@ public function testEscapeUsesPostgresBooleanLiterals(): void $this->assertSame('false', $connection->escape(false)); } + #[DataProvider('commitIdWaitStatuses')] + public function testWaitingForACommitIdUsesOnlyTheReadConnection(string $status, ?bool $immediateResult, bool $expected): void + { + $readPdo = $this->commitIdReadPdo('19.0'); + $readPdo->expects('prepare') + ->with("wait for lsn '0/16B3748' with (timeout '5s', no_throw)") + ->andReturn($this->commitIdStatement($status)); + + if ($immediateResult !== null) { + $readPdo->expects('prepare') + ->with('select case when pg_is_in_recovery() then pg_last_wal_replay_lsn() else pg_current_wal_insert_lsn() end >= ?::pg_lsn') + ->andReturn($this->commitIdStatement($immediateResult)); + } + + $this->assertSame($expected, $this->newCommitIdConnection($readPdo)->hasCommitId('0/16B3748', 5)); + } + + /** + * Provide WAIT FOR LSN statuses and the resulting commit ID checks. + * + * @return array + */ + public static function commitIdWaitStatuses(): array + { + return [ + 'success' => ['success', null, true], + 'timeout' => ['timeout', null, false], + 'primary' => ['not in recovery', true, true], + ]; + } + + public function testWaitingForACommitIdRequiresPostgres19OnTheReadConnection(): void + { + $readPdo = $this->commitIdReadPdo('18.4'); + $readPdo->shouldNotReceive('prepare'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL 19 or newer is required.'); + + $this->newCommitIdConnection($readPdo)->hasCommitId('0/16B3748', 5); + } + + /** + * Create a read PDO reporting the given server version. + */ + protected function commitIdReadPdo(string $version): PDO&MockInterface + { + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn($version); + $pdo->shouldReceive('quote')->andReturnUsing(fn (string $value): string => "'{$value}'"); + + return $pdo; + } + + /** + * Create a statement returning the given single-column result. + */ + protected function commitIdStatement(bool|string $value): PDOStatement + { + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('setFetchMode')->andReturnTrue(); + $statement->shouldReceive('bindValue')->andReturnTrue(); + $statement->expects('execute')->andReturnTrue(); + $statement->expects('fetchAll')->andReturn([(object) ['result' => $value]]); + + return $statement; + } + + /** + * Create a read / write connection whose write side fails the test when used. + */ + protected function newCommitIdConnection(PDO $readPdo): PostgresConnection + { + $connection = new PostgresConnection( + fn () => $this->fail('The write connection was used.'), + 'test_db', + '', + ['name' => 'test', 'driver' => 'pgsql'], + ); + + return $connection->setReadPdo($readPdo); + } + /** * Create a connection with the configured prepare mode. */ diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 1af1eab5b9..77889dc238 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -8974,6 +8974,29 @@ public function testForceIndexSqlite(): void $this->assertSame('select "foo" from "users" indexed by test_index', $builder->toSql()); } + public function testForceIndexSqliteIgnoresPrimaryKey(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('PRIMARY'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testForceIndexSqliteIgnoresMultipleIndexes(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index, other_index'); + $this->assertSame('select "foo" from "users"', $builder->toSql()); + } + + public function testForceIndexSqliteRejectsInvalidIndexNames(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Index name contains invalid characters.'); + + $builder = $this->getSQLiteBuilder(); + $builder->select('foo')->from('users')->forceIndex('test_index, other index')->toSql(); + } + public function testIgnoreIndexSqlite(): void { $builder = $this->getSQLiteBuilder(); diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php index 04f7f6f68c..a898947dd9 100644 --- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -442,6 +442,22 @@ public function testErrors(): void ); } + public function testSuppressedErrorsAreAvailableViaErrorGetLast(): void + { + set_error_handler([$this->handleExceptions(), 'handleError']); + + try { + error_clear_last(); + + $result = @file_get_contents(__DIR__ . '/missing-file.txt'); + } finally { + restore_error_handler(); + } + + $this->assertFalse($result); + $this->assertStringContainsString('Failed to open stream: No such file or directory', error_get_last()['message'] ?? ''); + } + public function testConsoleThrowableRendersToStandardError(): void { $exception = new RuntimeException('Test exception'); @@ -847,9 +863,11 @@ class RecordingHandleExceptions extends HandleExceptions /** * Record a PHP error. */ - public function handleError(int $level, string $message, string $file = '', int $line = 0): void + public function handleError(int $level, string $message, string $file = '', int $line = 0): bool { $this->errors[] = $message; + + return true; } /** diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index 3bf9c04078..4f4e15dd2f 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -20,6 +20,7 @@ use Hypervel\Http\Middleware\TrustProxies; use Hypervel\Http\Request; use Hypervel\Session\Middleware\AuthenticateSession; +use Hypervel\Session\Middleware\StartSession; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionClass; @@ -340,6 +341,153 @@ public function testPreventRequestForgery(): void $this->assertTrue($reflection->getStaticPropertyValue('allowSameSite')); } + public function testReplace(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->replace('FirstMiddleware', 'ReplacedMiddleware'); + + $this->assertSame(['ReplacedMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testReplaceWithArray(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware', 'ThirdMiddleware']); + $middleware->replace('FirstMiddleware', ['FirstMiddleware', 'AfterMiddleware']); + $middleware->replace('ThirdMiddleware', ['BeforeMiddleware', 'ThirdMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'AfterMiddleware', + 'SecondMiddleware', + 'BeforeMiddleware', + 'ThirdMiddleware', + ], $middleware->getGlobalMiddleware()); + } + + public function testReplaceDefaultMiddlewareWithArray(): void + { + $middleware = new Middleware; + + $middleware->replace(TrustProxies::class, [TrustProxies::class, 'AfterMiddleware']); + + $global = $middleware->getGlobalMiddleware(); + + $this->assertSame('AfterMiddleware', $global[array_search(TrustProxies::class, $global) + 1]); + } + + public function testReplaceMissingMiddlewareWithArray(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware']); + $middleware->replace('MissingMiddleware', ['MissingMiddleware', 'AfterMiddleware']); + + $this->assertSame(['FirstMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testRemoveReplacedMiddleware(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->replace('FirstMiddleware', ['FirstMiddleware', 'AfterMiddleware']); + $middleware->remove('FirstMiddleware'); + + $this->assertSame(['AfterMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testReplaceInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->replaceInGroup('custom', 'FirstMiddleware', 'ReplacedMiddleware'); + + $this->assertSame(['ReplacedMiddleware', 'SecondMiddleware'], $middleware->getMiddlewareGroups()['custom']); + } + + public function testReplaceInGroupWithArray(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->replaceInGroup('custom', 'FirstMiddleware', ['BeforeMiddleware', 'FirstMiddleware', 'AfterMiddleware']); + + $this->assertSame([ + 'BeforeMiddleware', + 'FirstMiddleware', + 'AfterMiddleware', + 'SecondMiddleware', + ], $middleware->getMiddlewareGroups()['custom']); + } + + public function testReplaceInWebGroupWithArray(): void + { + $middleware = new Middleware; + + $middleware->web(replace: [ + StartSession::class => [StartSession::class, 'AfterMiddleware'], + ]); + + $web = $middleware->getMiddlewareGroups()['web']; + + $this->assertSame('AfterMiddleware', $web[array_search(StartSession::class, $web) + 1]); + } + + public function testInsertBefore(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertBefore('SecondMiddleware', ['BeforeMiddleware', 'AnotherBeforeMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'BeforeMiddleware', + 'AnotherBeforeMiddleware', + 'SecondMiddleware', + ], $middleware->getGlobalMiddleware()); + } + + public function testInsertAfter(): void + { + $middleware = new Middleware; + + $middleware->use(['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertAfter('FirstMiddleware', 'AfterMiddleware'); + + $this->assertSame(['FirstMiddleware', 'AfterMiddleware', 'SecondMiddleware'], $middleware->getGlobalMiddleware()); + } + + public function testInsertBeforeInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertBeforeInGroup('custom', 'SecondMiddleware', ['BeforeMiddleware', 'AnotherBeforeMiddleware']); + + $this->assertSame([ + 'FirstMiddleware', + 'BeforeMiddleware', + 'AnotherBeforeMiddleware', + 'SecondMiddleware', + ], $middleware->getMiddlewareGroups()['custom']); + } + + public function testInsertAfterInGroup(): void + { + $middleware = new Middleware; + + $middleware->group('custom', ['FirstMiddleware', 'SecondMiddleware']); + $middleware->insertAfterInGroup('custom', 'FirstMiddleware', 'AfterMiddleware'); + + $this->assertSame(['FirstMiddleware', 'AfterMiddleware', 'SecondMiddleware'], $middleware->getMiddlewareGroups()['custom']); + } + public function testDefaultGlobalMiddleware(): void { $middleware = new Middleware; diff --git a/tests/Image/ImageManagerTest.php b/tests/Image/ImageManagerTest.php index 415cf0e903..5297a6abf9 100644 --- a/tests/Image/ImageManagerTest.php +++ b/tests/Image/ImageManagerTest.php @@ -25,6 +25,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use TypeError; +use WeakReference; class ImageManagerTest extends TestCase { @@ -433,6 +434,26 @@ public function testLazyContentsAreNotSharedBetweenDifferentImages(): void $this->assertSame('first', $first->toBytes()); } + public function testLazyImagesAreFreedWithoutWaitingForTheGarbageCollector(): void + { + $app = $this->makeApp([]); + $manager = new ImageManager($app); + + gc_disable(); + + try { + $image = $manager->fromBase64(base64_encode('contents')); + $this->assertSame('contents', $image->toBytes()); + + $reference = WeakReference::create($image); + unset($image); + + $this->assertNull($reference->get()); + } finally { + gc_enable(); + } + } + public function testFromUrlIsOnlyFetchedOnce(): void { $contents = $this->fakeImageContents(); diff --git a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php index 1562c7cc22..c313641aea 100644 --- a/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleRunCommandTest.php @@ -7,7 +7,9 @@ use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Events\ScheduledTaskFailed; use Hypervel\Console\Events\ScheduledTaskFinished; +use Hypervel\Console\Events\ScheduledTaskSkipped; use Hypervel\Console\Events\ScheduledTaskStarting; +use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Console\Scheduling\Event as ScheduledEvent; use Hypervel\Console\Scheduling\EventMutex; use Hypervel\Console\Scheduling\Schedule; @@ -16,6 +18,7 @@ use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Console\Fixtures\FakeEventMutex; use ReflectionMethod; @@ -213,6 +216,36 @@ public function testOverlappingTaskFinishedEventIndicatesSkipped(): void $this->assertFalse($ran); } + // The array store is coroutine-local, so the other server's mutex would be invisible to the task's coroutine. + #[WithConfig('cache.default', 'worker-array')] + public function testTaskSkippedEventIsDispatchedWhenTaskRanOnAnotherServer(): void + { + Event::fake([ + ScheduledTaskStarting::class, + ScheduledTaskFinished::class, + ScheduledTaskSkipped::class, + ]); + + CarbonImmutable::setTestNow(CarbonImmutable::now()->startOfMinute()); + + $ran = false; + $schedule = $this->app->make(Schedule::class); + $task = $schedule->call(function () use (&$ran): void { + $ran = true; + })->name('test')->onOneServer()->everyMinute(); + + $this->app->make(CacheSchedulingMutex::class)->create($task, CarbonImmutable::now()); + + $this->runSchedule($task); + + Event::assertDispatched(ScheduledTaskSkipped::class, function (ScheduledTaskSkipped $event) use ($task): bool { + return $event->task === $task; + }); + Event::assertNotDispatched(ScheduledTaskStarting::class); + Event::assertNotDispatched(ScheduledTaskFinished::class); + $this->assertFalse($ran); + } + /** * @throws BindingResolutionException */ diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index cb2206bd11..00442122d3 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -756,6 +756,25 @@ public function testPaginationWithMultipleAliases(): void $this->assertSame('B (post)', current($result->items())->alias, 'Expect the paginated query would return `B (post)`'); } + public function testPaginationWithMultipleAliasesAndMultipleOrders(): void + { + TestUser::fillAndInsert([['name' => 'A'], ['name' => 'C']]); + TestPost::fillAndInsert([['title' => 'A', 'user_id' => 2], ['title' => 'B', 'user_id' => 1]]); + + $table1 = TestPost::select(['title as label', 'user_id as rank']); + $table2 = TestUser::select(['name as label', 'id as rank']); + + $result = $table1->toBase() + ->union($table2->toBase()) + ->orderBy('label') + ->orderBy('rank') + ->cursorPaginate(1, ['*'], 'cursor', new Cursor(['label' => 'A', 'rank' => 1])); + + $this->assertCount(1, $result->items()); + $this->assertSame('A', current($result->items())->label); + $this->assertEquals(2, current($result->items())->rank); + } + public function testPaginationWithAliasedOrderBy(): void { TestUser::fillAndInsert([[], [], [], [], [], []]); diff --git a/tests/Integration/Database/EloquentWithCountTest.php b/tests/Integration/Database/EloquentWithCountTest.php index d47fcf61f9..a923f9c0a5 100644 --- a/tests/Integration/Database/EloquentWithCountTest.php +++ b/tests/Integration/Database/EloquentWithCountTest.php @@ -78,6 +78,15 @@ public function testGlobalScopes(): void $this->assertEquals(1, $result->all_fours_count); } + public function testWithExists(): void + { + Model1::create()->twos()->create(); + Model1::create(); + + $this->assertSame([true, false], Model1::withExists('twos')->orderBy('id')->pluck('twos_exists')->all()); + $this->assertSame([true, false], Model1::orderBy('id')->get()->loadExists('twos')->pluck('twos_exists')->all()); + } + public function testSortingScopes(): void { $one = Model1::create(); diff --git a/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php b/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php index d6af50b955..26ba12d99b 100644 --- a/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php +++ b/tests/Integration/Database/MariaDb/DatabaseMariaDbConnectionTest.php @@ -231,4 +231,25 @@ public static function jsonContainsKeyDataProvider(): array 'null value' => [1, 'json_col->bar'], ]; } + + public function testLastCommitReadAfterWrite(): void + { + if (! DB::scalar('select @@log_bin')) { + $this->markTestSkipped('Commit IDs require log_bin = ON.'); + } + + $first = DB::getLastCommitId(); + DB::table(self::TABLE)->insert([self::JSON_COL => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('4294967295-4294967295-18446744073709551615')); + $this->assertTrue(DB::hasCommitId($second, 1)); + $this->assertFalse(DB::hasCommitId('4294967295-4294967295-18446744073709551615', 1)); + } } diff --git a/tests/Integration/Database/MigratorEventsTest.php b/tests/Integration/Database/MigratorEventsTest.php index d28cd68ea5..07b7860a01 100644 --- a/tests/Integration/Database/MigratorEventsTest.php +++ b/tests/Integration/Database/MigratorEventsTest.php @@ -154,12 +154,14 @@ public function testMigrationEventsContainTheMigrationAndMethod(): void Event::assertDispatched(MigrationEnded::class, function ($event) { return $event->method === 'up' && $event->migration instanceof Migration - && $event->name === '2014_10_12_000000_create_members_table'; + && $event->name === '2014_10_12_000000_create_members_table' + && is_float($event->duration); }); Event::assertDispatched(MigrationEnded::class, function ($event) { return $event->method === 'down' && $event->migration instanceof Migration - && $event->name === '2014_10_12_000000_create_members_table'; + && $event->name === '2014_10_12_000000_create_members_table' + && is_float($event->duration); }); } diff --git a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php index 9a0ac11f10..0ccbc9dcba 100644 --- a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php +++ b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php @@ -293,4 +293,25 @@ public function testLastInsertIdIsPreserved(): void Schema::drop('auto_id_table'); } } + + public function testLastCommitReadAfterWrite(): void + { + if (! DB::scalar('select @@log_bin') || DB::scalar('select @@global.gtid_mode') !== 'ON') { + $this->markTestSkipped('Commit IDs require log_bin = ON and gtid_mode = ON.'); + } + + $first = DB::getLastCommitId(); + DB::table(self::TABLE)->insert([self::JSON_COL => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('ffffffff-ffff-ffff-ffff-ffffffffffff:9223372036854775806')); + $this->assertTrue(DB::hasCommitId($second, 1)); + $this->assertFalse(DB::hasCommitId('ffffffff-ffff-ffff-ffff-ffffffffffff:9223372036854775806', 1)); + } } diff --git a/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php index 26b614576d..9b6fe0533b 100644 --- a/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php +++ b/tests/Integration/Database/Postgres/DatabasePostgresConnectionTest.php @@ -9,9 +9,12 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; +use PDO; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use RuntimeException; #[RequiresOperatingSystem('Linux|Darwin')] #[RequiresPhpExtension('pdo_pgsql')] @@ -201,4 +204,61 @@ public static function jsonContainsKeyDataProvider() 'null value' => [1, 'json_col->bar'], ]; } + + public function testLastCommitReadAfterWrite(): void + { + $first = DB::getLastCommitId(); + DB::table('json_table')->insert(['json_col' => '{"commit":"read-after-write"}']); + $second = DB::getLastCommitId(); + $merged = DB::mergeCommitIds([$second, $first]); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $merged); + $this->assertSame($second, DB::mergeCommitIds(array_unique([$first, $first, $second]))); + $this->assertTrue(DB::hasCommitId($first)); + $this->assertTrue(DB::hasCommitId($second)); + $this->assertTrue(DB::hasCommitId($merged)); + $this->assertFalse(DB::hasCommitId('FFFFFFFF/FFFFFFFF')); + } + + public function testCommitIdChecksReturnBooleansWhenFetchesAreStringified(): void + { + config()->set('database.connections.pgsql_stringified', [ + ...config()->array('database.connections.' . config()->string('database.default')), + 'options' => [PDO::ATTR_STRINGIFY_FETCHES => true], + ]); + $connection = DB::connection('pgsql_stringified'); + + $this->assertTrue($connection->hasCommitId($connection->getLastCommitId())); + $this->assertFalse($connection->hasCommitId('FFFFFFFF/FFFFFFFF')); + } + + public function testLastCommitIdIncludesAnAsynchronousCommit(): void + { + $beforeCommit = DB::transaction(function () { + DB::statement('set local synchronous_commit = off'); + DB::table('json_table')->insert(['json_col' => '{"commit":"asynchronous"}']); + + return DB::scalar('select pg_current_wal_insert_lsn()'); + }); + + // The commit record follows the transaction's last insert, so a token that includes it must be later. + $this->assertTrue(DB::scalar('select ?::pg_lsn > ?::pg_lsn', [DB::getLastCommitId(), $beforeCommit])); + } + + #[RequiresDatabase('pgsql', '<19')] + public function testWaitingForACommitIdRequiresPostgres19(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL 19 or newer is required.'); + + DB::hasCommitId(DB::getLastCommitId(), 1); + } + + #[RequiresDatabase('pgsql', '>=19')] + public function testWaitingForACommitIdOnThePrimaryFallsBackToTheImmediateCheck(): void + { + $this->assertTrue(DB::hasCommitId(DB::getLastCommitId(), 1)); + $this->assertFalse(DB::hasCommitId('FFFFFFFF/FFFFFFFF', 1)); + } } diff --git a/tests/Integration/Database/QueryBuilderUpdateTest.php b/tests/Integration/Database/QueryBuilderUpdateTest.php index 6965966c7b..9aaaf01cbd 100644 --- a/tests/Integration/Database/QueryBuilderUpdateTest.php +++ b/tests/Integration/Database/QueryBuilderUpdateTest.php @@ -155,4 +155,32 @@ public function testSubqueryUpdate(): void 'credits' => null, ]); } + + public function testUpdateWithOrderAndLimit(): void + { + DB::table('example')->insert([ + ['name' => 'A', 'credits' => 30], + ['name' => 'B', 'credits' => 10], + ['name' => 'C', 'credits' => 40], + ['name' => 'D', 'credits' => 20], + ]); + + DB::table('example')->orderBy('credits')->limit(2)->update(['title' => 'Low']); + + $this->assertSame(['B', 'D'], DB::table('example')->where('title', 'Low')->orderBy('name')->pluck('name')->all()); + } + + public function testDeleteWithOrderAndLimit(): void + { + DB::table('example')->insert([ + ['name' => 'A', 'credits' => 30], + ['name' => 'B', 'credits' => 10], + ['name' => 'C', 'credits' => 40], + ['name' => 'D', 'credits' => 20], + ]); + + DB::table('example')->orderBy('credits')->limit(2)->delete(); + + $this->assertSame(['A', 'C'], DB::table('example')->orderBy('name')->pluck('name')->all()); + } } diff --git a/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php b/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php new file mode 100644 index 0000000000..938942a298 --- /dev/null +++ b/tests/Integration/Database/Sqlite/DatabaseSqliteIndexHintTest.php @@ -0,0 +1,56 @@ +id(); + $table->integer('user_id')->index(); + $table->integer('status')->index(); + }); + + DB::table('posts')->insert([ + ['user_id' => 1, 'status' => 1], + ['user_id' => 2, 'status' => 1], + ]); + } + + /** + * Drop the posts table. + */ + protected function destroyDatabaseMigrations(): void + { + Schema::drop('posts'); + } + + #[DataProvider('forceIndexDataProvider')] + public function testForceIndex(string $index): void + { + $this->assertSame(1, DB::table('posts')->forceIndex($index)->where('user_id', 1)->count()); + } + + /** + * Provide index hints that SQLite can run. + */ + public static function forceIndexDataProvider(): array + { + return [ + 'named index' => ['posts_user_id_index'], + 'primary key' => ['PRIMARY'], + 'multiple indexes' => ['posts_user_id_index, posts_status_index'], + ]; + } +} diff --git a/tests/Integration/Queue/CallQueuedHandlerTest.php b/tests/Integration/Queue/CallQueuedHandlerTest.php index 7aff027206..8140eccead 100644 --- a/tests/Integration/Queue/CallQueuedHandlerTest.php +++ b/tests/Integration/Queue/CallQueuedHandlerTest.php @@ -14,9 +14,14 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Cache\Lock; use Hypervel\Contracts\Cache\Repository as Cache; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Queue\Job; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Events\CallQueuedListener; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; use Hypervel\Queue\CallQueuedHandler; @@ -24,9 +29,12 @@ use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Jobs\FakeJob; +use Hypervel\Queue\Jobs\Job as BaseJob; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; use stdClass; class CallQueuedHandlerTest extends TestCase @@ -186,7 +194,28 @@ public function testBatchJobIsRecordedWhenDeletedDueToMissingModel(): void Event::assertNotDispatched(JobFailed::class); } - public function testUniqueJobLockIsReleasedViaContextOnModelNotFound(): void + public function testBatchJobIsRecordedAsFailedWhenModelIsMissing(): void + { + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $exception = new ModelNotFoundException('Foo'); + + $batch = m::mock(Batch::class); + $batch->expects('recordFailedJob')->with('job-uuid', $exception); + + $repository = m::mock(BatchRepository::class); + $repository->expects('find')->with('test-batch-id')->andReturn($batch); + $this->app->instance(BatchRepository::class, $repository); + + $this->expectException(ModelNotFoundException::class); + + $instance->failed([ + 'batchId' => 'test-batch-id', + 'command' => serialize((new CallQueuedHandlerBatchableExceptionThrower)->withBatchId('test-batch-id')), + ], $exception, 'job-uuid'); + } + + public function testUniqueJobLockIsReleasedFromPayloadWhenDeletedForMissingModel(): void { $lock = m::mock(Lock::class); $lock->expects('forceRelease'); @@ -198,23 +227,23 @@ public function testUniqueJobLockIsReleasedViaContextOnModelNotFound(): void $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); $this->app->instance(CacheFactory::class, $cacheFactory); - ContextRepository::getInstance()->addHidden([ - 'laravel_unique_job_cache_store' => 'array', - 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', - ]); - $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); $job = m::mock(Job::class); - $job->shouldReceive('payload')->andReturn(['deleteWhenMissingModels' => false]); - $job->expects('fail'); + $job->shouldReceive('payload')->andReturn([ + 'deleteWhenMissingModels' => true, + 'illuminate:log:context' => ['hidden' => $this->uniqueLockMetadata()], + ]); + $job->shouldReceive('attempts')->andReturn(1); + $job->expects('resolveQueuedJobClass')->andReturn(CallQueuedHandlerExceptionThrower::class); + $job->expects('delete'); $instance->call($job, [ - 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + 'command' => serialize(new CallQueuedHandlerExceptionThrower), ]); } - public function testUniqueJobLockOwnerIsRestoredViaContextOnModelNotFound(): void + public function testUniqueJobLockOwnerIsRestoredFromPayloadWhenDeletedForMissingModel(): void { $lock = m::mock(Lock::class); $lock->expects('release'); @@ -230,21 +259,142 @@ public function testUniqueJobLockOwnerIsRestoredViaContextOnModelNotFound(): voi $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); $this->app->instance(CacheFactory::class, $cacheFactory); - ContextRepository::getInstance()->addHidden([ - 'laravel_unique_job_cache_store' => 'array', - 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', - 'laravel_unique_job_lock_owner' => 'owner-token', - ]); - $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); $job = m::mock(Job::class); - $job->shouldReceive('payload')->andReturn(['deleteWhenMissingModels' => false]); - $job->expects('fail'); + $job->shouldReceive('payload')->andReturn([ + 'deleteWhenMissingModels' => true, + 'illuminate:log:context' => ['hidden' => $this->uniqueLockMetadata('owner-token')], + ]); + $job->expects('resolveQueuedJobClass')->andReturn(CallQueuedHandlerExceptionThrower::class); + $job->expects('delete'); $instance->call($job, [ - 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + 'command' => serialize(new CallQueuedHandlerExceptionThrower), + ]); + } + + #[TestWith([true], 'payload metadata')] + #[TestWith([false], 'no payload metadata')] + public function testUniqueJobLockIsReleasedOnlyFromThePayloadWhenFailingWithMissingModel(bool $payloadHasLockMetadata): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + $this->assertTrue($cache->lock('laravel_unique_job:Other:1')->acquire()); + + // Another job's metadata in the ambient context, as in the worker's timeout monitor. + ContextRepository::getInstance()->addHidden([ + 'laravel_unique_job_cache_store' => 'worker-array', + 'laravel_unique_job_key' => 'laravel_unique_job:Other:1', + ]); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueJob::class, + $payloadHasLockMetadata ? $this->uniqueLockMetadata(store: 'worker-array') : [], + )); + + try { + $this->app->make(CallQueuedHandler::class)->failed($job->payload()['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertSame($payloadHasLockMetadata, $cache->lock('laravel_unique_job:TestJob:42')->acquire()); + $this->assertFalse($cache->lock('laravel_unique_job:Other:1')->acquire()); + } + + public function testOwnerlessUniqueUntilProcessingLockIsKeptWhenFailingWithMissingModelAfterProcessingMayHaveStarted(): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + + // A replacement dispatch holds the lock the failed job released when its attempt began processing. + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, + $this->uniqueLockMetadata(store: 'worker-array'), + )); + + try { + $this->app->make(CallQueuedHandler::class)->failed($job->payload()['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertFalse($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + } + + public function testOwnerlessRegularUniqueListenerLockIsReleasedWhenFailingWithMissingModel(): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + + $payload = $this->missingModelPayload(CallQueuedListener::class, $this->uniqueLockMetadata(store: 'worker-array')); + $payload['displayName'] = CallQueuedHandlerUniqueListener::class; + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $payload); + + try { + $this->app->make(CallQueuedHandler::class)->failed($payload['data'], new RuntimeException, 'job-uuid', $job); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42')->acquire()); + } + + #[TestWith([1, 1])] + #[TestWith([2, 0])] + public function testMissingModelFailureReleasesAnOwnerlessUniqueUntilProcessingLockOnceBeforeProcessing(int $attempts, int $releases): void + { + $lock = m::mock(Lock::class); + $lock->expects('forceRelease')->times($releases); + + $cache = m::mock(Cache::class); + $cache->shouldReceive('lock')->with('laravel_unique_job:TestJob:42')->andReturn($lock); + + $cacheFactory = m::mock(CacheFactory::class); + $cacheFactory->shouldReceive('store')->with('array')->andReturn($cache); + $this->app->instance(CacheFactory::class, $cacheFactory); + + $job = new CallQueuedHandlerMissingModelQueueJob($this->app, $this->missingModelPayload( + CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, + $this->uniqueLockMetadata(), + ), $attempts); + + try { + $this->app->make(CallQueuedHandler::class)->call($job, $job->payload()['data']); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertTrue($job->hasFailed()); + } + + #[TestWith([CallQueuedHandlerMissingModelUniqueJob::class, '', true], 'ownerless unique job')] + #[TestWith([CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, 'owner-token', true], 'owned unique until processing job')] + #[TestWith([CallQueuedHandlerMissingModelUniqueUntilProcessingJob::class, '', false], 'ownerless unique until processing job')] + public function testUniqueJobLockIsReleasedFromContextWhenFailingWithMissingModelWithoutAJob(string $commandName, string $owner, bool $released): void + { + $cache = $this->app->make(CacheFactory::class)->store('worker-array'); + $this->assertTrue($cache->lock('laravel_unique_job:TestJob:42', 0, 'owner-token')->acquire()); + + ContextRepository::getInstance()->addHidden([ + 'laravel_unique_job_cache_store' => 'worker-array', + 'laravel_unique_job_key' => 'laravel_unique_job:TestJob:42', + 'laravel_unique_job_lock_owner' => $owner, ]); + + try { + $this->app->make(CallQueuedHandler::class)->failed([ + 'commandName' => $commandName, + 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + ], new RuntimeException, 'job-uuid'); + $this->fail('The missing model was not reported.'); + } catch (ModelNotFoundException) { + } + + $this->assertSame($released, $cache->lock('laravel_unique_job:TestJob:42')->acquire()); } public function testDebouncedJobEventIsSkippedWithoutListeners(): void @@ -303,6 +453,96 @@ public function testDebouncedJobEventRemainsVisibleToEventFake(): void && $event->command === $command; }); } + + /** + * Get the payload metadata of a unique job lock. + * + * @return array + */ + protected function uniqueLockMetadata(string $owner = '', string $store = 'array'): array + { + return [ + 'laravel_unique_job_cache_store' => serialize($store), + 'laravel_unique_job_key' => serialize('laravel_unique_job:TestJob:42'), + 'laravel_unique_job_lock_owner' => serialize($owner), + ]; + } + + /** + * Get the payload of a queued command whose model is missing. + * + * @param class-string $commandName + * @param array $hidden + */ + protected function missingModelPayload(string $commandName, array $hidden): array + { + return [ + 'job' => 'Illuminate\Queue\CallQueuedHandler@call', + 'uuid' => 'job-uuid', + 'displayName' => $commandName, + 'deleteWhenMissingModels' => false, + 'data' => [ + 'commandName' => $commandName, + 'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete), + ], + 'illuminate:log:context' => ['hidden' => $hidden], + ]; + } +} + +class CallQueuedHandlerMissingModelQueueJob extends BaseJob +{ + /** + * Create a new job instance. + */ + public function __construct(ContainerContract $container, protected array $rawPayload, protected int $attemptCount = 1) + { + $this->container = $container; + $this->connectionName = 'sync'; + $this->queue = 'default'; + } + + /** + * Get the job identifier. + */ + public function getJobId(): string + { + return 'job-id'; + } + + /** + * Get the raw body of the job. + */ + public function getRawBody(): string + { + return json_encode($this->rawPayload, JSON_THROW_ON_ERROR); + } + + /** + * Get the number of times the job has been attempted. + */ + public function attempts(): int + { + return $this->attemptCount; + } +} + +class CallQueuedHandlerMissingModelUniqueJob implements ShouldBeUnique +{ +} + +class CallQueuedHandlerMissingModelUniqueUntilProcessingJob implements ShouldBeUniqueUntilProcessing +{ +} + +class CallQueuedHandlerUniqueListener implements ShouldBeUnique, ShouldQueue +{ + /** + * Handle the event. + */ + public function handle(): void + { + } } class TestableCallQueuedHandler extends CallQueuedHandler diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 5b055736ee..3474c8c68b 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Worker; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Artisan; @@ -365,6 +366,20 @@ public function testFailedJobListenerOnlyRunsOnce(): void $this->assertSame(2, substr_count(Artisan::output(), JobWillFail::class)); } + public function testReleasedJobIsWrittenAsReleased(): void + { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + + Queue::push(new ReleasingJob); + + $this->artisan('queue:work', [ + '--once' => true, + '--memory' => 1024, + ])->expectsOutputToContain('RELEASED') + ->doesntExpectOutputToContain('DONE') + ->assertExitCode(0); + } + public function testStopReasonIsWritten(): void { Queue::push(new FirstJob); @@ -484,6 +499,21 @@ public function handle(): void } } +class ReleasingJob implements ShouldQueue +{ + use Dispatchable; + use InteractsWithQueue; + use Queueable; + + /** + * Release the job back onto the queue. + */ + public function handle(): void + { + $this->release(30); + } +} + class JobWillFail implements ShouldQueue { use Dispatchable; diff --git a/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php b/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php index 29f9e6bdd0..293681b54e 100644 --- a/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php +++ b/tests/Mcp/Feature/Testing/Tools/AssertStructuredContentTest.php @@ -10,7 +10,10 @@ use Hypervel\Mcp\Server\Tool; use Hypervel\Testing\Fluent\AssertableJson; use Hypervel\Tests\Mcp\TestCase; +use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; +use stdClass; enum BookingStatus: string { @@ -23,10 +26,37 @@ class BookingServer extends Server GetBookingTool::class, GetBookingWithoutStructuredContentTool::class, GetBookingWithSerializableValuesTool::class, + ListBookingsTool::class, EmptyStructuredContentTool::class, + GetBookingFiltersTool::class, ]; } +class ListBookingsTool extends Tool +{ + protected string $name = 'booking/list'; + + protected string $title = 'List bookings'; + + protected string $description = 'List bookings'; + + /** + * Return a structured list of bookings. + */ + public function handle(): ResponseFactory + { + return Response::structured([ + 'data' => [ + ['id' => 123, 'status' => BookingStatus::Confirmed], + ['id' => 124, 'status' => BookingStatus::Confirmed], + ], + 'meta' => [ + 'total' => 2, + ], + ]); + } +} + class GetBookingWithSerializableValuesTool extends Tool { protected string $name = 'booking/get/serializable'; @@ -96,6 +126,20 @@ public function handle(): ResponseFactory } } +class GetBookingFiltersTool extends Tool +{ + /** + * Return structured content with JSON objects that decode to PHP lists. + */ + public function handle(): ResponseFactory + { + return Response::structured([ + 'filters' => new stdClass, + 'labels' => (object) ['priority'], + ]); + } +} + class AssertStructuredContentTest extends TestCase { public function testMayAssertEmptyStructuredContent(): void @@ -184,4 +228,394 @@ public function testFailsToAssertTheStructuredContentWithAClosureWhenItIsNotPres $this->expectException(ExpectationFailedException::class); $response->assertStructuredContent(fn (AssertableJson $json): AssertableJson => $json->etc()); } + + public function testMayAssertTheStructuredContentCount(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $response->assertStructuredContentCount(3); + } + + public function testMayAssertTheStructuredContentCountAtAKey(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentCount(2, 'data'); + } + + public function testFailsToAssertTheStructuredContentCountIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentCount(3, 'data'); + } + + public function testFailsToAssertTheStructuredContentCountWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentCount(0); + } + + public function testMayAssertTheStructuredContentPath(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response + ->assertStructuredContentPath('data.1.id', 124) + ->assertStructuredContentPath('meta', ['total' => 2]); + } + + public function testMayAssertTheStructuredContentPathWithClosure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPath('meta.total', fn (int $total): bool => $total === 2); + } + + public function testFailsToAssertTheStructuredContentPathIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('data.1.id', 123); + } + + public function testFailsToAssertTheStructuredContentPathWithClosure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('meta.total', fn (int $total): bool => $total === 3); + } + + public function testMayAssertTheStructuredContentPathWithAnEnum(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPath('data.0.status', BookingStatus::Confirmed); + } + + public function testFailsToAssertTheStructuredContentPathWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPath('id', 123); + } + + public function testMayAssertTheStructuredContentPaths(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPaths([ + 'data.0.id' => 123, + 'data.1.status' => BookingStatus::Confirmed, + 'meta.total' => 2, + ]); + } + + public function testFailsToAssertTheStructuredContentPathsAreWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPaths([ + 'data.0.id' => 123, + 'meta.total' => 3, + ]); + } + + public function testMayAssertTheStructuredContentPathCanonicalizing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPathCanonicalizing('data.*.id', [124, 123]); + } + + public function testFailsToAssertTheStructuredContentPathCanonicalizingIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPathCanonicalizing('data.*.id', [124, 125]); + } + + public function testMayAssertTheExactStructuredContentRegardlessOfKeyOrder(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $response->assertExactStructuredContent([ + 'status' => 'confirmed', + 'id' => 123, + 'type' => 'booking', + ]); + } + + public function testFailsToAssertTheExactStructuredContentIsWrong(): void + { + $response = BookingServer::tool(GetBookingTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertExactStructuredContent([ + 'id' => 123, + 'status' => 'confirmed', + ]); + } + + public function testMayAssertAStructuredContentFragment(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentFragment(['id' => 124]); + } + + public function testFailsToAssertAStructuredContentFragmentThatIsNotPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragment(['id' => 125]); + } + + public function testFailsToAssertAStructuredContentFragmentWhenItIsNotPresent(): void + { + $response = BookingServer::tool(GetBookingWithoutStructuredContentTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragment(['id' => 123]); + } + + public function testMayAssertAStructuredContentPathIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingPath('data.2'); + } + + public function testFailsToAssertAStructuredContentPathIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissingPath('meta.total'); + } + + public function testMayAssertTheStructuredContentStructure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentStructure([ + 'data' => [ + '*' => ['id', 'status'], + ], + 'meta' => ['total'], + ]); + } + + public function testFailsToAssertTheStructuredContentStructureIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentStructure([ + 'meta' => ['per_page'], + ]); + } + + public function testMayAssertTheStructuredContentPathsCanonicalizing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentPathsCanonicalizing([ + 'data.*.id' => [124, 123], + 'data.*.status' => ['confirmed', 'confirmed'], + ]); + } + + public function testFailsToAssertTheStructuredContentPathsCanonicalizingAreWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentPathsCanonicalizing([ + 'data.*.id' => [124, 123], + 'data.*.status' => ['confirmed', 'pending'], + ]); + } + + public function testMayAssertSimilarStructuredContentRegardlessOfOrder(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertSimilarStructuredContent([ + 'meta' => [ + 'total' => 2, + ], + 'data' => [ + ['status' => 'confirmed', 'id' => 124], + ['status' => 'confirmed', 'id' => 123], + ], + ]); + } + + public function testFailsToAssertSimilarStructuredContentIsWrong(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertSimilarStructuredContent([ + 'data' => [ + ['id' => 123, 'status' => 'confirmed'], + ], + 'meta' => [ + 'total' => 2, + ], + ]); + } + + public function testMayAssertStructuredContentFragments(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentFragments([ + ['id' => 123], + ['total' => 2], + ]); + } + + public function testFailsToAssertStructuredContentFragmentsThatAreNotPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentFragments([ + ['id' => 123], + ['total' => 3], + ]); + } + + public function testMayAssertStructuredContentIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissing(['id' => 125]); + } + + public function testFailsToAssertStructuredContentIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissing(['id' => 124]); + } + + public function testMayAssertExactStructuredContentIsMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingExact(['id' => 124, 'status' => 'pending']); + } + + public function testFailsToAssertExactStructuredContentIsMissingWhenItIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(AssertionFailedError::class); + $response->assertStructuredContentMissingExact(['id' => 124, 'status' => 'confirmed']); + } + + public function testMayAssertStructuredContentPathsAreMissing(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentMissingPaths(['data.2', 'meta.per_page']); + } + + public function testFailsToAssertStructuredContentPathsAreMissingWhenOneIsPresent(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentMissingPaths(['data.2', 'meta.total']); + } + + public function testMayAssertTheExactStructuredContentStructure(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertExactStructuredContentStructure([ + 'data' => [ + '*' => ['id', 'status'], + ], + 'meta' => ['total'], + ]); + } + + public function testFailsToAssertTheExactStructuredContentStructureWhenKeysAreNotListed(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertExactStructuredContentStructure([ + 'data' => [ + '*' => ['id'], + ], + 'meta' => ['total'], + ]); + } + + public function testMayAssertTheStructuredContentIsAnArray(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response->assertStructuredContentIsArray('data'); + } + + public function testFailsToAssertTheStructuredContentIsAnArrayWhenItIsAnObject(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsArray('meta'); + } + + #[TestWith([EmptyStructuredContentTool::class, null])] + #[TestWith([GetBookingFiltersTool::class, 'filters'])] + #[TestWith([GetBookingFiltersTool::class, 'labels'])] + public function testFailsToAssertTheStructuredContentIsAnArrayWhenAnObjectDecodesToAList(string $tool, ?string $key): void + { + $response = BookingServer::tool($tool); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsArray($key); + } + + public function testMayAssertTheStructuredContentIsAnObject(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $response + ->assertStructuredContentIsObject() + ->assertStructuredContentIsObject('meta'); + } + + public function testMayAssertTheStructuredContentIsAnObjectWhenItDecodesToAList(): void + { + BookingServer::tool(EmptyStructuredContentTool::class)->assertStructuredContentIsObject(); + + BookingServer::tool(GetBookingFiltersTool::class) + ->assertStructuredContentIsObject('filters') + ->assertStructuredContentIsObject('labels'); + } + + public function testFailsToAssertTheStructuredContentIsAnObjectWhenItIsAnArray(): void + { + $response = BookingServer::tool(ListBookingsTool::class); + + $this->expectException(ExpectationFailedException::class); + $response->assertStructuredContentIsObject('data'); + } } diff --git a/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php b/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php new file mode 100644 index 0000000000..b8ffe19b4d --- /dev/null +++ b/tests/Mcp/Unit/Client/OAuth/AuthServerDiscoveryTest.php @@ -0,0 +1,221 @@ +> $hosts + */ + private function resolveHosts(array $hosts): void + { + AuthServerDiscovery::resolveHostsUsing(fn (string $host): array => $hosts[$host] ?? []); + } + + /** + * Fake the resource and authorization server metadata responses. + */ + private function fakeAuthServer( + string $resource = 'https://mcp.test', + string $authorizationEndpoint = 'https://auth.test/authorize', + ?string $registrationEndpoint = null, + ): void { + Http::fake([ + $resource . '/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => ['https://auth.test'], + ]), + 'https://auth.test/.well-known/oauth-authorization-server' => Http::response(array_filter([ + 'issuer' => 'https://auth.test', + 'authorization_endpoint' => $authorizationEndpoint, + 'token_endpoint' => 'https://auth.test/token', + 'registration_endpoint' => $registrationEndpoint, + ])), + ]); + } + + /** + * Assert that discovery rejects a disallowed address before sending to it. + */ + private function assertDiscoveryRejects(string $address, string $resourceUrl, ?string $resourceMetadataUrl = null): void + { + try { + (new AuthServerDiscovery)->discover($resourceUrl, $resourceMetadataUrl); + $this->fail('Expected discovery to reject the disallowed address.'); + } catch (OAuthException $exception) { + $this->assertStringContainsString("disallowed address [{$address}]", $exception->getMessage()); + } + } + + #[DataProvider('nonPublicAddresses')] + public function testRejectsHostsThatResolveToNonPublicAddresses(string $address): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => ['93.184.216.34', $address]]); + + Http::fake(); + + $this->assertDiscoveryRejects($address, 'https://mcp.test/mcp', 'https://meta.test/prm'); + + Http::assertNothingSent(); + } + + /** + * Provide addresses outside the public internet. + */ + public static function nonPublicAddresses(): array + { + return [ + 'rfc1918 10/8' => ['10.0.0.5'], + 'rfc1918 172.16/12' => ['172.16.0.1'], + 'rfc1918 192.168/16' => ['192.168.1.1'], + 'cloud metadata' => ['169.254.169.254'], + 'loopback' => ['127.0.0.1'], + 'carrier grade nat' => ['100.64.0.1'], + 'ipv4 multicast' => ['224.0.0.1'], + 'ipv6 loopback' => ['::1'], + 'ipv6 link local' => ['fe80::1'], + 'ipv6 unique local' => ['fc00::1'], + 'ipv4 mapped ipv6' => ['::ffff:10.0.0.1'], + 'ipv6 multicast' => ['ff02::1'], + 'nat64' => ['64:ff9b::a9fe:a9fe'], + 'local use nat64' => ['64:ff9b:1::a9fe:a9fe'], + '6to4' => ['2002:7f00:1::'], + 'teredo' => ['2001:0:4136:e378:8000:63bf:3fff:fdd2'], + 'benchmarking' => ['198.18.0.1'], + 'ietf protocol assignments' => ['192.0.0.1'], + 'ipv4 compatible ipv6' => ['::7f00:1'], + 'ipv4 translated ipv6' => ['::ffff:0:7f00:1'], + 'ipv6 site local' => ['fec0::1'], + ]; + } + + public function testRejectsInternalEndpointsWhenTheResourceAlsoResolvesToPublicAddresses(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34', '10.0.0.1'], 'meta.test' => ['10.0.0.5']]); + + Http::fake(); + + $this->assertDiscoveryRejects('10.0.0.5', 'https://mcp.test/mcp', 'https://meta.test/prm'); + + Http::assertNothingSent(); + } + + // REMOVED: the IPv4-mapped case. The HTTP client's destination policy rejects every IPv4-mapped + // address; resolvers only return them from nonstandard AAAA records. + #[TestWith(['64:ff9b::5db8:d822'], 'nat64')] + public function testAllowsHostsThatResolveToAWrappedPublicIpv4Address(string $address): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'auth.test' => [$address]]); + + $this->fakeAuthServer(); + + $this->assertSame('https://auth.test', (new AuthServerDiscovery)->discover('https://mcp.test/mcp')->server->issuer); + } + + public function testRejectsHostsThatDoNotResolve(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => []]); + + // DNS failures pass through unchanged rather than as OAuth policy errors. + $this->expectException(DestinationResolutionException::class); + $this->expectExceptionMessage('The host [meta.test] did not resolve to an address.'); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp', 'https://meta.test/prm'); + } + + // REMOVED: allowing private endpoints because the resource itself is private, on the same or + // another host. Private destinations always require an explicit OAuthDestinationPolicy allowance. + #[TestWith(['https://intranet.test', ['intranet.test' => ['10.0.0.5']], '10.0.0.5'], 'same host')] + #[TestWith(['https://mcp.test', ['mcp.test' => ['127.0.0.1'], 'auth.test' => ['127.0.0.1']], '127.0.0.1'], 'other host')] + public function testPrivateResourcesStillRequireAnAllowanceForPrivateEndpoints(string $resource, array $hosts, string $address): void + { + $this->resolveHosts($hosts); + + $this->fakeAuthServer($resource); + + $this->assertDiscoveryRejects($address, "{$resource}/mcp"); + } + + // REMOVED: rejecting registration endpoints during discovery. Each endpoint is checked when a request + // is sent to it; see OAuthClientTest::testRejectsPrivateDnsTargetsBeforeSendingAtEveryOAuthBoundary(). + + public function testPinsEachRequestToTheAddressesThatWereChecked(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'auth.test' => ['93.184.216.35', '2606:4700::1111']]); + + $pins = []; + + Http::fake(function (Request $request, array $options) use (&$pins): void { + $pins[$request->url()] = $options['curl'][CURLOPT_RESOLVE] ?? null; + }); + + $this->fakeAuthServer(); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp'); + + $this->assertSame([ + 'https://mcp.test/.well-known/oauth-protected-resource/mcp' => ['mcp.test:443:93.184.216.34'], + 'https://auth.test/.well-known/oauth-authorization-server' => ['auth.test:443:[2606:4700::1111],93.184.216.35'], + ], $pins); + } + + public function testLooksUpEachHostOncePerDiscovery(): void + { + $lookups = []; + + AuthServerDiscovery::resolveHostsUsing(function (string $host) use (&$lookups): array { + $lookups[$host] = ($lookups[$host] ?? 0) + 1; + + return ['93.184.216.34']; + }); + + $this->fakeAuthServer(); + + (new AuthServerDiscovery)->discover('https://mcp.test/mcp'); + + $this->assertSame(['mcp.test' => 1, 'auth.test' => 1], $lookups); + } + + public function testDoesNotResolveTheAuthorizationEndpointTheBrowserOpens(): void + { + $this->resolveHosts([ + 'mcp.test' => ['93.184.216.34'], + 'auth.test' => ['93.184.216.34'], + 'login.corp.test' => ['10.0.0.5'], + ]); + + $this->fakeAuthServer(authorizationEndpoint: 'https://login.corp.test/authorize'); + + $this->assertSame( + 'https://login.corp.test/authorize', + (new AuthServerDiscovery)->discover('https://mcp.test/mcp')->server->authorizationEndpoint, + ); + } + + // REMOVED: skipping resolution behind an HTTPS proxy, and rejecting local names unresolved there. + // DNS is resolved and validated locally behind ordinary proxies; a policy selects proxies that resolve themselves. + + public function testStillResolvesHostsWhenOnlyAnHttpProxyIsConfigured(): void + { + $this->resolveHosts(['mcp.test' => ['93.184.216.34'], 'meta.test' => ['10.0.0.5']]); + + Http::fake(); + + $this->withEnvironmentValues( + ['HTTP_PROXY' => 'http://proxy.test:3128'], + fn () => $this->assertDiscoveryRejects('10.0.0.5', 'https://mcp.test/mcp', 'https://meta.test/prm'), + ); + } +} diff --git a/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php b/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php index 59cb296ef3..52269ee1ca 100644 --- a/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php +++ b/tests/Mcp/Unit/Client/OAuth/OAuthClientTest.php @@ -255,6 +255,129 @@ public static function tokenScopes(): array ]; } + public function testRejectsAStoredTokenEndpointThatNowResolvesToAnInternalHost(): void + { + $this->app->instance(OAuthDestinationPolicy::class, new FakeDestinationPolicy(['auth.test' => ['10.0.0.5']])); + + Http::fake(); + + Session::put($this->pendingSessionKey(), [ + 'state' => 'the-state', + 'verifier' => 'the-verifier', + 'client_id' => 'client-123', + 'client_secret' => null, + 'token_endpoint' => 'https://auth.test/token', + 'redirect_uri' => 'https://app.test/callback', + 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', + ]); + + RequestContext::set(Request::create('https://app.test/callback', 'GET', [ + 'code' => 'auth-code', + 'state' => 'the-state', + ])); + + try { + Client::web('https://mcp.test/mcp') + ->withOAuth(clientId: 'client-123') + ->oAuthClient() + ->exchangeCallback(); + $this->fail('Expected the internal token endpoint to be rejected.'); + } catch (OAuthException $exception) { + $this->assertStringContainsString('disallowed address [10.0.0.5]', $exception->getMessage()); + } + + Http::assertNothingSent(); + } + + public function testChecksTheStoredTokenEndpointThroughTheConfiguredDiscovery(): void + { + Http::fake(); + + Session::put($this->pendingSessionKey(), [ + 'state' => 'the-state', + 'verifier' => 'the-verifier', + 'client_id' => 'client-123', + 'client_secret' => null, + 'token_endpoint' => 'https://auth.test/token', + 'redirect_uri' => 'https://app.test/callback', + 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', + ]); + + RequestContext::set(Request::create('https://app.test/callback', 'GET', [ + 'code' => 'auth-code', + 'state' => 'the-state', + ])); + + $rejection = new OAuthException('The custom discovery rejected the token endpoint.'); + $discovery = new class($rejection) extends AuthServerDiscovery { + /** + * @var list + */ + public array $checked = []; + + /** + * Create a discovery that rejects every endpoint. + */ + public function __construct(private OAuthException $rejection) + { + } + + /** + * Record and reject the endpoint. + */ + public function requireFetchable(string $url, string $resourceUrl): void + { + $this->checked[] = [$url, $resourceUrl]; + + throw $this->rejection; + } + }; + + $client = new OAuthClient( + new OAuthConfig(clientId: 'client-123'), + 'https://mcp.test/mcp', + discovery: $discovery, + ); + + try { + $client->exchangeCallback(); + $this->fail('Expected the custom discovery to reject the token endpoint.'); + } catch (OAuthException $exception) { + $this->assertSame($rejection, $exception); + } + + $this->assertSame([['https://auth.test/token', 'https://mcp.test/mcp']], $discovery->checked); + Http::assertNothingSent(); + } + + public function testPinsTheTokenAndRegistrationRequestsToTheCheckedAddresses(): void + { + $pins = []; + + Http::fake(function ($request, array $options) use (&$pins): void { + $pins[$request->url()] = $options['curl'][CURLOPT_RESOLVE] ?? null; + }); + + $this->fakeDiscovery(); + + Http::fake([ + 'https://auth.test/register' => Http::response(['client_id' => 'dcr-999']), + 'https://auth.test/token' => Http::response(['access_token' => 'access-token', 'token_type' => 'Bearer']), + ]); + + $oauth = Client::web('https://mcp.test/mcp') + ->withOAuth(redirectUri: 'https://app.test/callback') + ->oAuthClient(); + + $oauth->redirect(); + $oauth->refreshCredentials('refresh-token', 'dcr-999'); + + $this->assertSame(['auth.test:443:93.184.216.34'], $pins['https://auth.test/register']); + $this->assertSame(['auth.test:443:93.184.216.34'], $pins['https://auth.test/token']); + } + public function testNamedClientsForTheSameResourceKeepIndependentPendingAuthorizations(): void { $this->fakeDiscovery(); @@ -966,6 +1089,72 @@ public static function insecureAuthorizationMetadata(): array ]; } + #[TestWith(['javascript://localhost/%0aalert(1)'], 'javascript')] + #[TestWith(['file://localhost/etc/passwd'], 'file')] + #[TestWith(['data://localhost/text'], 'data')] + public function testRejectsANonHttpAuthorizationServerOnALocalhostHost(string $issuer): void + { + Http::fake([ + 'http://localhost/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => [$issuer], + ]), + ]); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('must be served over HTTPS'); + Client::web('http://localhost/mcp') + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect(); + } + + public function testRejectsANonHttpAuthorizationEndpointOnALocalhostHost(): void + { + Http::fake([ + 'http://localhost/.well-known/oauth-protected-resource/mcp' => Http::response([ + 'authorization_servers' => ['http://localhost'], + ]), + 'http://localhost/.well-known/oauth-authorization-server' => Http::response([ + 'issuer' => 'http://localhost', + 'authorization_endpoint' => 'javascript://localhost/%0aalert(1)', + 'token_endpoint' => 'http://localhost/token', + 'code_challenge_methods_supported' => ['S256'], + ]), + ]); + + $this->expectException(OAuthException::class); + $this->expectExceptionMessage('must be served over HTTPS'); + Client::web('http://localhost/mcp') + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect(); + } + + #[TestWith(['http://localhost', 'http://localhost', 'HTTP://localhost/authorize', 'http://localhost/authorize'], 'local http')] + #[TestWith(['https://mcp.test', 'https://auth.test', 'HTTPS://auth.test/authorize', 'https://auth.test/authorize'], 'remote https')] + public function testAcceptsUppercaseAuthorizationEndpointSchemes(string $resourceOrigin, string $issuer, string $authorizationEndpoint, string $expectedTarget): void + { + Http::fake([ + "{$resourceOrigin}/.well-known/oauth-protected-resource/mcp" => Http::response([ + 'authorization_servers' => [$issuer], + ]), + "{$issuer}/.well-known/oauth-authorization-server" => Http::response([ + 'issuer' => $issuer, + 'authorization_endpoint' => $authorizationEndpoint, + 'token_endpoint' => "{$issuer}/token", + 'code_challenge_methods_supported' => ['S256'], + ]), + ]); + + $target = Client::web("{$resourceOrigin}/mcp") + ->withOAuth(clientId: 'client-123', redirectUri: 'https://app.test/callback') + ->oAuthClient() + ->redirect() + ->getTargetUrl(); + + $this->assertStringStartsWith("{$expectedTarget}?", $target); + } + public function testFallsBackToOpenidConnectDiscoveryWhenOauthMetadataIsAbsent(): void { Http::fake([ @@ -1626,6 +1815,21 @@ public function testAcceptsAClientIdMetadataDocumentUrlWithAPort(): void $this->assertSame('https://app.example.com:8443/client-metadata.json', $query['client_id']); } + public function testAcceptsAClientIdMetadataDocumentUrlWithAnUppercaseScheme(): void + { + $this->fakeCimdDiscovery(); + + $target = $this->cimdClient('HTTPS://app.example.com/client-metadata.json') + ->redirect() + ->getTargetUrl(); + + parse_str((string) parse_url($target, PHP_URL_QUERY), $query); + + $this->assertSame('HTTPS://app.example.com/client-metadata.json', $query['client_id']); + + Http::assertNotSent(fn ($request): bool => $request->url() === 'https://auth.test/register'); + } + public function testNeverSendsAClientSecretAlongsideAClientIdMetadataDocument(): void { $this->fakeCimdDiscovery(); diff --git a/tests/Mcp/Unit/Methods/ReadAppResourceTest.php b/tests/Mcp/Unit/Methods/ReadAppResourceTest.php index e921315df0..dd72222088 100644 --- a/tests/Mcp/Unit/Methods/ReadAppResourceTest.php +++ b/tests/Mcp/Unit/Methods/ReadAppResourceTest.php @@ -14,6 +14,7 @@ use Hypervel\Mcp\Server\Attributes\Cacheable; use Hypervel\Mcp\Server\Contracts\HasUriTemplate; use Hypervel\Mcp\Server\Contracts\Transport; +use Hypervel\Mcp\Server\Methods\ListResources; use Hypervel\Mcp\Server\Methods\ReadResource; use Hypervel\Mcp\Server\Ui\AppMeta; use Hypervel\Mcp\Server\Ui\Csp; @@ -139,6 +140,46 @@ public function testDoesNotIncludeUiMetaOnContentItemsForRegularResources(): voi $this->assertArrayNotHasKey('_meta', $payload['contents'][0]); } + public function testExcludesUiDomainFromReadAndListWhenAppMetaIsWithoutADomain(): void + { + $resource = new class extends AppResource { + /** + * Get the application metadata. + */ + public function appMeta(): AppMeta + { + return AppMeta::make()->prefersBorder()->withoutDomain(); + } + + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text('Hello'); + } + }; + + $context = $this->getServerContext([ + 'resources' => [$resource], + ]); + + $read = (new ReadResource)->handle(new JsonRpcRequest( + id: 1, + method: 'resources/read', + params: ['uri' => $resource->uri()] + ), $context)->toArray()['result']; + + $list = (new ListResources)->handle(new JsonRpcRequest( + id: 2, + method: 'resources/list', + params: [] + ), $context)->toArray()['result']; + + $this->assertSame(['prefersBorder' => true], $read['contents'][0]['_meta']['ui']); + $this->assertSame(['prefersBorder' => true], $list['resources'][0]['_meta']['ui']); + } + #[DataProvider('responseModes')] public function testResourceMetadataAndCacheHintsUseTheSameRequestAsTheHandler(bool $lazy): void { diff --git a/tests/Mcp/Unit/Resources/AppResourceTest.php b/tests/Mcp/Unit/Resources/AppResourceTest.php index fbbfc66151..ea15d0124a 100644 --- a/tests/Mcp/Unit/Resources/AppResourceTest.php +++ b/tests/Mcp/Unit/Resources/AppResourceTest.php @@ -232,4 +232,44 @@ public function handle(Request $request): Response 'domain' => 'sandbox.example.com', ], $array['_meta']['ui']); } + + public function testDoesNotIncludeTheDomainWhenAppMetaIsWithoutADomain(): void + { + $resource = new class extends AppResource { + /** + * Get the application metadata. + */ + public function appMeta(): AppMeta + { + return AppMeta::make()->prefersBorder()->withoutDomain(); + } + + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text(''); + } + }; + + $this->assertSame(['prefersBorder' => true], $resource->resolvedAppMeta()); + $this->assertArrayNotHasKey('domain', $resource->toArray()['_meta']['ui']); + } + + public function testDoesNotIncludeTheDomainWhenTheAttributeDisablesIt(): void + { + $resource = new #[AppMetaAttribute(prefersBorder: true, domain: false)] class extends AppResource { + /** + * Handle the resource request. + */ + public function handle(Request $request): Response + { + return Response::text(''); + } + }; + + $this->assertSame(['prefersBorder' => true], $resource->resolvedAppMeta()); + $this->assertArrayNotHasKey('domain', $resource->toArray()['_meta']['ui']); + } } diff --git a/tests/Mcp/Unit/Ui/AppMetaTest.php b/tests/Mcp/Unit/Ui/AppMetaTest.php index 1685ec4860..d79cea3cae 100644 --- a/tests/Mcp/Unit/Ui/AppMetaTest.php +++ b/tests/Mcp/Unit/Ui/AppMetaTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Mcp\Unit\Ui; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Mcp\Server\Attributes\AppMeta as AppMetaAttribute; use Hypervel\Mcp\Server\Ui\AppMeta; use Hypervel\Mcp\Server\Ui\Csp; use Hypervel\Mcp\Server\Ui\Enums\Library; @@ -137,4 +138,30 @@ public function testReturnsLibrariesViaGetLibraries(): void $this->assertSame([Library::Tailwind, Library::Alpine], $meta->getLibraries()); } + + public function testUsesTheDomainByDefault(): void + { + $this->assertTrue(AppMeta::make()->usesDomain()); + } + + public function testCanBeCreatedWithoutADomain(): void + { + $meta = AppMeta::make()->domain('sandbox.example.com')->withoutDomain(); + + $this->assertFalse($meta->usesDomain()); + $this->assertArrayNotHasKey('domain', $meta->toArray()); + } + + public function testUsesAnExplicitDomainSetAfterWithoutDomain(): void + { + $meta = AppMeta::make()->withoutDomain()->domain('sandbox.example.com'); + + $this->assertTrue($meta->usesDomain()); + $this->assertSame('sandbox.example.com', $meta->toArray()['domain']); + } + + public function testMapsDomainFalseOnTheAttributeToWithoutDomain(): void + { + $this->assertFalse((new AppMetaAttribute(domain: false))->toAppMeta()->usesDomain()); + } } diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 41999d9c2e..6b9206ceef 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -19,6 +19,8 @@ use Hypervel\Queue\Connectors\FailoverConnector; use Hypervel\Queue\Connectors\NullConnector; use Hypervel\Queue\Connectors\SyncConnector; +use Hypervel\Queue\Events\JobAttempted; +use Hypervel\Queue\Jobs\FakeJob; use Hypervel\Queue\Jobs\Job; use Hypervel\Queue\NullQueue; use Hypervel\Queue\Queue as BaseQueue; @@ -179,6 +181,28 @@ public function testSetDefaultDriverAcceptsBackedEnum(): void $this->assertSame('sync', $container->make('config')->get('queue.default')); } + public function testWhenJobRunsLongerThan(): void + { + $app = new Container; + $app->instance('events', new Dispatcher($app)); + + $manager = new QueueManager($app); + + $called = false; + + $manager->whenJobRunsLongerThan(1000, function () use (&$called) { + $called = true; + }); + + $app->make('events')->dispatch(new JobAttempted('sync', new FakeJob, null, 500.0)); + + $this->assertFalse($called); + + $app->make('events')->dispatch(new JobAttempted('sync', new FakeJob, null, 1500.0)); + + $this->assertTrue($called); + } + #[DataProvider('circularConnections')] public function testRejectsCircularFailoverDefinitions(array $connections, string $cycle): void { diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php index 6db08cce9a..248cfaebae 100644 --- a/tests/Queue/WorkCommandTest.php +++ b/tests/Queue/WorkCommandTest.php @@ -43,6 +43,7 @@ public function testJobOutputIncludesVerboseDetailsOnlyWhenRequested(bool $verbo $job->shouldReceive('resolveName')->andReturn('App\Jobs\SendInvoice'); $job->shouldReceive('getConnectionName')->andReturn('redis'); $job->shouldReceive('getQueue')->andReturn('invoices'); + $job->shouldReceive('isReleased')->andReturnFalse(); $command = new WorkCommandOutputStub( $this->app, diff --git a/tests/Support/SupportHelpersTest.php b/tests/Support/SupportHelpersTest.php index c25613444d..ad91874f36 100644 --- a/tests/Support/SupportHelpersTest.php +++ b/tests/Support/SupportHelpersTest.php @@ -502,6 +502,12 @@ public function testDataGetStar(): void $data = collect(['foo' => 'bar']); $this->assertEquals(['bar'], data_get($data, '*')); + + $taylor = (object) ['name' => 'Taylor']; + $abigail = (object) ['name' => 'Abigail']; + $data = (object) ['first' => $taylor, 'second' => $abigail]; + $this->assertSame([$taylor, $abigail], data_get($data, '*')); + $this->assertSame(['Taylor', 'Abigail'], data_get($data, '*.name')); } public function testDataGetNullKey(): void @@ -651,6 +657,11 @@ public function testDataSetWithStar(): void ['foo' => [], 'bar' => ['overwritten', 'overwritten']], data_set($data, 'bar.*', 'overwritten') ); + + $data = (object) ['one' => (object) ['price' => 1], 'two' => (object) ['price' => 2]]; + data_set($data, '*.price', 0); + + $this->assertEquals((object) ['one' => (object) ['price' => 0], 'two' => (object) ['price' => 0]], $data); } public function testDataSetWithDoubleStar(): void @@ -714,6 +725,15 @@ public function testDataRemove(): void ['one' => ['two' => ['four' => ['five']]]], data_forget($data, 'one.two.three') ); + + $data = (object) ['price' => null, 'name' => 'Desk']; + data_forget($data, 'price.amount'); + + $this->assertEquals((object) ['price' => null, 'name' => 'Desk'], $data); + + data_forget($data, 'price'); + + $this->assertEquals((object) ['name' => 'Desk'], $data); } public function testDataRemoveWithStar(): void @@ -740,6 +760,14 @@ public function testDataRemoveWithStar(): void ], data_forget($data, 'article.comments.*.name') ); + + $data = (object) [ + 'one' => (object) ['price' => 1, 'name' => 'Desk'], + 'two' => (object) ['price' => null, 'name' => 'Chair'], + ]; + data_forget($data, '*.price'); + + $this->assertEquals((object) ['one' => (object) ['name' => 'Desk'], 'two' => (object) ['name' => 'Chair']], $data); } public function testDataRemoveWithDoubleStar(): void diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 2da3f1e012..721f6398cc 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1326,6 +1326,12 @@ public function testRemove(): void $this->assertSame('ltý', Str::remove('ž', 'Žltý', false)); $this->assertSame('žltý ', Str::remove('KÔŇ', 'žltý kôň', false)); + + $this->assertSame(['Fbar', 'Bar'], Str::remove('o', ['Foobar', 'Boar'])); + $this->assertSame(['Fbar', 'Bar'], Str::remove('o', collect(['Foobar', 'Boar']))); + $this->assertSame(['Fbr', 'Br'], Str::remove(collect(['o', 'a']), collect(['Foobar', 'Boar']))); + $this->assertSame(['Fooar', 'oar'], Str::remove('b', collect(['Foobar', 'Boar']), false)); + $this->assertSame(['ltý', 'ito'], Str::remove('ž', collect(['Žltý', 'žito']), false)); } public function testReverse(): void @@ -1659,6 +1665,15 @@ public function testSubstrCount(): void $this->assertSame(0, Str::substrCount('hypervelPHPFramework', 'a', 1, 2)); $this->assertSame(1, Str::substrCount('hypervelPHPFramework', 'a', 1, -2)); $this->assertSame(1, Str::substrCount('hypervelPHPFramework', 'a', -10, -3)); + + $this->assertSame(3, Str::substrCount('سلام سلام سلام', 'سلام')); + $this->assertSame(2, Str::substrCount('سلام سلام سلام', 'سلام', 5)); + $this->assertSame(3, Str::substrCount('سلام سلام سلام', 'سلام', 0, 14)); + $this->assertSame(1, Str::substrCount('سلام سلام سلام', 'سلام', 5, 4)); + $this->assertSame(1, Str::substrCount('سلام سلام سلام', 'سلام', -4)); + $this->assertSame(2, Str::substrCount('ab🙂cd🙂ef', '🙂', 0, 6)); + $this->assertSame(1, Str::substrCount('ab🙂cd🙂ef', '🙂', 3)); + $this->assertSame(2, Str::substrCount('ab🙂cd🙂ef', '🙂', 1, -1)); } public function testPosition(): void diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 64f17dd4c0..6c7bec992f 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -2612,6 +2612,22 @@ public function testAssertJsonIsNotArray(): void $responseObject->assertJsonIsArray(); } + #[TestWith(['{}', null])] + #[TestWith(['{"0":"value"}', null])] + #[TestWith(['{"data":{}}', 'data'])] + #[TestWith(['{"data":{"0":"value"}}', 'data'])] + public function testAssertJsonIsNotArrayForObjectsThatDecodeToLists(string $json, ?string $key): void + { + $this->expectException(ExpectationFailedException::class); + + TestResponse::fromBaseResponse(new Response($json))->assertJsonIsArray($key); + } + + public function testAssertJsonIsArrayForWildcardSelectionOfAnObject(): void + { + TestResponse::fromBaseResponse(new Response('{"data":{"first":[]}}'))->assertJsonIsArray('data.*'); + } + public function testAssertJsonIsObject(): void { $responseObject = TestResponse::fromBaseResponse(new Response([ @@ -2620,6 +2636,15 @@ public function testAssertJsonIsObject(): void $responseObject->assertJsonIsObject(); } + #[TestWith(['{}', null])] + #[TestWith(['{"0":"value"}', null])] + #[TestWith(['{"data":{}}', 'data'])] + #[TestWith(['{"data":{"0":"value"}}', 'data'])] + public function testAssertJsonIsObjectForObjectsThatDecodeToLists(string $json, ?string $key): void + { + TestResponse::fromBaseResponse(new Response($json))->assertJsonIsObject($key); + } + public function testAssertJsonIsNotObject(): void { $this->expectException(ExpectationFailedException::class); diff --git a/tests/Validation/ValidationForEachTest.php b/tests/Validation/ValidationForEachTest.php index e7b2e3e4c2..cfb67270f3 100644 --- a/tests/Validation/ValidationForEachTest.php +++ b/tests/Validation/ValidationForEachTest.php @@ -371,6 +371,23 @@ public function testForEachRulesAreAppliedToEmptyKeys(): void ], $v->getMessageBag()->toArray()); } + public function testForEachLeavesObjectsInTheInputUnchanged(): void + { + $listed = (object) ['price' => 2]; + $keyed = (object) ['one' => (object) ['price' => 3]]; + + $v = new Validator($this->getArrayTranslator(), [ + 'items' => [ + ['options' => [['price' => 1], $listed]], + ['options' => $keyed], + ], + ], ['items.*.options.*.price' => Rule::forEach(fn (): array => ['integer'])]); + + $this->assertSame(['items.0.options.0.price', 'items.0.options.1.price'], array_keys($v->getRules())); + $this->assertSame(2, $listed->price); + $this->assertSame(3, $keyed->one->price); + } + public function getArrayTranslator() { return new Translator( diff --git a/tests/Validation/ValidationImageFileRuleTest.php b/tests/Validation/ValidationImageFileRuleTest.php index 71c8498214..84600fa950 100644 --- a/tests/Validation/ValidationImageFileRuleTest.php +++ b/tests/Validation/ValidationImageFileRuleTest.php @@ -11,6 +11,7 @@ use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; +use Hypervel\Validation\Rules\ImageFile; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -126,6 +127,22 @@ public function testDimensionWithTheRatioBetweenMethod(): void ); } + public function testDefaultReturnsImageFileInstance(): void + { + $this->assertInstanceOf(ImageFile::class, ImageFile::default()); + + $this->fails( + ImageFile::default(), + UploadedFile::fake()->createWithContent('foo.txt', 'Hello World!'), + ['validation.image'], + ); + + $this->passes( + ImageFile::default(), + UploadedFile::fake()->image('foo.png'), + ); + } + protected function fails($rule, $values, $messages) { $this->assertValidationRules($rule, $values, false, $messages); diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 914fe165c9..ba8a4e1552 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; @@ -113,3 +115,9 @@ }); assertType('Hypervel\Pagination\CursorPaginator', $throughPaginator); + +/** @var PaginatorContract $paginatorContract */ +assertType('Hypervel\Contracts\Support\Htmlable', $paginatorContract->render()); + +/** @var CursorPaginatorContract $cursorPaginatorContract */ +assertType('Hypervel\Contracts\Support\Htmlable', $cursorPaginatorContract->render());