createStub(ConnectionInterface::class)); $context->setSelectedMailbox('INBOX'); return $context; } private function command(string $name): CommandInterface { return match ($name) { 'COPY' => new CopyCommand(MessageTarget::uid(42), 'Archive'), 'MOVE' => new MoveCommand(MessageTarget::uid(42), 'Archive'), 'APPEND' => new AppendCommand('Archive', 'body'), 'CAPABILITY' => new Commands\CapabilityCommand(), 'CREATE' => new Commands\CreateCommand('Archive'), 'DELETE' => new Commands\DeleteCommand('Archive'), 'RENAME' => new Commands\RenameCommand('INBOX', 'Archive'), 'LOGIN' => new Commands\LoginCommand('user', 'password'), 'LOGOUT' => new Commands\LogoutCommand(), 'NOOP' => new Commands\NoopCommand(), 'STARTTLS' => new Commands\StartTlsCommand(), 'SELECT' => new Commands\SelectCommand('INBOX', false), 'EXAMINE' => new Commands\SelectCommand('INBOX', true), 'STATUS' => new Commands\StatusCommand('INBOX'), 'SEARCH' => new Commands\SearchCommand(), 'SORT' => new Commands\SortCommand(['DATE']), 'STORE' => new Commands\StoreCommand(MessageTarget::uid(42), ['\\Seen']), 'EXPUNGE' => new Commands\ExpungeCommand(), 'UID EXPUNGE' => new Commands\ExpungeCommand(MessageTarget::uid(42)), 'LIST' => new Commands\ListCommand(), 'LIST-STATUS' => new Commands\ListCommand(returnOptions: ListReturnOptions::status('MESSAGES')), 'FETCH' => new Commands\FetchOneCommand(42), 'FETCH-MANY' => new Commands\FetchManyCommand(), }; } public static function failures(): iterable { foreach (['COPY', 'MOVE', 'APPEND', 'CAPABILITY', 'CREATE', 'DELETE', 'RENAME', 'LOGIN', 'LOGOUT', 'NOOP', 'STARTTLS', 'SELECT', 'EXAMINE', 'STATUS', 'SEARCH', 'SORT', 'STORE', 'EXPUNGE', 'UID EXPUNGE', 'LIST', 'LIST-STATUS', 'FETCH', 'FETCH-MANY'] as $command) { foreach (['NO', 'BAD'] as $status) { foreach ([ ['[trycreate] Create destination first', ['name' => 'TRYCREATE', 'arguments' => [], 'text' => 'Create destination first']], ['[LIMIT 10] Too many messages', ['name' => 'LIMIT', 'arguments' => ['10'], 'text' => 'Too many messages']], ['Denied', null], ['[Malformed', null], ] as [$text, $code]) { yield $command . ' ' . $status . ' ' . $text => [$command, $status, $text, $code]; } } } } #[DataProvider('failures')] public function testServerFailurePreservesStructuredDetails(string $command, string $status, string $text, ?array $code): void { $responses = new ResponseStream(static function () use ($status, $text): \Generator { yield new TaggedResponse('A1', $status, $text, "A1 $status $text"); }); try { $result = $this->command($command)->handle($responses, $this->context()); if ($result instanceof \Generator) { iterator_to_array($result); } $this->fail('Expected server failure'); } catch (ImapException $exception) { $command = match ($command) { 'LIST-STATUS' => 'LIST', 'FETCH-MANY' => 'FETCH', default => $command }; $this->assertInstanceOf(CommandFailedException::class, $exception); $this->assertSame($command . ' failed: ' . $text, $exception->getMessage()); $this->assertSame($command, $exception->command()); $this->assertSame($status, $exception->status()); $this->assertSame($text, $exception->text()); $this->assertSame($code, $exception->responseCode()); } } public function testMissingCompletionRemainsAProtocolError(): void { foreach (['COPY', 'MOVE', 'APPEND'] as $command) { $responses = new ResponseStream(static function (): \Generator { yield from []; }); try { $this->command($command)->handle($responses, $this->context()); $this->fail('Expected missing completion failure'); } catch (ImapException $exception) { $this->assertNotInstanceOf(CommandFailedException::class, $exception); $this->assertSame($command . ' did not receive a tagged completion response.', $exception->getMessage()); } } } public function testSuccessfulAppendStillReturnsAssignedUid(): void { $responses = new ResponseStream(static function (): \Generator { yield new TaggedResponse('A1', 'OK', '[APPENDUID 7 42] Done', 'A1 OK [APPENDUID 7 42] Done'); }); $this->assertSame(42, $this->command('APPEND')->handle($responses, $this->context())); } public function testDownloadFailuresUseStructuredExceptions(): void { foreach (['readUntilFetchLiteral', 'readToEnd'] as $method) { foreach (['NO', 'BAD'] as $status) { $connection = $this->createStub(ConnectionInterface::class); $connection->method('readLine')->willReturn("A1 $status [LIMIT 10] Denied\r\n"); try { (new ProtocolReader($connection))->$method('A1'); $this->fail('Expected download failure'); } catch (CommandFailedException $exception) { $this->assertSame('FETCH', $exception->command()); $this->assertSame($status, $exception->status()); $this->assertSame(['name' => 'LIMIT', 'arguments' => ['10'], 'text' => 'Denied'], $exception->responseCode()); } } } } public function testCheckerAcceptsOkAndRejectsMalformedStatusAsProtocolError(): void { CompletionChecker::assertSuccess('NOOP', new TaggedResponse('A1', 'OK', 'Done', 'A1 OK Done')); try { CompletionChecker::assertSuccess('NOOP', new TaggedResponse('A1', 'INVALID', 'Bad status', 'A1 INVALID Bad status')); $this->fail('Expected malformed status error'); } catch (ImapException $exception) { $this->assertNotInstanceOf(CommandFailedException::class, $exception); } } }