createStub(ConnectionInterface::class); $connection->method('readLine')->willReturnCallback(static function () use (&$lines): string { return array_shift($lines) ?? throw new \RuntimeException('Unexpected response read'); }); $connection->method('write')->willReturnCallback(function (string $wire) use (&$lines, $failure, $move, $failureStatus): void { [$tag, $command] = explode(' ', trim($wire), 2); $this->commands[] = $command; $operation = str_starts_with($command, 'UID ') ? explode(' ', $command)[1] : explode(' ', $command)[0]; if ($operation === 'CAPABILITY') { $lines[] = '* CAPABILITY IMAP4rev1 UIDPLUS' . ($move ? ' MOVE' : '') . "\r\n"; } $status = $operation === $failure ? $failureStatus : 'OK'; $text = $status === 'OK' ? 'Completed' : 'Denied'; if ($status === 'OK' && in_array($operation, ['COPY', 'MOVE'], true)) { $text = '[COPYUID 7 42 142] Completed'; } $lines[] = "$tag $status $text\r\n"; }); $factory = $this->createStub(ConnectionFactoryInterface::class); $factory->method('create')->willReturn($connection); $client = new Client($factory); $client->connect(new ConnectionConfig('localhost')); $this->commands = []; return $client; } private function service(Client $client): LiveMailService { return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService { public function __construct(ServiceBase $service, private readonly Client $client) { parent::__construct($service); } public function imapClient(): Client { return $this->client; } }; } public function testSuccessfulCommandReturnsCompletionText(): void { $result = $this->client()->perform(new CreateCommand('Archive')); $this->assertInstanceOf(CommandCompletion::class, $result); $this->assertSame('OK', $result->status()); $this->assertSame('Completed', $result->text()); } public static function failures(): iterable { foreach (['NO', 'BAD'] as $status) { yield "create $status" => ['collectionCreate', ['Archive'], 'CREATE', false, ['CREATE "Archive"'], $status]; yield "rename $status" => ['collectionRename', ['INBOX', 'Archive'], 'RENAME', false, ['RENAME "INBOX" "Archive"'], $status]; yield "delete $status" => ['collectionDestroy', ['Archive'], 'DELETE', false, ['DELETE "Archive"'], $status]; yield "copy $status" => ['entityCopy', ['Archive', 'INBOX', 42], 'COPY', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"'], $status]; yield "move $status" => ['entityMove', ['Archive', 'INBOX', 42], 'MOVE', true, ['SELECT "INBOX"', 'UID MOVE 42 "Archive"'], $status]; yield "fallback copy $status" => ['entityMove', ['Archive', 'INBOX', 42], 'COPY', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"'], $status]; yield "fallback store $status" => ['entityMove', ['Archive', 'INBOX', 42], 'STORE', false, ['SELECT "INBOX"', 'UID COPY 42 "Archive"', 'UID STORE 42 +FLAGS.SILENT (\\Deleted)'], $status]; } } #[DataProvider('failures')] public function testFailuresPropagateWithoutExecutingLaterCommands(string $method, array $arguments, string $failure, bool $move, array $commands, string $status): void { $service = $this->service($this->client($failure, $move, $status)); try { $service->$method(...$arguments); $this->fail('Expected command failure'); } catch (ImapException $exception) { $this->assertSame($failure . ' failed: Denied', $exception->getMessage()); } $this->assertSame($commands, $this->commands); } public function testMoveFallbackDeletesOnlyAfterCopySucceeds(): void { $result = $this->service($this->client())->entityMove('Archive', 'INBOX', 42); $this->assertSame([42 => '142'], $result); $this->assertSame([ 'SELECT "INBOX"', 'UID COPY 42 "Archive"', 'UID STORE 42 +FLAGS.SILENT (\\Deleted)', 'UID EXPUNGE 42', ], $this->commands); } }