diff --git a/src/Data/Collection.php b/src/Data/Collection.php index a3a2200d..46c7523b 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -4,22 +4,35 @@ namespace Phenix\Data; +use Closure; use Phenix\Contracts\Arrayable; use Ramsey\Collection\Collection as GenericCollection; -use SplFixedArray; +use Ramsey\Collection\CollectionInterface; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; +use function array_filter; use function array_key_first; +use function array_map; +use function array_merge; +use function array_udiff; +use function array_uintersect; +use function is_int; +use function is_object; +use function spl_object_id; +use function sprintf; +use function usort; +/** + * @template T + * @extends GenericCollection + */ class Collection extends GenericCollection implements Arrayable { public static function fromArray(array $data): self { - $data = SplFixedArray::fromArray($data); - $collection = new self('array'); - - foreach ($data as $value) { - $collection->add($value); - } + $collection = new self(self::getDataType($data)); + $collection->data = $data; return $collection; } @@ -34,4 +47,207 @@ public function first(): mixed return $this->data[$firstIndex]; } + + /** + * @param callable(T): bool $callback + * + * @return self + */ + public function filter(callable $callback): self + { + $collection = clone $this; + $collection->data = array_merge([], array_filter($collection->data, $callback)); + + return $collection; + } + + /** + * @param callable(T): TCallbackReturn $callback + * + * @return self + * + * @template TCallbackReturn + */ + public function map(callable $callback): self + { + return new self('mixed', array_map($callback, $this->data)); + } + + /** + * @param string|null $propertyOrMethod + * @param mixed $value + * + * @return self + */ + public function where(string|null $propertyOrMethod, mixed $value): self + { + return $this->filter( + fn (mixed $item): bool => $this->extractValue($item, $propertyOrMethod) === $value, + ); + } + + /** + * @param string|null $propertyOrMethod + * @param Sort $order + * + * @return self + */ + public function sort(string|null $propertyOrMethod = null, Sort $order = Sort::Ascending): self + { + $collection = clone $this; + + usort( + $collection->data, + function (mixed $a, mixed $b) use ($propertyOrMethod, $order): int { + $aValue = $this->extractValue($a, $propertyOrMethod); + $bValue = $this->extractValue($b, $propertyOrMethod); + + return ($aValue <=> $bValue) * ($order === Sort::Descending ? -1 : 1); + }, + ); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function diff(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $diffAtoB = array_udiff($this->data, $other->toArray(), $this->getComparator()); + $diffBtoA = array_udiff($other->toArray(), $this->data, $this->getComparator()); + + $collection = clone $this; + $collection->data = array_merge($diffAtoB, $diffBtoA); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function intersect(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $collection = clone $this; + $collection->data = array_uintersect($this->data, $other->toArray(), $this->getComparator()); + + return $collection; + } + + /** + * @param CollectionInterface ...$collections + * + * @return self + */ + public function merge(CollectionInterface ...$collections): self + { + $mergedCollection = clone $this; + + foreach ($collections as $index => $collection) { + if (! $collection instanceof static) { + throw new CollectionMismatchException( + sprintf('Collection with index %d must be of type %s', $index, static::class), + ); + } + + if ($this->getUniformType($collection) !== $this->getUniformType($this)) { + throw new CollectionMismatchException( + sprintf( + 'Collection items in collection with index %d must be of type %s', + $index, + $this->getType(), + ), + ); + } + + foreach ($collection as $key => $value) { + if (is_int($key)) { + $mergedCollection[] = $value; + } else { + $mergedCollection[$key] = $value; + } + } + } + + return $mergedCollection; + } + + /** + * @param CollectionInterface $other + * + * @throws CollectionMismatchException + */ + private function compareCollectionTypes(CollectionInterface $other): void + { + if (! $other instanceof static) { + throw new CollectionMismatchException('Collection must be of type ' . static::class); + } + + if ($this->getUniformType($other) !== $this->getUniformType($this)) { + throw new CollectionMismatchException('Collection items must be of type ' . $this->getType()); + } + } + + private function getComparator(): Closure + { + return function (mixed $a, mixed $b): int { + if (is_object($a) && is_object($b)) { + $a = spl_object_id($a); + $b = spl_object_id($b); + } + + if ($a === $b) { + return 0; + } + + if ($a < $b) { + return 1; + } + + return -1; + }; + } + + /** + * @param CollectionInterface $collection + */ + private function getUniformType(CollectionInterface $collection): string + { + return match ($collection->getType()) { + 'integer' => 'int', + 'boolean' => 'bool', + 'double' => 'float', + default => $collection->getType(), + }; + } + + /** + * @param array $data + * + * @return string + */ + private static function getDataType(array $data): string + { + if (empty($data)) { + return 'mixed'; + } + + $firstType = gettype(reset($data)); + + foreach ($data as $item) { + if (gettype($item) !== $firstType) { + return 'mixed'; + } + } + + return $firstType; + } } diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cb4df29a..cd534151 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -139,7 +139,7 @@ public function with(array|string $relationships): self } /** - * @return Collection + * @return Collection */ public function get(): Collection { @@ -215,7 +215,7 @@ protected function resolveRelationships(Collection $collection): void } /** - * @param Collection $models + * @param Collection $models * @param BelongsTo $relationship * @param Closure $closure */ @@ -226,7 +226,7 @@ protected function resolveBelongsToRelationship( ): void { $closure($relationship); - /** @var Collection $records */ + /** @var Collection $records */ $records = $relationship->query() ->whereIn($relationship->getForeignKey()->getColumnName(), $models->modelKeys()) ->get(); @@ -243,7 +243,7 @@ protected function resolveBelongsToRelationship( } /** - * @param Collection $models + * @param Collection $models * @param HasMany $relationship * @param Closure $closure */ @@ -254,7 +254,7 @@ protected function resolveHasManyRelationship( ): void { $closure($relationship); - /** @var Collection $children */ + /** @var Collection $children */ $children = $relationship->query() ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) ->get(); @@ -284,7 +284,7 @@ protected function resolveHasManyRelationship( } /** - * @param Collection $models + * @param Collection $models * @param BelongsToMany $relationship * @param Closure $closure */ @@ -297,7 +297,7 @@ protected function resolveBelongsToManyRelationship( $attr = $relationship->getProperty()->getAttribute(); - /** @var Collection $related */ + /** @var Collection $related */ $related = $relationship->query() ->addSelect($relationship->getColumns()) ->innerJoin($attr->table, function (Join $join) use ($attr): void { diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index e03408e5..54fdc4ae 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -68,7 +68,7 @@ public function connection(SqlCommonConnectionPool|string $connection): self } /** - * @return Collection + * @return Collection> */ public function get(): Collection { diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php new file mode 100644 index 00000000..b3549db5 --- /dev/null +++ b/src/Events/Concerns/CaptureEvents.php @@ -0,0 +1,212 @@ + + */ + protected array $fakeEvents = []; + + /** + * @var array + */ + protected array $fakeExceptEvents = []; + + /** + * @var Collection + */ + protected Collection $dispatched; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->enableLog(); + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::ALL); + } + + public function fakeWhen(string $event, Closure $callback): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = $callback; + } + + public function fakeTimes(string $event, int $times): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = $times; + } + + public function fakeOnce(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = 1; + } + + public function fakeOnly(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents = [ + $event => null, + ]; + } + + public function fakeExcept(string $event): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::EXCEPT); + + $this->fakeExceptEvents[] = $event; + } + + public function getEventLog(): Collection + { + if (! isset($this->dispatched)) { + $this->dispatched = Collection::fromArray([]); + } + + return $this->dispatched; + } + + public function resetEventLog(): void + { + $this->dispatched = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeEvents = []; + $this->fakeExceptEvents = []; + $this->dispatched = Collection::fromArray([]); + } + + protected function recordDispatched(EventContract $event): void + { + if (! $this->logging) { + return; + } + + $this->dispatched->add([ + 'name' => $event->getName(), + 'event' => $event, + 'timestamp' => Date::now(), + ]); + } + + protected function shouldFakeEvent(string $name): bool + { + if ($this->fakeMode === FakeMode::ALL) { + return true; + } + + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($name, $this->fakeExceptEvents, true); + } + + $result = false; + + if (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { + $config = $this->fakeEvents[$name]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->dispatched); + } catch (Throwable $e) { + report($e); + + $result = false; + } + } else { + $result = $config === null || $config > 0; + } + } + + return $result; + } + + protected function consumeFakedEvent(string $name): void + { + if (! isset($this->fakeEvents[$name])) { + return; + } + + $remaining = $this->fakeEvents[$name]; + + if (! $remaining || $remaining instanceof Closure) { + return; + } + + $remaining--; + + if ($remaining <= 0) { + unset($this->fakeEvents[$name]); + } else { + $this->fakeEvents[$name] = $remaining; + } + } + + protected function enableLog(): void + { + if (! $this->logging) { + $this->logging = true; + $this->dispatched = Collection::fromArray([]); + } + } + + protected function enableFake(FakeMode $fakeMode): void + { + $this->enableLog(); + $this->fakeMode = $fakeMode; + } +} diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 0b383f43..0077dd25 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -6,6 +6,7 @@ use Amp\Future; use Closure; +use Phenix\Events\Concerns\CaptureEvents; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; use Phenix\Events\Contracts\EventListener as EventListenerContract; @@ -17,6 +18,8 @@ class EventEmitter implements EventEmitterContract { + use CaptureEvents; + /** * @var array> */ @@ -27,14 +30,8 @@ class EventEmitter implements EventEmitterContract */ protected array $listenerCounts = []; - /** - * Maximum number of listeners per event. - */ protected int $maxListeners = 10; - /** - * Whether to emit warnings for too many listeners. - */ protected bool $emitWarnings = true; public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void @@ -88,6 +85,15 @@ public function off(string $event, Closure|EventListenerContract|string|null $li public function emit(string|EventContract $event, mixed $payload = null): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); + + return []; + } + $results = []; $listeners = $this->getListeners($eventObject->getName()); @@ -105,7 +111,6 @@ public function emit(string|EventContract $event, mixed $payload = null): array $result = $listener->handle($eventObject); $results[] = $result; - // Remove one-time listeners after execution if ($listener->isOnce()) { $this->removeListener($eventObject->getName(), $listener); } @@ -134,6 +139,15 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F { return async(function () use ($event, $payload): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); + + return []; + } + $listeners = $this->getListeners($eventObject->getName()); $futures = []; @@ -164,43 +178,6 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F }); } - protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future - { - return async(function () use ($listener, $eventObject): mixed { - try { - if ($eventObject->isPropagationStopped()) { - return null; - } - - $result = $listener->handle($eventObject); - - // Remove one-time listeners after execution - if ($listener->isOnce()) { - $this->removeListener($eventObject->getName(), $listener); - } - - return $result; - } catch (Throwable $e) { - Log::error('Async event listener error', [ - 'event' => $eventObject->getName(), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); - - if ($this->emitWarnings) { - throw new EventException( - "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", - 0, - $e - ); - } - - return null; - } - }); - } - /** * @return array */ @@ -245,6 +222,37 @@ public function getEventNames(): array return array_keys($this->listeners); } + protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future + { + return async(function () use ($listener, $eventObject): mixed { + try { + if ($eventObject->isPropagationStopped()) { + return null; + } + + $result = $listener->handle($eventObject); + + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + + return $result; + } catch (Throwable $e) { + report($e); + + if ($this->emitWarnings) { + throw new EventException( + "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + + return null; + } + }); + } + protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListenerContract { if ($listener instanceof EventListenerContract) { diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 66f4954e..a4cde3af 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -6,9 +6,12 @@ use Amp\Future; use Closure; +use Phenix\App; +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventListener; use Phenix\Runtime\Facade; +use Phenix\Testing\TestEvent; /** * @method static void on(string $event, Closure|EventListener|string $listener, int $priority = 0) @@ -24,6 +27,16 @@ * @method static void setEmitWarnings(bool $emitWarnings) * @method static int getListenerCount(string $event) * @method static array getEventNames() + * @method static void log() + * @method static void fake() + * @method static void fakeWhen(string $event, Closure $callback) + * @method static void fakeTimes(string $event, int $times) + * @method static void fakeOnce(string $event) + * @method static void fakeOnly(string $event) + * @method static void fakeExcept(string $event) + * @method static Collection getEventLog() + * @method static void resetEventLog() + * @method static void resetFaking() * * @see \Phenix\Events\EventEmitter */ @@ -33,4 +46,12 @@ public static function getKeyName(): string { return \Phenix\Events\EventEmitter::class; } + + public static function expect(string $event): TestEvent + { + /** @var \Phenix\Events\EventEmitter $emitter */ + $emitter = App::make(self::getKeyName()); + + return new TestEvent($event, $emitter->getEventLog()); + } } diff --git a/src/Facades/Mail.php b/src/Facades/Mail.php index 208c6b3d..48c48fb5 100644 --- a/src/Facades/Mail.php +++ b/src/Facades/Mail.php @@ -5,6 +5,7 @@ namespace Phenix\Facades; use Phenix\Mail\Constants\MailerType; +use Phenix\Mail\Contracts\Mailable as MailableContract; use Phenix\Mail\MailManager; use Phenix\Runtime\Facade; use Phenix\Testing\TestMail; @@ -14,7 +15,8 @@ * @method static \Phenix\Mail\Contracts\Mailer using(MailerType $mailerType) * @method static \Phenix\Mail\Contracts\Mailer to(array|string $to) * @method static void send(\Phenix\Mail\Contracts\Mailable $mailable) - * @method static \Phenix\Mail\Contracts\Mailer log(\Phenix\Mail\Constants\MailerType|null $mailerType = null) + * @method static \Phenix\Mail\Contracts\Mailer fake(\Phenix\Mail\Constants\MailerType|null $mailerType = null) + * @method static TestMail expect(MailableContract|string $mailable, MailerType|null $mailerType = null) * * @see \Phenix\Mail\MailManager */ @@ -25,11 +27,12 @@ public static function getKeyName(): string return MailManager::class; } - public static function expect(MailerType|null $mailerType = null): TestMail + public static function expect(MailableContract|string $mailable, MailerType|null $mailerType = null): TestMail { $mailerType ??= MailerType::from(Config::get('mail.default')); return new TestMail( + $mailable, self::mailer($mailerType)->getSendingLog() ); } diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index 2a7c2aec..f3899f8d 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -4,11 +4,15 @@ namespace Phenix\Facades; +use Closure; +use Phenix\App; +use Phenix\Data\Collection; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue as QueueContract; use Phenix\Queue\QueueManager; use Phenix\Runtime\Facade; use Phenix\Tasks\QueuableTask; +use Phenix\Testing\TestQueue; /** * @method static void push(QueuableTask $task) @@ -20,6 +24,16 @@ * @method static string getConnectionName() * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) + * @method static void log() + * @method static void fake() + * @method static void fakeWhen(string $taskClass, Closure $callback) + * @method static void fakeTimes(string $taskClass, int $times) + * @method static void fakeOnce(string $taskClass) + * @method static void fakeOnly(string $taskClass) + * @method static void fakeExcept(string $taskClass) + * @method static Collection getQueueLog() + * @method static void resetQueueLog() + * @method static void resetFaking() * * @see \Phenix\Queue\QueueManager */ @@ -29,4 +43,12 @@ protected static function getKeyName(): string { return QueueManager::class; } + + public static function expect(string $taskClass): TestQueue + { + /** @var QueueManager $manager */ + $manager = App::make(self::getKeyName()); + + return new TestQueue($taskClass, $manager->getQueueLog()); + } } diff --git a/src/Mail/MailManager.php b/src/Mail/MailManager.php index 1311c4f1..4c7c93f9 100644 --- a/src/Mail/MailManager.php +++ b/src/Mail/MailManager.php @@ -49,7 +49,7 @@ public function send(Mailable $mailable): void $this->mailer()->send($mailable); } - public function log(MailerType|null $mailerType = null): void + public function fake(MailerType|null $mailerType = null): void { $mailerType ??= MailerType::from($this->config->default()); diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php new file mode 100644 index 00000000..e84abb5d --- /dev/null +++ b/src/Queue/Concerns/CaptureTasks.php @@ -0,0 +1,217 @@ + + */ + protected array $fakeTasks = []; + + /** + * @var array + */ + protected array $fakeExceptTasks = []; + + /** + * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: Date}> + */ + protected Collection $pushed; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->enableLog(); + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::ALL); + } + + public function fakeWhen(string $taskClass, Closure $callback): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = $callback; + } + + public function fakeTimes(string $taskClass, int $times): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = $times; + } + + public function fakeOnce(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = 1; + } + + public function fakeOnly(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks = [ + $taskClass => null, + ]; + } + + public function fakeExcept(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::EXCEPT); + + $this->fakeExceptTasks[] = $taskClass; + $this->fakeTasks = []; + } + + public function getQueueLog(): Collection + { + if (! isset($this->pushed)) { + $this->pushed = Collection::fromArray([]); + } + + return $this->pushed; + } + + public function resetQueueLog(): void + { + $this->pushed = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeTasks = []; + $this->fakeExceptTasks = []; + $this->pushed = Collection::fromArray([]); + } + + protected function recordPush(QueuableTask $task): void + { + if (! $this->logging) { + return; + } + + $this->pushed->add([ + 'task_class' => $task::class, + 'task' => $task, + 'queue' => $task->getQueueName(), + 'connection' => $task->getConnectionName(), + 'timestamp' => Date::now(), + ]); + } + + protected function shouldFakeTask(QueuableTask $task): bool + { + if ($this->fakeMode === FakeMode::ALL) { + return true; + } + + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($task::class, $this->fakeExceptTasks, true); + } + + $result = false; + + if (! empty($this->fakeTasks) && array_key_exists($task::class, $this->fakeTasks)) { + $config = $this->fakeTasks[$task::class]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->pushed); + } catch (Throwable $e) { + report($e); + + $result = false; + } + } else { + $result = $config === null || $config > 0; + } + } + + return $result; + } + + protected function consumeFakedTask(QueuableTask $task): void + { + $class = $task::class; + + if (! array_key_exists($class, $this->fakeTasks)) { + return; + } + + $remaining = $this->fakeTasks[$class]; + + if (! $remaining || $remaining instanceof Closure) { + return; + } + + $remaining--; + + if ($remaining <= 0) { + unset($this->fakeTasks[$class]); + } else { + $this->fakeTasks[$class] = $remaining; + } + } + + protected function enableLog(): void + { + if (! $this->logging) { + $this->logging = true; + $this->pushed = Collection::fromArray([]); + } + } + + protected function enableFake(FakeMode $fakeMode): void + { + $this->enableLog(); + $this->fakeMode = $fakeMode; + } +} diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index c4ad693c..ae6a316a 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -6,6 +6,7 @@ use Phenix\App; use Phenix\Database\Constants\Driver as DatabaseDriver; +use Phenix\Queue\Concerns\CaptureTasks; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue; use Phenix\Redis\Contracts\Client; @@ -13,6 +14,8 @@ class QueueManager { + use CaptureTasks; + protected array $drivers = []; protected Config $config; @@ -24,11 +27,28 @@ public function __construct(Config|null $config = null) public function push(QueuableTask $task): void { + $this->recordPush($task); + + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + + return; + } + $this->driver()->push($task); } public function pushOn(string $queueName, QueuableTask $task): void { + $task->setQueueName($queueName); + $this->recordPush($task); + + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + + return; + } + $this->driver()->pushOn($queueName, $task); } diff --git a/src/Testing/Concerns/InteractWithDatabase.php b/src/Testing/Concerns/InteractWithDatabase.php new file mode 100644 index 00000000..cba900e8 --- /dev/null +++ b/src/Testing/Concerns/InteractWithDatabase.php @@ -0,0 +1,72 @@ + $criteria + */ + public function assertDatabaseHas(string $table, Closure|array $criteria): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertGreaterThan(0, $count, 'Failed asserting that table has matching record.'); + } + + /** + * @param Closure|array $criteria + */ + public function assertDatabaseMissing(string $table, Closure|array $criteria): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertSame(0, $count, 'Failed asserting that table is missing the provided record.'); + } + + /** + * @param Closure|array $criteria + */ + public function assertDatabaseCount(string $table, int $expected, Closure|array $criteria = []): void + { + $count = $this->getRecordCount($table, $criteria); + + Assert::assertSame($expected, $count, 'Failed asserting the expected database record count.'); + } + + /** + * @param Closure|array $criteria + */ + protected function getRecordCount(string $table, Closure|array $criteria): int + { + $query = DB::from($table); + + if ($criteria instanceof Closure) { + $criteria($query); + + return $query->count(); + } + + foreach ($criteria as $column => $value) { + if ($value === null) { + $query->whereNull($column); + + continue; + } + + if (is_bool($value)) { + $value = (int) $value; // normalize boolean to int representation + } + + $query->whereEqual($column, is_int($value) ? $value : (string) $value); + } + + return $query->count(); + } +} diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php new file mode 100644 index 00000000..1cc9674a --- /dev/null +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -0,0 +1,168 @@ +runMigrations(); + + static::$migrated = true; + } + + $this->truncateDatabase(); + } + + protected function runMigrations(): void + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + + $config = new MigrationConfig([ + 'paths' => [ + 'migrations' => Config::get('database.paths.migrations'), + 'seeds' => Config::get('database.paths.seeds'), + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => $driver->value, + 'host' => $settings['host'] ?? null, + 'name' => $settings['database'] ?? null, + 'user' => $settings['username'] ?? null, + 'pass' => $settings['password'] ?? null, + 'port' => $settings['port'] ?? null, + ], + ], + ]); + + $manager = new Manager($config, new ArrayInput([]), new NullOutput()); + + try { + $manager->migrate('default'); + } catch (Throwable $e) { + report($e); + } + } + + protected function truncateDatabase(): void + { + /** @var SqlCommonConnectionPool $connection */ + $connection = App::make(Connection::default()); + + $driver = $this->resolveDriver(); + + try { + $tables = $this->getDatabaseTables($connection, $driver); + } catch (Throwable) { + return; + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + $this->truncateTables($connection, $driver, $tables); + } + + protected function resolveDriver(): Driver + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + return Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + } + + /** + * @return array + */ + protected function getDatabaseTables(SqlCommonConnectionPool $connection, Driver $driver): array + { + $tables = []; + + if ($driver === Driver::MYSQL) { + $result = $connection->prepare('SHOW TABLES')->execute(); + + foreach ($result as $row) { + $table = array_values($row)[0] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } elseif ($driver === Driver::POSTGRESQL) { + $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); + + foreach ($result as $row) { + $table = $row['tablename'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + } else { + // Unsupported driver (sqlite, etc.) – return empty so caller exits gracefully. + return []; + } + + return $tables; + } + + /** + * @param array $tables + * @return array + */ + protected function filterTruncatableTables(array $tables): array + { + return array_values(array_filter( + $tables, + static fn (string $t): bool => $t !== 'migrations' + )); + } + + /** + * @param array $tables + */ + protected function truncateTables(SqlCommonConnectionPool $connection, Driver $driver, array $tables): void + { + try { + if ($driver === Driver::MYSQL) { + $connection->prepare('SET FOREIGN_KEY_CHECKS=0')->execute(); + + foreach ($tables as $table) { + $connection->prepare('TRUNCATE TABLE `'.$table.'`')->execute(); + } + + $connection->prepare('SET FOREIGN_KEY_CHECKS=1')->execute(); + } elseif ($driver === Driver::POSTGRESQL) { + $quoted = array_map(static fn (string $t): string => '"' . str_replace('"', '""', $t) . '"', $tables); + + $connection->prepare('TRUNCATE TABLE '.implode(', ', $quoted).' RESTART IDENTITY CASCADE')->execute(); + } + } catch (Throwable $e) { + report($e); + } + } +} diff --git a/src/Testing/Constants/FakeMode.php b/src/Testing/Constants/FakeMode.php new file mode 100644 index 00000000..87151da2 --- /dev/null +++ b/src/Testing/Constants/FakeMode.php @@ -0,0 +1,16 @@ +app = AppBuilder::build($this->getAppDir(), $this->getEnvFile()); $this->app->enableTestingMode(); } + + $uses = class_uses_recursive($this); + + if (in_array(RefreshDatabase::class, $uses, true) && method_exists($this, 'refreshDatabase')) { + $this->refreshDatabase(); + } } protected function tearDown(): void { parent::tearDown(); + Event::resetFaking(); + Queue::resetFaking(); + $this->app = null; } diff --git a/src/Testing/TestEvent.php b/src/Testing/TestEvent.php new file mode 100644 index 00000000..18490826 --- /dev/null +++ b/src/Testing/TestEvent.php @@ -0,0 +1,57 @@ +filterByName($this->event); + + if ($closure) { + Assert::assertTrue($closure($matches->first()['event'] ?? null)); + } else { + Assert::assertNotEmpty($matches, "Failed asserting that event '{$this->event}' was dispatched at least once."); + } + } + + public function toNotBeDispatched(Closure|null $closure = null): void + { + $matches = $this->filterByName($this->event); + + if ($closure) { + Assert::assertFalse($closure($matches->first()['event'] ?? null)); + } else { + Assert::assertEmpty($matches, "Failed asserting that event '{$this->event}' was NOT dispatched."); + } + } + + public function toBeDispatchedTimes(int $times): void + { + $matches = $this->filterByName($this->event); + + Assert::assertCount($times, $matches, "Failed asserting that event '{$this->event}' was dispatched {$times} times. Actual: {$matches->count()}."); + } + + public function toDispatchNothing(): void + { + Assert::assertEmpty($this->log, "Failed asserting that no events were dispatched."); + } + + private function filterByName(string $event): Collection + { + return $this->log->filter(fn (array $record) => $record['name'] === $event); + } +} diff --git a/src/Testing/TestMail.php b/src/Testing/TestMail.php index 3c4a4299..d8cf98f5 100644 --- a/src/Testing/TestMail.php +++ b/src/Testing/TestMail.php @@ -7,60 +7,69 @@ use Closure; use Phenix\Data\Collection; use Phenix\Mail\Contracts\Mailable; +use PHPUnit\Framework\Assert; class TestMail { public readonly Collection $log; + protected string $mailable; - public function __construct(array $log = []) - { - $this->log = Collection::fromArray($log); - } - - public function toBeSent(Mailable|string $mailable, Closure|null $closure = null): void + /** + * @param array $log + */ + public function __construct(Mailable|string $mailable, array $log = []) { if ($mailable instanceof Mailable) { $mailable = $mailable::class; } - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $this->mailable = $mailable; + $this->log = Collection::fromArray($log); + } + + public function toBeSent(Closure|null $closure = null): void + { + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeTrue(); + Assert::assertTrue($closure($matches->first())); } else { - expect($matches)->not->toBeEmpty(); + Assert::assertNotEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was sent at least once."); } } - public function toNotBeSent(Mailable|string $mailable, Closure|null $closure = null): void + public function toNotBeSent(Closure|null $closure = null): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } - - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeFalse(); + Assert::assertFalse($closure($matches->first())); } else { - expect($matches)->toBeEmpty(); + $matches = $matches->filter(fn (array $item): bool => $item['success'] === false); + + Assert::assertEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was NOT sent."); } } - public function toBeSentTimes(Mailable|string $mailable, int $times): void + public function toBeSentTimes(int $times): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } + $matches = $this->filterByMailable($this->mailable); + + $count = $matches->count(); - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + Assert::assertCount($times, $matches, "Failed asserting that mailable '{$this->mailable}' was sent {$times} times. Actual: {$count}."); + } + + private function filterByMailable(string $mailable): Collection + { + $filtered = []; + + foreach ($this->log as $record) { + if (($record['mailable'] ?? null) === $mailable) { + $filtered[] = $record; + } + } - expect($matches)->toHaveCount($times); + return Collection::fromArray($filtered); } } diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php new file mode 100644 index 00000000..d8b93bf8 --- /dev/null +++ b/src/Testing/TestQueue.php @@ -0,0 +1,72 @@ + $taskClass + * @param Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log + */ + public function __construct( + protected string $taskClass, + public readonly Collection $log + ) { + } + + public function toBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertTrue($closure($task), "Failed asserting that task '{$this->taskClass}' was pushed with given conditions."); + } else { + Assert::assertNotEmpty($matches, "Failed asserting that task '{$this->taskClass}' was pushed at least once."); + } + } + + public function toNotBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertFalse($closure($task), "Failed asserting that task '{$this->taskClass}' was NOT pushed with given conditions."); + } else { + Assert::assertEmpty($matches, "Failed asserting that task '{$this->taskClass}' was NOT pushed."); + } + } + + public function toBePushedTimes(int $times): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + Assert::assertCount( + $times, + $matches, + "Failed asserting that task '{$this->taskClass}' was pushed {$times} times. Actual: {$matches->count()}." + ); + } + + public function toPushNothing(): void + { + Assert::assertEmpty($this->log, 'Failed asserting that no tasks were pushed.'); + } + + private function filterByTaskClass(string $taskClass): Collection + { + return $this->log->filter(fn (array $record) => $record['task_class'] === $taskClass); + } +} diff --git a/src/Testing/TestResponse.php b/src/Testing/TestResponse.php index b8d971c5..f3ad85c2 100644 --- a/src/Testing/TestResponse.php +++ b/src/Testing/TestResponse.php @@ -6,6 +6,7 @@ use Amp\Http\Client\Response; use Phenix\Http\Constants\HttpStatus; +use PHPUnit\Framework\Assert; class TestResponse { @@ -33,28 +34,28 @@ public function getHeader(string $name): string|null public function assertOk(): self { - expect($this->response->getStatus())->toBe(HttpStatus::OK->value); + Assert::assertEquals(HttpStatus::OK->value, $this->response->getStatus()); return $this; } public function assertNotFound(): self { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_FOUND->value); + Assert::assertEquals(HttpStatus::NOT_FOUND->value, $this->response->getStatus()); return $this; } public function assertNotAcceptable(): self { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_ACCEPTABLE->value); + Assert::assertEquals(HttpStatus::NOT_ACCEPTABLE->value, $this->response->getStatus()); return $this; } public function assertUnprocessableEntity(): self { - expect($this->response->getStatus())->toBe(HttpStatus::UNPROCESSABLE_ENTITY->value); + Assert::assertEquals(HttpStatus::UNPROCESSABLE_ENTITY->value, $this->response->getStatus()); return $this; } @@ -67,7 +68,9 @@ public function assertBodyContains(array|string $needles): self { $needles = (array) $needles; - expect($this->body)->toContain(...$needles); + foreach ($needles as $needle) { + Assert::assertStringContainsString($needle, $this->body); + } return $this; } @@ -77,8 +80,8 @@ public function assertHeaderContains(array $needles): self $needles = (array) $needles; foreach ($needles as $header => $value) { - expect($this->response->getHeader($header))->not->toBeNull(); - expect($this->response->getHeader($header))->toBe($value); + Assert::assertNotNull($this->response->getHeader($header)); + Assert::assertEquals($value, $this->response->getHeader($header)); } return $this; diff --git a/src/functions.php b/src/functions.php index bc22b596..0f25cf0d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -78,3 +78,28 @@ function trans_choice(string $key, int $number, array $replace = []): string return Translator::choice($key, $number, $replace); } } + +if (! function_exists('class_uses_recursive')) { + function class_uses_recursive(object|string $class): array + { + if (is_object($class)) { + $class = get_class($class); + } + + $results = []; + + do { + $traits = class_uses($class) ?: []; + + foreach ($traits as $trait) { + $results[$trait] = $trait; + + foreach (class_uses_recursive($trait) as $nestedTrait) { + $results[$nestedTrait] = $nestedTrait; + } + } + } while ($class = get_parent_class($class)); + + return array_values($results); + } +} diff --git a/tests/Mocks/Database/PostgresqlConnectionPool.php b/tests/Mocks/Database/PostgresqlConnectionPool.php new file mode 100644 index 00000000..62f5fdcf --- /dev/null +++ b/tests/Mocks/Database/PostgresqlConnectionPool.php @@ -0,0 +1,99 @@ +setFakeResult($result); + + return $pool; + } + + public function setFakeResult(array $result): void + { + $this->fakeResult = new FakeResult($result); + } + + public function throwDatabaseException(Throwable|null $error = null): self + { + $this->fakeError = $error ?? new SqlException('Fail trying database connection'); + + return $this; + } + + public function prepare(string $sql): SqlStatement + { + if (isset($this->fakeError)) { + throw $this->fakeError; + } + + return new FakeStatement($this->fakeResult); + } + + protected function createStatement(SqlStatement $statement, Closure $release): SqlStatement + { + return $statement; + } + + protected function createResult(SqlResult $result, Closure $release): SqlResult + { + return $result; + } + + protected function createStatementPool(string $sql, Closure $prepare): SqlStatement + { + return new FakeStatement($this->fakeResult); + } + + protected function createTransaction(SqlTransaction $transaction, Closure $release): SqlTransaction + { + return $transaction; + } +} diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index 2ecdde2f..d880c055 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Phenix\Data\Collection; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; it('creates collection from array', function () { $collection = Collection::fromArray([['name' => 'John']]); @@ -22,3 +24,327 @@ expect($collection->first())->toBeNull(); }); + +it('filters items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ['name' => 'Bob', 'age' => 20], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] >= 25); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->count())->toBe(2); + expect($filtered->first()['name'])->toBe('John'); +}); + +it('filter returns empty collection when no items match', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] > 50); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->isEmpty())->toBe(true); +}); + +it('filter returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John']]); + $filtered = $collection->filter(fn (array $item) => true); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('transforms items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $mapped = $collection->map(fn (array $item) => $item['name']); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->count())->toBe(2); + expect($mapped->first())->toBe('John'); +}); + +it('map can transform to different types', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => ['value' => $num * 2]); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->first())->toBe(['value' => 2]); +}); + +it('map returns new collection instance', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => $num); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped)->not()->toBe($collection); +}); + +it('filters by property value using where', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ['name' => 'Bob', 'role' => 'admin'], + ]); + + $admins = $collection->where('role', 'admin'); + + expect($admins)->toBeInstanceOf(Collection::class); + expect($admins->count())->toBe(2); + expect($admins->first()['name'])->toBe('John'); +}); + +it('where returns empty collection when no matches', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ]); + + $guests = $collection->where('role', 'guest'); + + expect($guests)->toBeInstanceOf(Collection::class); + expect($guests->isEmpty())->toBe(true); +}); + +it('where returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John', 'role' => 'admin']]); + $filtered = $collection->where('role', 'admin'); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('sorts items by property in ascending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age'); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Jane'); + expect($sorted->last()['name'])->toBe('Bob'); +}); + +it('sorts items by property in descending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age', Sort::Descending); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Bob'); + expect($sorted->last()['name'])->toBe('Jane'); +}); + +it('sorts items without property when comparing elements directly', function () { + $collection = new Collection('integer', [3, 1, 4, 1, 5, 9, 2, 6]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first())->toBe(1); + expect($sorted->last())->toBe(9); +}); + +it('sort returns new collection instance', function () { + $collection = new Collection('integer', [3, 1, 2]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted)->not()->toBe($collection); +}); + +it('returns divergent items between collections', function () { + $collection1 = Collection::fromArray([1, 2, 3, 4]); + $collection2 = Collection::fromArray([3, 4, 5, 6]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->count())->toBe(4); // 1, 2, 5, 6 + expect($diff->contains(1))->toBe(true); + expect($diff->contains(2))->toBe(true); + expect($diff->contains(5))->toBe(true); + expect($diff->contains(6))->toBe(true); +}); + +it('diff returns empty collection when collections are identical', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([1, 2, 3]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->isEmpty())->toBe(true); +}); + +it('diff returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([2, 3, 4]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff)->not()->toBe($collection1); + expect($diff)->not()->toBe($collection2); +}); + +// Intersect tests +it('returns intersecting items between collections', function () { + $collection1 = new Collection('integer', [1, 2, 3, 4]); + $collection2 = new Collection('integer', [3, 4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->count())->toBe(2); // 3, 4 + expect($intersect->contains(3))->toBe(true); + expect($intersect->contains(4))->toBe(true); +}); + +it('intersect returns empty collection when no intersection exists', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->isEmpty())->toBe(true); +}); + +it('intersect returns new collection instance', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [2, 3, 4]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect)->not()->toBe($collection1); + expect($intersect)->not()->toBe($collection2); +}); + +it('merges multiple collections', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([4, 5]); + $collection3 = Collection::fromArray([6, 7]); + + $merged = $collection1->merge($collection2, $collection3); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(7); + expect($merged->contains(1))->toBe(true); + expect($merged->contains(7))->toBe(true); +}); + +it('merges collections with array keys', function () { + $collection1 = new Collection('array', ['a' => ['name' => 'John']]); + $collection2 = new Collection('array', ['b' => ['name' => 'Jane']]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(2); + expect($merged->offsetExists('a'))->toBe(true); + expect($merged->offsetExists('b'))->toBe(true); +}); + +it('merge returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2]); + $collection2 = Collection::fromArray([3, 4]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged)->not()->toBe($collection1); + expect($merged)->not()->toBe($collection2); +}); + +it('merge throws exception when merging incompatible collection types', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = new Collection('string', ['a', 'b', 'c']); + + $collection1->merge($collection2); +})->throws(CollectionMismatchException::class); + +it('allows fluent method chaining', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30, 'role' => 'admin'], + ['name' => 'Jane', 'age' => 25, 'role' => 'user'], + ['name' => 'Bob', 'age' => 35, 'role' => 'admin'], + ['name' => 'Alice', 'age' => 28, 'role' => 'user'], + ]); + + $result = $collection + ->filter(fn (array $item) => $item['age'] >= 28) + ->where('role', 'admin') + ->sort('age', Sort::Descending) + ->map(fn (array $item) => $item['name']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(2); + expect($result->first())->toBe('Bob'); +}); + +it('efficiently detects homogeneous array types', function () { + $largeArray = array_fill(0, 10000, ['key' => 'value']); + + $start = microtime(true); + $collection = Collection::fromArray($largeArray); + $duration = microtime(true) - $start; + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('array'); + expect($duration)->toBeLessThan(0.5); // Should complete in less than 500ms +}); + +it('efficiently detects mixed array types', function () { + $mixedArray = [1, 'string', 3.14, true, ['array']]; + + $collection = Collection::fromArray($mixedArray); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('mixed'); +}); + +it('handles empty arrays efficiently', function () { + $collection = Collection::fromArray([]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->isEmpty())->toBe(true); + expect($collection->getType())->toBe('mixed'); +}); + +it('detects type from single element', function () { + $collection = Collection::fromArray([42]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('integer'); + expect($collection->count())->toBe(1); +}); + +it('stops checking types early when mixed is detected', function () { + $array = [1, 'two']; + for ($i = 0; $i < 10000; $i++) { + $array[] = $i; + } + + $start = microtime(true); + $collection = Collection::fromArray($array); + $duration = microtime(true) - $start; + + expect($collection->getType())->toBe('mixed'); + expect($duration)->toBeLessThan(0.1); +}); diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index e12259ab..3e850427 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Event; use Phenix\Events\EventEmitter; use Phenix\Events\Exceptions\EventException; use Phenix\Exceptions\RuntimeError; +use Phenix\Facades\Config; use Phenix\Facades\Event as EventFacade; use Phenix\Facades\Log; use Tests\Unit\Events\Internal\InvalidListener; @@ -466,3 +468,404 @@ expect($emitter->getListenerCount('warn.event'))->toBe(2); }); + +it('logs dispatched events while still processing listeners', function (): void { + EventFacade::log(); + + $called = false; + EventFacade::on('logged.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::emit('logged.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::expect('logged.event')->toBeDispatched(); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); + + expect(EventFacade::getEventLog()->count())->toEqual(1); + + EventFacade::resetEventLog(); + + expect(EventFacade::getEventLog()->count())->toEqual(0); + + EventFacade::emit('logged.event', 'payload-2'); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); +}); + +it('fakes events preventing listener execution', function (): void { + EventFacade::fake(); + + $called = false; + EventFacade::on('fake.event', function () use (&$called): void { + dump('FAILING'); + $called = true; + }); + + EventFacade::emit('fake.event', 'payload'); + + expect($called)->toBeFalse(); + + EventFacade::expect('fake.event')->toBeDispatched(); + EventFacade::expect('fake.event')->toBeDispatchedTimes(1); +}); + +it('can assert nothing dispatched', function (): void { + EventFacade::log(); + + EventFacade::expect('any.event')->toDispatchNothing(); +}); + +it('supports closure predicate', function (): void { + EventFacade::log(); + + EventFacade::emit('closure.event', ['foo' => 'bar']); + + EventFacade::expect('closure.event')->toBeDispatched(function ($event): bool { + return $event !== null && $event->getPayload()['foo'] === 'bar'; + }); +}); + +it('supports closure predicate with existing event', function (): void { + EventFacade::log(); + + EventFacade::expect('neg.event')->toNotBeDispatched(); + EventFacade::expect('neg.event')->toNotBeDispatched(fn ($event): bool => false); +}); + +it('supports closure predicate with absent event', function (): void { + EventFacade::log(); + + EventFacade::expect('absent.event')->toNotBeDispatched(); + EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); +}); + +it('fakes only specific events when a single event is provided and consumes it after first fake', function (): void { + $calledSpecific = false; + $calledOther = false; + + EventFacade::on('specific.event', function () use (&$calledSpecific): void { + $calledSpecific = true; // Should NOT run because faked + }); + + EventFacade::on('other.event', function () use (&$calledOther): void { + $calledOther = true; // Should run + }); + + EventFacade::fakeTimes('specific.event', 1); + + EventFacade::emit('specific.event', 'payload-1'); + + expect($calledSpecific)->toBeFalse(); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(1); + + EventFacade::emit('specific.event', 'payload-2'); + + expect($calledSpecific)->toBeTrue(); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(2); + + EventFacade::emit('other.event', 'payload'); + + expect($calledOther)->toBeTrue(); + + EventFacade::expect('other.event')->toBeDispatched(); +}); + +it('supports infinite fake for single event with no times argument', function (): void { + $called = 0; + + EventFacade::on('always.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeOnly('always.event'); + + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + + expect($called)->toBe(0); + + EventFacade::expect('always.event')->toBeDispatchedTimes(3); +}); + +it('supports limited fake with times argument then processes listeners', function (): void { + $called = 0; + + EventFacade::on('limited.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeTimes('limited.event', 2); + + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // real + EventFacade::emit('limited.event'); // real + + expect($called)->toEqual(2); + + EventFacade::expect('limited.event')->toBeDispatchedTimes(4); +}); + +it('supports limited fake then switching to only one infinite event', function (): void { + $limitedCalled = 0; + $onlyCalled = 0; + + EventFacade::on('assoc.limited', function () use (&$limitedCalled): void { $limitedCalled++; }); + EventFacade::on('assoc.only', function () use (&$onlyCalled): void { $onlyCalled++; }); + + EventFacade::fakeTimes('assoc.limited', 1); // fake first occurrence only + + EventFacade::emit('assoc.limited'); // fake + EventFacade::emit('assoc.limited'); // real + + EventFacade::fakeOnly('assoc.only'); + + EventFacade::emit('assoc.only'); // fake + EventFacade::emit('assoc.only'); // fake + + EventFacade::emit('assoc.limited'); // real + + expect($limitedCalled)->toBe(2); + expect($onlyCalled)->toBe(0); + + EventFacade::expect('assoc.limited')->toBeDispatchedTimes(3); // recorded 3 emits + EventFacade::expect('assoc.only')->toBeDispatchedTimes(2); // recorded but never executed +}); + +it('supports conditional closure based faking', function (): void { + $called = 0; + + EventFacade::log(); + EventFacade::fakeWhen('conditional.event', function (Collection $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'conditional.event') { + $count++; + } + } + + return $count <= 2; + }); + + EventFacade::on('conditional.event', function () use (&$called): void { $called++; }); + + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + + expect($called)->toBe(2); + + EventFacade::expect('conditional.event')->toBeDispatchedTimes(4); +}); + +it('supports single event closure predicate faking', function (): void { + $called = 0; + + EventFacade::fakeWhen('single.closure.event', function (Collection $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'single.closure.event') { + $count++; + } + } + + return $count <= 2; + }); + + EventFacade::on('single.closure.event', function () use (&$called): void { $called++; }); + + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // real + EventFacade::emit('single.closure.event'); // real + + expect($called)->toBe(2); + + EventFacade::expect('single.closure.event')->toBeDispatchedTimes(4); +}); + +it('does not log events in production environment', function (): void { + Config::set('app.env', 'production'); + + EventFacade::log(); + + EventFacade::emit('prod.logged.event', 'payload'); + + expect(EventFacade::getEventLog()->count())->toEqual(0); + + Config::set('app.env', 'local'); +}); + +it('does not fake events in production environment', function (): void { + Config::set('app.env', 'production'); + + $called = false; + EventFacade::on('prod.fake.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::fake(); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeOnly('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeWhen('prod.fake.event', function (): bool { + return true; + }); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeTimes('prod.fake.event', 10); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeOnce('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeExcept('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + Config::set('app.env', 'local'); +}); + +it('fakes multiple events provided sequentially', function (): void { + EventFacade::on('list.one', function (): never { throw new RuntimeError('Should not run'); }); + EventFacade::on('list.two', function (): never { throw new RuntimeError('Should not run'); }); + + $executedThree = false; + + EventFacade::on('list.three', function () use (&$executedThree): void { $executedThree = true; }); + + EventFacade::fakeOnly('list.one'); + EventFacade::fakeTimes('list.two', PHP_INT_MAX); + + EventFacade::emit('list.one'); + EventFacade::emit('list.one'); + EventFacade::emit('list.two'); + EventFacade::emit('list.two'); + + EventFacade::emit('list.three'); + + expect($executedThree)->toEqual(true); + + EventFacade::expect('list.one')->toBeDispatchedTimes(2); + EventFacade::expect('list.two')->toBeDispatchedTimes(2); + EventFacade::expect('list.three')->toBeDispatchedTimes(1); +}); + +it('ignores events configured with zero count', function (): void { + $executed = 0; + + EventFacade::on('zero.count.event', function () use (&$executed): void { $executed++; }); + + EventFacade::fakeTimes('zero.count.event', 0); + + EventFacade::emit('zero.count.event'); + EventFacade::emit('zero.count.event'); + + expect($executed)->toEqual(2); + + EventFacade::expect('zero.count.event')->toBeDispatchedTimes(2); +}); + +it('does not fake when closure throws exception', function (): void { + $executed = false; + + EventFacade::on('closure.exception.event', function () use (&$executed): void { $executed = true; }); + + EventFacade::fakeWhen('closure.exception.event', function (Collection $log): bool { + throw new RuntimeError('Predicate error'); + }); + + EventFacade::emit('closure.exception.event'); + + expect($executed)->toEqual(true); + + EventFacade::expect('closure.exception.event')->toBeDispatchedTimes(1); +}); + +it('fakes async emits correctly', function (): void { + EventFacade::fake(); + + $called = false; + + EventFacade::on('async.fake.event', function () use (&$called): void { + $called = true; + }); + + $future = EventFacade::emitAsync('async.fake.event', 'payload'); + + $future->await(); + + expect($called)->toBeFalse(); + + EventFacade::expect('async.fake.event')->toBeDispatched(); +}); + +it('fakes once correctly', function (): void { + $called = 0; + + EventFacade::on('fake.once.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeOnce('fake.once.event'); + + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + + expect($called)->toBe(2); + + EventFacade::expect('fake.once.event')->toBeDispatchedTimes(3); +}); + +it('fakes all except specified events', function (): void { + $calledFaked = 0; + $calledNotFaked = 0; + + EventFacade::on('not.faked.event', function () use (&$calledNotFaked): void { + $calledNotFaked++; + }); + + EventFacade::on('faked.event', function () use (&$calledFaked): void { + $calledFaked++; + }); + + EventFacade::fakeExcept('not.faked.event'); + + EventFacade::emit('faked.event'); + EventFacade::emit('faked.event'); + + EventFacade::emit('not.faked.event'); + EventFacade::emit('not.faked.event'); + + expect($calledFaked)->toBe(0); + expect($calledNotFaked)->toBe(2); + + EventFacade::expect('faked.event')->toBeDispatchedTimes(2); + EventFacade::expect('not.faked.event')->toBeDispatchedTimes(2); +}); diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php new file mode 100644 index 00000000..8f9e0d31 --- /dev/null +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -0,0 +1,90 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 1]])), + new Statement(new Result([['COUNT(*)' => 0]])), + new Statement(new Result([['COUNT(*)' => 1]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'email' => 'test@example.com', + ]); + + $this->assertDatabaseMissing('users', [ + 'email' => 'nonexistent@example.com', + ]); + + $this->assertDatabaseCount('users', 1, [ + 'email' => 'test@example.com', + ]); +}); + +it('supports closure criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 2]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseCount('users', 2, function ($query) { + $query->whereEqual('active', 1); + }); +}); + +it('supports null value criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'deleted_at' => null, + ]); +}); + +it('normalizes boolean criteria to integers', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), // active => true + new Statement(new Result([[ 'COUNT(*)' => 0 ]])), // active => false + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'active' => true, + ]); + + $this->assertDatabaseMissing('users', [ + 'active' => false, + ]); +}); diff --git a/tests/Unit/Mail/MailTest.php b/tests/Unit/Mail/MailTest.php index c3ef9b7c..3aff9d15 100644 --- a/tests/Unit/Mail/MailTest.php +++ b/tests/Unit/Mail/MailTest.php @@ -128,7 +128,7 @@ 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -142,14 +142,15 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { return $matches['success'] === true; }); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSentTimes(1); + Mail::expect($mailable)->toNotBeSent(); + Mail::expect($mailable)->toNotBeSent(function (array $matches): bool { return $matches['success'] === false; }); }); @@ -164,7 +165,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -178,14 +179,14 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { return $matches['success'] === true; }); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSentTimes(1); + Mail::expect($mailable)->toNotBeSent(function (array $matches): bool { return $matches['success'] === false; }); }); @@ -200,7 +201,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $mailable = new class () extends Mailable { public function build(): self @@ -213,7 +214,7 @@ public function build(): self Mail::send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); }); it('merge sender defined from facade and mailer', function (): void { @@ -226,7 +227,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -241,7 +242,7 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -265,7 +266,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $cc = faker()->freeEmail(); @@ -282,7 +283,7 @@ public function build(): self ->cc($cc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($cc): bool { + Mail::expect($mailable)->toBeSent(function (array $matches) use ($cc): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -307,7 +308,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $bcc = faker()->freeEmail(); @@ -324,7 +325,7 @@ public function build(): self ->bcc($bcc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($bcc): bool { + Mail::expect($mailable)->toBeSent(function (array $matches) use ($bcc): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -349,7 +350,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); @@ -365,7 +366,7 @@ public function build(): self Mail::to($to) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -389,7 +390,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -410,7 +411,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { return false; @@ -442,7 +443,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -456,7 +457,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toNotBeSent($mailable); + Mail::expect($mailable)->toNotBeSent(); })->throws(InvalidArgumentException::class); it('run parallel task to send email', function (): void { @@ -511,7 +512,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); @@ -530,7 +531,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 56c81fb7..5f492347 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -239,7 +239,7 @@ $parallelQueue = new ParallelQueue('test-skip-processing'); // Add initial task that will take 6 seconds to process - $parallelQueue->push(new DelayableTask(3)); + $parallelQueue->push(new DelayableTask(6)); $this->assertTrue($parallelQueue->isProcessing()); @@ -528,3 +528,341 @@ $parallelQueue->clear(); }); + +it('logs pushed tasks when logging is enabled', function (): void { + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::getQueueLog()->count())->toBe(1); + + Queue::resetQueueLog(); + + expect(Queue::getQueueLog()->count())->toBe(0); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + Queue::resetFaking(); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::getQueueLog()->count())->toBe(0); +}); + +it('does not log pushes in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); +}); + +it('does not fake tasks in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::fake(); + + Queue::push(new BasicQueuableTask()); + Queue::push(new BasicQueuableTask()); + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BadTask()); + Queue::push(new BasicQueuableTask()); + + Queue::expect(BadTask::class)->toPushNothing(); + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return true; + }); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); + Queue::clear(); +}); + +it('does not log tasks when logging is disabled', function (): void { + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); + +it('fakes queue pushes and prevents tasks from actually being enqueued', function (): void { + Queue::fake(); + + Queue::push(new BasicQueuableTask()); + Queue::pushOn('custom', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); + + $this->assertSame(0, Queue::size()); +}); + +it('asserts a task was not pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toNotBePushed(); + Queue::expect(BasicQueuableTask::class)->toNotBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'default'; + }); +}); + +it('asserts tasks pushed on a custom queue', function (): void { + Queue::fake(); + + Queue::pushOn('emails', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'emails'; + }); +}); + +it('asserts no tasks were pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); + +it('fakeOnly fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakeExcept fakes the specified task until it appears in the log', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); +}); + +it('fakes a task multiple times using times parameter', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('fakes tasks with per-task counts array', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); // faked + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('conditionally fakes tasks using array and a closure configuration', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 3; + }); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); +}); + +it('conditionally fakes tasks using only a closure configuration', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 2; + }); + + for ($i = 0; $i < 4; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(4); +}); + +it('does not fake when closure throws an exception', function (): void { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + throw new RuntimeException('Closure exception'); + }); + + Queue::push(new BasicQueuableTask()); + + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); +}); + +it('fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakes all tasks except the specified class', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); +}); + +it('fakeOnly resets previous fake configurations', function (): void { + Queue::fakeTimes(BadTask::class, 2); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(2); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); +}); + +it('fakeExcept resets previous fake configurations', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 1); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(1); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); +}); + +it('fakeOnly continues to fake the same task multiple times', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + expect(Queue::size())->toBe(0); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); + + Queue::push(new BadTask()); + + expect(Queue::size())->toBe(1); +}); + +it('fake once fakes only the next push of the specified task class', function (): void { + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); // faked + + expect(Queue::size())->toBe(0); + + Queue::push(new BasicQueuableTask()); // real + + expect(Queue::size())->toBe(1); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); +}); diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php new file mode 100644 index 00000000..5f48887d --- /dev/null +++ b/tests/Unit/RefreshDatabaseTest.php @@ -0,0 +1,67 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->atLeast(4)) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SHOW TABLES')) { + return new Statement(new Result([ + ['Tables_in_test' => 'users'], + ['Tables_in_test' => 'posts'], + ['Tables_in_test' => 'migrations'], // should be ignored for truncation + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); + +it('truncates tables for postgresql driver', function (): void { + Config::set('database.default', 'postgresql'); + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->atLeast(2)) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SELECT tablename FROM pg_tables')) { + return new Statement(new Result([ + ['tablename' => 'users'], + ['tablename' => 'posts'], + ['tablename' => 'migrations'], + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 732528da..3c8456e5 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -6,7 +6,7 @@ 'name' => env('APP_NAME', static fn (): string => 'Phenix'), 'env' => env('APP_ENV', static fn (): string => 'local'), 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), - 'port' => env('APP_PORT', static fn (): int => 1337), + 'port' => env('APP_PORT', static fn (): int => 1338), 'key' => env('APP_KEY'), 'previous_key' => env('APP_PREVIOUS_KEY'), 'debug' => env('APP_DEBUG', static fn (): bool => true),