From 5527322678ccb606150b2c44a1f5add166d60a12 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 3 Feb 2026 11:09:49 +0300 Subject: [PATCH 1/9] Add `StreamMock` --- CHANGELOG.md | 2 +- composer.json | 1 + docs/guide/en/README.md | 66 ++++ src/HttpMessage/StreamMock.php | 204 +++++++++++ tests/HttpMessage/StreamMockTest.php | 500 +++++++++++++++++++++++++++ 5 files changed, 772 insertions(+), 1 deletion(-) create mode 100644 src/HttpMessage/StreamMock.php create mode 100644 tests/HttpMessage/StreamMockTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 766451f..b7c4385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 3.1.1 under development -- no changes in this release. +- New #85: Add `StreamMock` (@vjik) ## 3.1.0 December 01, 2025 diff --git a/composer.json b/composer.json index ab35dd3..ae92c58 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-message": "^1.1 || ^2.0", "psr/log": "^2.0|^3.0", "psr/simple-cache": "^2.0|^3.0" }, diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index c56f2ef..a7a7ea2 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -129,3 +129,69 @@ sleep(10); echo $clock->now(); // Same value as above. ``` + +## Stream mock [PSR-7](https://www.php-fig.org/psr/psr-7/) + +The `StreamMock` class is a test-specific implementation of `StreamInterface`. +It allows you to create stream instances with configurable behavior for testing HTTP message handling. + +```php +use Yiisoft\Test\Support\HttpMessage\StreamMock; + +// Create a stream with content +$stream = new StreamMock('Hello, World!'); + +echo $stream; // Hello, World! +echo $stream->getSize(); // 13 +echo $stream->read(5); // Hello +echo $stream->getContents(); // , World! +``` + +You can configure stream behavior through constructor parameters: + +```php +// Create a read-only stream +$readOnlyStream = new StreamMock('content', writable: false); + +// Create a non-seekable stream +$nonSeekableStream = new StreamMock('content', seekable: false); + +// Create a stream with custom initial position +$stream = new StreamMock('Hello', position: 3); +echo $stream->getContents(); // lo +``` + +Custom metadata can be provided as an array or a closure: + +```php +// Array metadata +$stream = new StreamMock( + 'content', + metadata: [ + 'uri' => 'php://memory', + 'mode' => 'r+', + ], +); + +// Closure metadata (receives the stream instance) +$stream = new StreamMock( + 'content', + metadata: static fn(StreamMock $s) => [ + 'size' => $s->getSize(), + 'eof' => $s->eof(), + ], +); +``` + +The stream provides helper methods to check its state: + +```php +$stream = new StreamMock('content'); + +$stream->isClosed(); // false +$stream->isDetached(); // false +$stream->getPosition(); // 0 + +$stream->close(); +$stream->isClosed(); // true +``` diff --git a/src/HttpMessage/StreamMock.php b/src/HttpMessage/StreamMock.php new file mode 100644 index 0000000..f5dfd08 --- /dev/null +++ b/src/HttpMessage/StreamMock.php @@ -0,0 +1,204 @@ + + */ +final class StreamMock implements StreamInterface +{ + private bool $closed = false; + private bool $detached = false; + + /** + * @psalm-param MetadataClosure|array|null $metadata + */ + public function __construct( + private string $content = '', + private int $position = 0, + private bool $readable = true, + private bool $writable = true, + private bool $seekable = true, + private Closure|array|null $metadata = null, + ) { + } + + public function __toString(): string + { + return $this->content; + } + + public function isClosed(): bool + { + return $this->closed; + } + + public function isDetached(): bool + { + return $this->detached; + } + + public function getPosition(): int + { + return $this->position; + } + + public function close(): void + { + $this->closed = true; + } + + public function detach() + { + $this->detached = true; + $this->close(); + return null; + } + + public function getSize(): ?int + { + return $this->getContentSize(); + } + + public function tell(): int + { + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + return $this->position; + } + + public function eof(): bool + { + return $this->closed || $this->position >= $this->getContentSize(); + } + + public function isSeekable(): bool + { + return $this->seekable && !$this->closed; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + if (!$this->seekable) { + throw new RuntimeException('Stream is not seekable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + $size = $this->getContentSize(); + + $newPosition = match ($whence) { + SEEK_SET => $offset, + SEEK_CUR => $this->position + $offset, + SEEK_END => $size + $offset, + default => throw new RuntimeException('Invalid whence value.'), + }; + + if ($newPosition < 0 || $newPosition > $size) { + throw new RuntimeException('Invalid seek position.'); + } + + $this->position = $newPosition; + } + + public function rewind(): void + { + $this->seek(0); + } + + public function isWritable(): bool + { + return $this->writable && !$this->closed; + } + + public function write(string $string): int + { + if (!$this->writable) { + throw new RuntimeException('Stream is not writable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + $size = strlen($string); + $this->content = substr($this->content, 0, $this->position) + . $string + . substr($this->content, $this->position + $size); + $this->position += $size; + + return $size; + } + + public function isReadable(): bool + { + return $this->readable && !$this->closed; + } + + public function read(int $length): string + { + if (!$this->readable) { + throw new RuntimeException('Stream is not readable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + if ($this->position >= $this->getContentSize()) { + return ''; + } + + $result = substr($this->content, $this->position, $length); + $this->position += strlen($result); + + return $result; + } + + public function getContents(): string + { + return $this->read( + $this->getContentSize() - $this->position, + ); + } + + public function getMetadata(?string $key = null) + { + if ($this->closed) { + return $key === null ? [] : null; + } + + $metadata = match (true) { + is_array($this->metadata) => $this->metadata, + $this->metadata instanceof Closure => ($this->metadata)($this), + default => [ + 'eof' => $this->eof(), + 'seekable' => $this->isSeekable(), + ], + }; + + if ($key === null) { + return $metadata; + } + + return $metadata[$key] ?? null; + } + + private function getContentSize(): int + { + return strlen($this->content); + } +} diff --git a/tests/HttpMessage/StreamMockTest.php b/tests/HttpMessage/StreamMockTest.php new file mode 100644 index 0000000..67c2a41 --- /dev/null +++ b/tests/HttpMessage/StreamMockTest.php @@ -0,0 +1,500 @@ +assertSame(0, $stream->getSize()); + $this->assertSame(0, $stream->getPosition()); + $this->assertSame('', (string) $stream); + $this->assertSame('', $stream->getContents()); + $this->assertTrue($stream->isReadable()); + $this->assertTrue($stream->isWritable()); + $this->assertTrue($stream->isSeekable()); + $this->assertFalse($stream->isClosed()); + $this->assertFalse($stream->isDetached()); + } + + public function testConstructorWithContent(): void + { + $stream = new StreamMock('Hello, World!'); + + $this->assertSame('Hello, World!', (string) $stream); + $this->assertSame(13, $stream->getSize()); + } + + public function testConstructorWithPosition(): void + { + $stream = new StreamMock('Hello', position: 3); + + $this->assertSame(3, $stream->getPosition()); + $this->assertSame('lo', $stream->getContents()); + } + + public function testConstructorWithReadableFlag(): void + { + $stream = new StreamMock(readable: false); + + $this->assertFalse($stream->isReadable()); + } + + public function testConstructorWithWritableFlag(): void + { + $stream = new StreamMock(writable: false); + + $this->assertFalse($stream->isWritable()); + } + + public function testConstructorWithSeekableFlag(): void + { + $stream = new StreamMock(seekable: false); + + $this->assertFalse($stream->isSeekable()); + } + + public function testToString(): void + { + $stream = new StreamMock('Test content'); + + $this->assertSame('Test content', (string) $stream); + } + + public function testClose(): void + { + $stream = new StreamMock('content'); + + $this->assertFalse($stream->isClosed()); + + $stream->close(); + + $this->assertTrue($stream->isClosed()); + $this->assertFalse($stream->isReadable()); + $this->assertFalse($stream->isWritable()); + $this->assertFalse($stream->isSeekable()); + } + + public function testDetach(): void + { + $stream = new StreamMock('content'); + + $this->assertFalse($stream->isDetached()); + + $result = $stream->detach(); + + $this->assertNull($result); + $this->assertTrue($stream->isDetached()); + $this->assertTrue($stream->isClosed()); + } + + public function testGetSize(): void + { + $stream = new StreamMock(''); + $this->assertSame(0, $stream->getSize()); + + $stream = new StreamMock('Hello'); + $this->assertSame(5, $stream->getSize()); + + $stream = new StreamMock('Привет'); + $this->assertSame(12, $stream->getSize()); // UTF-8 bytes + } + + public function testTell(): void + { + $stream = new StreamMock('Hello', position: 0); + $this->assertSame(0, $stream->tell()); + + $stream = new StreamMock('Hello', position: 3); + $this->assertSame(3, $stream->tell()); + } + + public function testTellThrowsExceptionWhenClosed(): void + { + $stream = new StreamMock('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->tell(); + } + + public function testEof(): void + { + $stream = new StreamMock('Hello', position: 0); + $this->assertFalse($stream->eof()); + + $stream = new StreamMock('Hello', position: 5); + $this->assertTrue($stream->eof()); + + $stream = new StreamMock('Hello', position: 10); + $this->assertTrue($stream->eof()); + } + + public function testEofWhenClosed(): void + { + $stream = new StreamMock('Hello'); + $stream->close(); + + $this->assertTrue($stream->eof()); + } + + public function testSeekSet(): void + { + $stream = new StreamMock('Hello World'); + + $stream->seek(5); + $this->assertSame(5, $stream->getPosition()); + + $stream->seek(0); + $this->assertSame(0, $stream->getPosition()); + } + + public function testSeekCur(): void + { + $stream = new StreamMock('Hello World', position: 3); + + $stream->seek(2, SEEK_CUR); + $this->assertSame(5, $stream->getPosition()); + + $stream->seek(-3, SEEK_CUR); + $this->assertSame(2, $stream->getPosition()); + } + + public function testSeekEnd(): void + { + $stream = new StreamMock('Hello World'); + + $stream->seek(0, SEEK_END); + $this->assertSame(11, $stream->getPosition()); + + $stream->seek(-5, SEEK_END); + $this->assertSame(6, $stream->getPosition()); + } + + public function testSeekThrowsExceptionWhenNotSeekable(): void + { + $stream = new StreamMock('Hello', seekable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + $stream->seek(0); + } + + public function testSeekThrowsExceptionWhenClosed(): void + { + $stream = new StreamMock('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->seek(0); + } + + public function testSeekThrowsExceptionForInvalidWhence(): void + { + $stream = new StreamMock('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid whence value.'); + $stream->seek(0, 999); + } + + public function testSeekThrowsExceptionForNegativePosition(): void + { + $stream = new StreamMock('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid seek position.'); + $stream->seek(-1); + } + + public function testSeekThrowsExceptionForPositionBeyondSize(): void + { + $stream = new StreamMock('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid seek position.'); + $stream->seek(100); + } + + public function testRewind(): void + { + $stream = new StreamMock('Hello', position: 5); + + $stream->rewind(); + + $this->assertSame(0, $stream->getPosition()); + } + + public function testWrite(): void + { + $stream = new StreamMock(); + + $bytesWritten = $stream->write('Hello'); + + $this->assertSame(5, $bytesWritten); + $this->assertSame('Hello', (string) $stream); + $this->assertSame(5, $stream->getPosition()); + } + + public function testWriteAtPosition(): void + { + $stream = new StreamMock('Hello World', position: 6); + + $stream->write('PHP'); + + $this->assertSame('Hello PHPld', (string) $stream); + $this->assertSame(9, $stream->getPosition()); + } + + public function testWriteOverwrite(): void + { + $stream = new StreamMock('AAAAA', position: 0); + + $stream->write('BB'); + + $this->assertSame('BBAAA', (string) $stream); + } + + public function testWriteThrowsExceptionWhenNotWritable(): void + { + $stream = new StreamMock(writable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + $stream->write('test'); + } + + public function testWriteThrowsExceptionWhenClosed(): void + { + $stream = new StreamMock(); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->write('test'); + } + + public function testRead(): void + { + $stream = new StreamMock('Hello World'); + + $result = $stream->read(5); + + $this->assertSame('Hello', $result); + $this->assertSame(5, $stream->getPosition()); + } + + public function testReadFromPosition(): void + { + $stream = new StreamMock('Hello World', position: 6); + + $result = $stream->read(5); + + $this->assertSame('World', $result); + } + + public function testReadBeyondContent(): void + { + $stream = new StreamMock('Hi'); + + $result = $stream->read(100); + + $this->assertSame('Hi', $result); + $this->assertSame(2, $stream->getPosition()); + } + + public function testReadAtEof(): void + { + $stream = new StreamMock('Hello', position: 5); + + $result = $stream->read(10); + + $this->assertSame('', $result); + } + + public function testReadThrowsExceptionWhenNotReadable(): void + { + $stream = new StreamMock('content', readable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + $stream->read(5); + } + + public function testReadThrowsExceptionWhenClosed(): void + { + $stream = new StreamMock('content'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->read(5); + } + + public function testGetContents(): void + { + $stream = new StreamMock('Hello World'); + + $result = $stream->getContents(); + + $this->assertSame('Hello World', $result); + $this->assertSame(11, $stream->getPosition()); + } + + public function testGetContentsFromPosition(): void + { + $stream = new StreamMock('Hello World', position: 6); + + $result = $stream->getContents(); + + $this->assertSame('World', $result); + } + + public function testGetContentsAtEof(): void + { + $stream = new StreamMock('Hello', position: 5); + + $result = $stream->getContents(); + + $this->assertSame('', $result); + } + + public function testGetMetadataDefault(): void + { + $stream = new StreamMock('Hello'); + + $metadata = $stream->getMetadata(); + + $this->assertIsArray($metadata); + $this->assertArrayHasKey('eof', $metadata); + $this->assertArrayHasKey('seekable', $metadata); + $this->assertFalse($metadata['eof']); + $this->assertTrue($metadata['seekable']); + } + + public function testGetMetadataDefaultAtEof(): void + { + $stream = new StreamMock('Hello', position: 5); + + $metadata = $stream->getMetadata(); + + $this->assertTrue($metadata['eof']); + } + + public function testGetMetadataWithKey(): void + { + $stream = new StreamMock('Hello'); + + $this->assertFalse($stream->getMetadata('eof')); + $this->assertTrue($stream->getMetadata('seekable')); + $this->assertNull($stream->getMetadata('nonexistent')); + } + + public function testGetMetadataWhenClosed(): void + { + $stream = new StreamMock('Hello'); + $stream->close(); + + $this->assertSame([], $stream->getMetadata()); + $this->assertNull($stream->getMetadata('eof')); + } + + public function testGetMetadataWithArrayMetadata(): void + { + $customMetadata = [ + 'uri' => 'php://memory', + 'mode' => 'r+', + 'custom' => 'value', + ]; + $stream = new StreamMock('Hello', metadata: $customMetadata); + + $metadata = $stream->getMetadata(); + + $this->assertSame($customMetadata, $metadata); + $this->assertSame('php://memory', $stream->getMetadata('uri')); + $this->assertSame('value', $stream->getMetadata('custom')); + $this->assertNull($stream->getMetadata('nonexistent')); + } + + public function testGetMetadataWithClosureMetadata(): void + { + $stream = new StreamMock( + 'Hello', + position: 2, + metadata: static fn(StreamMock $s) => [ + 'position' => $s->getPosition(), + 'size' => $s->getSize(), + ], + ); + + $metadata = $stream->getMetadata(); + + $this->assertSame(['position' => 2, 'size' => 5], $metadata); + $this->assertSame(2, $stream->getMetadata('position')); + $this->assertSame(5, $stream->getMetadata('size')); + } + + public function testIsReadableWhenClosed(): void + { + $stream = new StreamMock(); + $stream->close(); + + $this->assertFalse($stream->isReadable()); + } + + public function testIsWritableWhenClosed(): void + { + $stream = new StreamMock(); + $stream->close(); + + $this->assertFalse($stream->isWritable()); + } + + public function testIsSeekableWhenClosed(): void + { + $stream = new StreamMock(); + $stream->close(); + + $this->assertFalse($stream->isSeekable()); + } + + public function testMultipleReadOperations(): void + { + $stream = new StreamMock('Hello World'); + + $this->assertSame('Hello', $stream->read(5)); + $this->assertSame(' ', $stream->read(1)); + $this->assertSame('World', $stream->read(5)); + $this->assertSame('', $stream->read(1)); + } + + public function testReadWriteCombination(): void + { + $stream = new StreamMock('Hello World'); + + $stream->read(6); + $stream->write('PHP'); + + $this->assertSame('Hello PHPld', (string) $stream); + } + + public function testSeekReadCombination(): void + { + $stream = new StreamMock('Hello World'); + + $stream->seek(6); + $result = $stream->read(5); + + $this->assertSame('World', $result); + } +} From f2c176af1b82344c733907a22abc16aa316d127e Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 3 Feb 2026 11:13:56 +0300 Subject: [PATCH 2/9] phpdoc --- src/HttpMessage/StreamMock.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/HttpMessage/StreamMock.php b/src/HttpMessage/StreamMock.php index f5dfd08..6ae65ac 100644 --- a/src/HttpMessage/StreamMock.php +++ b/src/HttpMessage/StreamMock.php @@ -12,6 +12,10 @@ use function strlen; /** + * A test-specific implementation of PSR-7 stream. + * + * Allows creating stream instances with configurable behavior for testing HTTP message handling. + * * @psalm-type MetadataClosure = Closure(StreamMock): array */ final class StreamMock implements StreamInterface @@ -20,6 +24,14 @@ final class StreamMock implements StreamInterface private bool $detached = false; /** + * @param string $content Initial stream content. + * @param int $position Initial position of the stream pointer. + * @param bool $readable Whether the stream is readable. + * @param bool $writable Whether the stream is writable. + * @param bool $seekable Whether the stream is seekable. + * @param Closure|array|null $metadata Custom metadata as an array or a closure that receives + * the stream instance and returns an array. + * * @psalm-param MetadataClosure|array|null $metadata */ public function __construct( @@ -37,16 +49,25 @@ public function __toString(): string return $this->content; } + /** + * Checks whether the stream has been closed. + */ public function isClosed(): bool { return $this->closed; } + /** + * Checks whether the stream has been detached. + */ public function isDetached(): bool { return $this->detached; } + /** + * Returns the current position of the stream pointer. + */ public function getPosition(): int { return $this->position; From de3e7f81171bdc4e366452cf03a6e3fe61ee79dd Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 3 Feb 2026 08:14:11 +0000 Subject: [PATCH 3/9] Apply fixes from StyleCI --- src/HttpMessage/StreamMock.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HttpMessage/StreamMock.php b/src/HttpMessage/StreamMock.php index 6ae65ac..787936f 100644 --- a/src/HttpMessage/StreamMock.php +++ b/src/HttpMessage/StreamMock.php @@ -29,7 +29,7 @@ final class StreamMock implements StreamInterface * @param bool $readable Whether the stream is readable. * @param bool $writable Whether the stream is writable. * @param bool $seekable Whether the stream is seekable. - * @param Closure|array|null $metadata Custom metadata as an array or a closure that receives + * @param array|Closure|null $metadata Custom metadata as an array or a closure that receives * the stream instance and returns an array. * * @psalm-param MetadataClosure|array|null $metadata From 1b612de719a3e3029c163a018794fd1c59cda75b Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Tue, 3 Feb 2026 13:26:25 +0300 Subject: [PATCH 4/9] rename --- CHANGELOG.md | 2 +- docs/guide/en/README.md | 22 ++-- .../{StreamMock.php => StringStream.php} | 4 +- ...treamMockTest.php => StringStreamTest.php} | 114 +++++++++--------- 4 files changed, 71 insertions(+), 71 deletions(-) rename src/HttpMessage/{StreamMock.php => StringStream.php} (97%) rename tests/HttpMessage/{StreamMockTest.php => StringStreamTest.php} (78%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c4385..4d3e771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 3.1.1 under development -- New #85: Add `StreamMock` (@vjik) +- New #85: Add `StringStream` (@vjik) ## 3.1.0 December 01, 2025 diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index a7a7ea2..4958b8b 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -130,16 +130,16 @@ sleep(10); echo $clock->now(); // Same value as above. ``` -## Stream mock [PSR-7](https://www.php-fig.org/psr/psr-7/) +## String stream [PSR-7](https://www.php-fig.org/psr/psr-7/) -The `StreamMock` class is a test-specific implementation of `StreamInterface`. +The `StringStream` class is a test-specific implementation of `StreamInterface`. It allows you to create stream instances with configurable behavior for testing HTTP message handling. ```php -use Yiisoft\Test\Support\HttpMessage\StreamMock; +use Yiisoft\Test\Support\HttpMessage\StringStream; // Create a stream with content -$stream = new StreamMock('Hello, World!'); +$stream = new StringStream('Hello, World!'); echo $stream; // Hello, World! echo $stream->getSize(); // 13 @@ -151,13 +151,13 @@ You can configure stream behavior through constructor parameters: ```php // Create a read-only stream -$readOnlyStream = new StreamMock('content', writable: false); +$readOnlyStream = new StringStream('content', writable: false); // Create a non-seekable stream -$nonSeekableStream = new StreamMock('content', seekable: false); +$nonSeekableStream = new StringStream('content', seekable: false); // Create a stream with custom initial position -$stream = new StreamMock('Hello', position: 3); +$stream = new StringStream('Hello', position: 3); echo $stream->getContents(); // lo ``` @@ -165,7 +165,7 @@ Custom metadata can be provided as an array or a closure: ```php // Array metadata -$stream = new StreamMock( +$stream = new StringStream( 'content', metadata: [ 'uri' => 'php://memory', @@ -174,9 +174,9 @@ $stream = new StreamMock( ); // Closure metadata (receives the stream instance) -$stream = new StreamMock( +$stream = new StringStream( 'content', - metadata: static fn(StreamMock $s) => [ + metadata: static fn(StringStream $s) => [ 'size' => $s->getSize(), 'eof' => $s->eof(), ], @@ -186,7 +186,7 @@ $stream = new StreamMock( The stream provides helper methods to check its state: ```php -$stream = new StreamMock('content'); +$stream = new StringStream('content'); $stream->isClosed(); // false $stream->isDetached(); // false diff --git a/src/HttpMessage/StreamMock.php b/src/HttpMessage/StringStream.php similarity index 97% rename from src/HttpMessage/StreamMock.php rename to src/HttpMessage/StringStream.php index 787936f..683be8f 100644 --- a/src/HttpMessage/StreamMock.php +++ b/src/HttpMessage/StringStream.php @@ -16,9 +16,9 @@ * * Allows creating stream instances with configurable behavior for testing HTTP message handling. * - * @psalm-type MetadataClosure = Closure(StreamMock): array + * @psalm-type MetadataClosure = Closure(StringStream): array */ -final class StreamMock implements StreamInterface +final class StringStream implements StreamInterface { private bool $closed = false; private bool $detached = false; diff --git a/tests/HttpMessage/StreamMockTest.php b/tests/HttpMessage/StringStreamTest.php similarity index 78% rename from tests/HttpMessage/StreamMockTest.php rename to tests/HttpMessage/StringStreamTest.php index 67c2a41..b086d6d 100644 --- a/tests/HttpMessage/StreamMockTest.php +++ b/tests/HttpMessage/StringStreamTest.php @@ -6,13 +6,13 @@ use PHPUnit\Framework\TestCase; use RuntimeException; -use Yiisoft\Test\Support\HttpMessage\StreamMock; +use Yiisoft\Test\Support\HttpMessage\StringStream; -final class StreamMockTest extends TestCase +final class StringStreamTest extends TestCase { public function testBase(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $this->assertSame(0, $stream->getSize()); $this->assertSame(0, $stream->getPosition()); @@ -27,7 +27,7 @@ public function testBase(): void public function testConstructorWithContent(): void { - $stream = new StreamMock('Hello, World!'); + $stream = new StringStream('Hello, World!'); $this->assertSame('Hello, World!', (string) $stream); $this->assertSame(13, $stream->getSize()); @@ -35,7 +35,7 @@ public function testConstructorWithContent(): void public function testConstructorWithPosition(): void { - $stream = new StreamMock('Hello', position: 3); + $stream = new StringStream('Hello', position: 3); $this->assertSame(3, $stream->getPosition()); $this->assertSame('lo', $stream->getContents()); @@ -43,35 +43,35 @@ public function testConstructorWithPosition(): void public function testConstructorWithReadableFlag(): void { - $stream = new StreamMock(readable: false); + $stream = new StringStream(readable: false); $this->assertFalse($stream->isReadable()); } public function testConstructorWithWritableFlag(): void { - $stream = new StreamMock(writable: false); + $stream = new StringStream(writable: false); $this->assertFalse($stream->isWritable()); } public function testConstructorWithSeekableFlag(): void { - $stream = new StreamMock(seekable: false); + $stream = new StringStream(seekable: false); $this->assertFalse($stream->isSeekable()); } public function testToString(): void { - $stream = new StreamMock('Test content'); + $stream = new StringStream('Test content'); $this->assertSame('Test content', (string) $stream); } public function testClose(): void { - $stream = new StreamMock('content'); + $stream = new StringStream('content'); $this->assertFalse($stream->isClosed()); @@ -85,7 +85,7 @@ public function testClose(): void public function testDetach(): void { - $stream = new StreamMock('content'); + $stream = new StringStream('content'); $this->assertFalse($stream->isDetached()); @@ -98,28 +98,28 @@ public function testDetach(): void public function testGetSize(): void { - $stream = new StreamMock(''); + $stream = new StringStream(''); $this->assertSame(0, $stream->getSize()); - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $this->assertSame(5, $stream->getSize()); - $stream = new StreamMock('Привет'); + $stream = new StringStream('Привет'); $this->assertSame(12, $stream->getSize()); // UTF-8 bytes } public function testTell(): void { - $stream = new StreamMock('Hello', position: 0); + $stream = new StringStream('Hello', position: 0); $this->assertSame(0, $stream->tell()); - $stream = new StreamMock('Hello', position: 3); + $stream = new StringStream('Hello', position: 3); $this->assertSame(3, $stream->tell()); } public function testTellThrowsExceptionWhenClosed(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $stream->close(); $this->expectException(RuntimeException::class); @@ -129,19 +129,19 @@ public function testTellThrowsExceptionWhenClosed(): void public function testEof(): void { - $stream = new StreamMock('Hello', position: 0); + $stream = new StringStream('Hello', position: 0); $this->assertFalse($stream->eof()); - $stream = new StreamMock('Hello', position: 5); + $stream = new StringStream('Hello', position: 5); $this->assertTrue($stream->eof()); - $stream = new StreamMock('Hello', position: 10); + $stream = new StringStream('Hello', position: 10); $this->assertTrue($stream->eof()); } public function testEofWhenClosed(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $stream->close(); $this->assertTrue($stream->eof()); @@ -149,7 +149,7 @@ public function testEofWhenClosed(): void public function testSeekSet(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $stream->seek(5); $this->assertSame(5, $stream->getPosition()); @@ -160,7 +160,7 @@ public function testSeekSet(): void public function testSeekCur(): void { - $stream = new StreamMock('Hello World', position: 3); + $stream = new StringStream('Hello World', position: 3); $stream->seek(2, SEEK_CUR); $this->assertSame(5, $stream->getPosition()); @@ -171,7 +171,7 @@ public function testSeekCur(): void public function testSeekEnd(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $stream->seek(0, SEEK_END); $this->assertSame(11, $stream->getPosition()); @@ -182,7 +182,7 @@ public function testSeekEnd(): void public function testSeekThrowsExceptionWhenNotSeekable(): void { - $stream = new StreamMock('Hello', seekable: false); + $stream = new StringStream('Hello', seekable: false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stream is not seekable.'); @@ -191,7 +191,7 @@ public function testSeekThrowsExceptionWhenNotSeekable(): void public function testSeekThrowsExceptionWhenClosed(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $stream->close(); $this->expectException(RuntimeException::class); @@ -201,7 +201,7 @@ public function testSeekThrowsExceptionWhenClosed(): void public function testSeekThrowsExceptionForInvalidWhence(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Invalid whence value.'); @@ -210,7 +210,7 @@ public function testSeekThrowsExceptionForInvalidWhence(): void public function testSeekThrowsExceptionForNegativePosition(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Invalid seek position.'); @@ -219,7 +219,7 @@ public function testSeekThrowsExceptionForNegativePosition(): void public function testSeekThrowsExceptionForPositionBeyondSize(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Invalid seek position.'); @@ -228,7 +228,7 @@ public function testSeekThrowsExceptionForPositionBeyondSize(): void public function testRewind(): void { - $stream = new StreamMock('Hello', position: 5); + $stream = new StringStream('Hello', position: 5); $stream->rewind(); @@ -237,7 +237,7 @@ public function testRewind(): void public function testWrite(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $bytesWritten = $stream->write('Hello'); @@ -248,7 +248,7 @@ public function testWrite(): void public function testWriteAtPosition(): void { - $stream = new StreamMock('Hello World', position: 6); + $stream = new StringStream('Hello World', position: 6); $stream->write('PHP'); @@ -258,7 +258,7 @@ public function testWriteAtPosition(): void public function testWriteOverwrite(): void { - $stream = new StreamMock('AAAAA', position: 0); + $stream = new StringStream('AAAAA', position: 0); $stream->write('BB'); @@ -267,7 +267,7 @@ public function testWriteOverwrite(): void public function testWriteThrowsExceptionWhenNotWritable(): void { - $stream = new StreamMock(writable: false); + $stream = new StringStream(writable: false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stream is not writable.'); @@ -276,7 +276,7 @@ public function testWriteThrowsExceptionWhenNotWritable(): void public function testWriteThrowsExceptionWhenClosed(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $stream->close(); $this->expectException(RuntimeException::class); @@ -286,7 +286,7 @@ public function testWriteThrowsExceptionWhenClosed(): void public function testRead(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $result = $stream->read(5); @@ -296,7 +296,7 @@ public function testRead(): void public function testReadFromPosition(): void { - $stream = new StreamMock('Hello World', position: 6); + $stream = new StringStream('Hello World', position: 6); $result = $stream->read(5); @@ -305,7 +305,7 @@ public function testReadFromPosition(): void public function testReadBeyondContent(): void { - $stream = new StreamMock('Hi'); + $stream = new StringStream('Hi'); $result = $stream->read(100); @@ -315,7 +315,7 @@ public function testReadBeyondContent(): void public function testReadAtEof(): void { - $stream = new StreamMock('Hello', position: 5); + $stream = new StringStream('Hello', position: 5); $result = $stream->read(10); @@ -324,7 +324,7 @@ public function testReadAtEof(): void public function testReadThrowsExceptionWhenNotReadable(): void { - $stream = new StreamMock('content', readable: false); + $stream = new StringStream('content', readable: false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stream is not readable.'); @@ -333,7 +333,7 @@ public function testReadThrowsExceptionWhenNotReadable(): void public function testReadThrowsExceptionWhenClosed(): void { - $stream = new StreamMock('content'); + $stream = new StringStream('content'); $stream->close(); $this->expectException(RuntimeException::class); @@ -343,7 +343,7 @@ public function testReadThrowsExceptionWhenClosed(): void public function testGetContents(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $result = $stream->getContents(); @@ -353,7 +353,7 @@ public function testGetContents(): void public function testGetContentsFromPosition(): void { - $stream = new StreamMock('Hello World', position: 6); + $stream = new StringStream('Hello World', position: 6); $result = $stream->getContents(); @@ -362,7 +362,7 @@ public function testGetContentsFromPosition(): void public function testGetContentsAtEof(): void { - $stream = new StreamMock('Hello', position: 5); + $stream = new StringStream('Hello', position: 5); $result = $stream->getContents(); @@ -371,7 +371,7 @@ public function testGetContentsAtEof(): void public function testGetMetadataDefault(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $metadata = $stream->getMetadata(); @@ -384,7 +384,7 @@ public function testGetMetadataDefault(): void public function testGetMetadataDefaultAtEof(): void { - $stream = new StreamMock('Hello', position: 5); + $stream = new StringStream('Hello', position: 5); $metadata = $stream->getMetadata(); @@ -393,7 +393,7 @@ public function testGetMetadataDefaultAtEof(): void public function testGetMetadataWithKey(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $this->assertFalse($stream->getMetadata('eof')); $this->assertTrue($stream->getMetadata('seekable')); @@ -402,7 +402,7 @@ public function testGetMetadataWithKey(): void public function testGetMetadataWhenClosed(): void { - $stream = new StreamMock('Hello'); + $stream = new StringStream('Hello'); $stream->close(); $this->assertSame([], $stream->getMetadata()); @@ -416,7 +416,7 @@ public function testGetMetadataWithArrayMetadata(): void 'mode' => 'r+', 'custom' => 'value', ]; - $stream = new StreamMock('Hello', metadata: $customMetadata); + $stream = new StringStream('Hello', metadata: $customMetadata); $metadata = $stream->getMetadata(); @@ -428,10 +428,10 @@ public function testGetMetadataWithArrayMetadata(): void public function testGetMetadataWithClosureMetadata(): void { - $stream = new StreamMock( + $stream = new StringStream( 'Hello', position: 2, - metadata: static fn(StreamMock $s) => [ + metadata: static fn(StringStream $s) => [ 'position' => $s->getPosition(), 'size' => $s->getSize(), ], @@ -446,7 +446,7 @@ public function testGetMetadataWithClosureMetadata(): void public function testIsReadableWhenClosed(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $stream->close(); $this->assertFalse($stream->isReadable()); @@ -454,7 +454,7 @@ public function testIsReadableWhenClosed(): void public function testIsWritableWhenClosed(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $stream->close(); $this->assertFalse($stream->isWritable()); @@ -462,7 +462,7 @@ public function testIsWritableWhenClosed(): void public function testIsSeekableWhenClosed(): void { - $stream = new StreamMock(); + $stream = new StringStream(); $stream->close(); $this->assertFalse($stream->isSeekable()); @@ -470,7 +470,7 @@ public function testIsSeekableWhenClosed(): void public function testMultipleReadOperations(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $this->assertSame('Hello', $stream->read(5)); $this->assertSame(' ', $stream->read(1)); @@ -480,7 +480,7 @@ public function testMultipleReadOperations(): void public function testReadWriteCombination(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $stream->read(6); $stream->write('PHP'); @@ -490,7 +490,7 @@ public function testReadWriteCombination(): void public function testSeekReadCombination(): void { - $stream = new StreamMock('Hello World'); + $stream = new StringStream('Hello World'); $stream->seek(6); $result = $stream->read(5); From 93331775cdfb1f06408319de8f6c0ba74322d506 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 5 Feb 2026 12:54:36 +0300 Subject: [PATCH 5/9] Update docs/guide/en/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guide/en/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index 4958b8b..dcfe82b 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -130,7 +130,7 @@ sleep(10); echo $clock->now(); // Same value as above. ``` -## String stream [PSR-7](https://www.php-fig.org/psr/psr-7/) +## String stream [PSR-7](https://www.php-fig.org/psr/psr-7/) The `StringStream` class is a test-specific implementation of `StreamInterface`. It allows you to create stream instances with configurable behavior for testing HTTP message handling. From 6dbbf1281ddca2c5ce8259ecdf7555b9b23b93fa Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 5 Feb 2026 12:54:45 +0300 Subject: [PATCH 6/9] Update docs/guide/en/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guide/en/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index dcfe82b..90fdd6c 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -175,7 +175,7 @@ $stream = new StringStream( // Closure metadata (receives the stream instance) $stream = new StringStream( - 'content', + 'content', metadata: static fn(StringStream $s) => [ 'size' => $s->getSize(), 'eof' => $s->eof(), From de89fefceb8a5a9b87a9028813e92334ad69360f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 5 Feb 2026 12:59:17 +0300 Subject: [PATCH 7/9] test --- src/HttpMessage/StringStream.php | 2 +- tests/HttpMessage/StringStreamTest.php | 76 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/HttpMessage/StringStream.php b/src/HttpMessage/StringStream.php index 683be8f..c3e4cfc 100644 --- a/src/HttpMessage/StringStream.php +++ b/src/HttpMessage/StringStream.php @@ -159,7 +159,7 @@ public function write(string $string): int $this->content = substr($this->content, 0, $this->position) . $string . substr($this->content, $this->position + $size); - $this->position += $size; + $this->position = min($this->position + $size, $this->getContentSize()); return $size; } diff --git a/tests/HttpMessage/StringStreamTest.php b/tests/HttpMessage/StringStreamTest.php index b086d6d..20e0d9d 100644 --- a/tests/HttpMessage/StringStreamTest.php +++ b/tests/HttpMessage/StringStreamTest.php @@ -69,6 +69,22 @@ public function testToString(): void $this->assertSame('Test content', (string) $stream); } + public function testToStringWhenClosed(): void + { + $stream = new StringStream('Test content'); + $stream->close(); + + $this->assertSame('Test content', (string) $stream); + } + + public function testToStringWhenDetached(): void + { + $stream = new StringStream('Test content'); + $stream->detach(); + + $this->assertSame('Test content', (string) $stream); + } + public function testClose(): void { $stream = new StringStream('content'); @@ -235,6 +251,25 @@ public function testRewind(): void $this->assertSame(0, $stream->getPosition()); } + public function testRewindThrowsExceptionWhenNotSeekable(): void + { + $stream = new StringStream('Hello', seekable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + $stream->rewind(); + } + + public function testRewindThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->rewind(); + } + public function testWrite(): void { $stream = new StringStream(); @@ -265,6 +300,28 @@ public function testWriteOverwrite(): void $this->assertSame('BBAAA', (string) $stream); } + public function testWriteAtEndOfContent(): void + { + $stream = new StringStream('Hello', position: 5); + + $bytesWritten = $stream->write(' World'); + + $this->assertSame(6, $bytesWritten); + $this->assertSame('Hello World', (string) $stream); + $this->assertSame(11, $stream->getPosition()); + } + + public function testWriteBeyondContent(): void + { + $stream = new StringStream('Hi', position: 10); + + $bytesWritten = $stream->write('!'); + + $this->assertSame(1, $bytesWritten); + $this->assertSame('Hi!', (string) $stream); + $this->assertSame(3, $stream->getPosition()); + } + public function testWriteThrowsExceptionWhenNotWritable(): void { $stream = new StringStream(writable: false); @@ -369,6 +426,25 @@ public function testGetContentsAtEof(): void $this->assertSame('', $result); } + public function testGetContentsThrowsExceptionWhenNotReadable(): void + { + $stream = new StringStream('content', readable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + $stream->getContents(); + } + + public function testGetContentsThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('content'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->getContents(); + } + public function testGetMetadataDefault(): void { $stream = new StringStream('Hello'); From ca989811ce829611506e49b9e02a3a815eb55e52 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 5 Feb 2026 13:10:51 +0300 Subject: [PATCH 8/9] improve --- src/HttpMessage/StringStream.php | 7 +++++++ tests/HttpMessage/StringStreamTest.php | 29 +++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/HttpMessage/StringStream.php b/src/HttpMessage/StringStream.php index c3e4cfc..01217a1 100644 --- a/src/HttpMessage/StringStream.php +++ b/src/HttpMessage/StringStream.php @@ -7,6 +7,7 @@ use Closure; use Psr\Http\Message\StreamInterface; use RuntimeException; +use LogicException; use function is_array; use function strlen; @@ -42,6 +43,12 @@ public function __construct( private bool $seekable = true, private Closure|array|null $metadata = null, ) { + $size = strlen($this->content); + if ($this->position < 0 || $this->position > $size) { + throw new LogicException( + sprintf('Position %d is out of valid range [0, %d].', $this->position, $size) + ); + } } public function __toString(): string diff --git a/tests/HttpMessage/StringStreamTest.php b/tests/HttpMessage/StringStreamTest.php index 20e0d9d..1472b5c 100644 --- a/tests/HttpMessage/StringStreamTest.php +++ b/tests/HttpMessage/StringStreamTest.php @@ -4,6 +4,7 @@ namespace Yiisoft\Test\Support\Tests\HttpMessage; +use LogicException; use PHPUnit\Framework\TestCase; use RuntimeException; use Yiisoft\Test\Support\HttpMessage\StringStream; @@ -62,6 +63,20 @@ public function testConstructorWithSeekableFlag(): void $this->assertFalse($stream->isSeekable()); } + public function testConstructorThrowsExceptionForNegativePosition(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Position -1 is out of valid range [0, 5].'); + new StringStream('Hello', position: -1); + } + + public function testConstructorThrowsExceptionForPositionBeyondContent(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Position 10 is out of valid range [0, 5].'); + new StringStream('Hello', position: 10); + } + public function testToString(): void { $stream = new StringStream('Test content'); @@ -150,9 +165,6 @@ public function testEof(): void $stream = new StringStream('Hello', position: 5); $this->assertTrue($stream->eof()); - - $stream = new StringStream('Hello', position: 10); - $this->assertTrue($stream->eof()); } public function testEofWhenClosed(): void @@ -311,17 +323,6 @@ public function testWriteAtEndOfContent(): void $this->assertSame(11, $stream->getPosition()); } - public function testWriteBeyondContent(): void - { - $stream = new StringStream('Hi', position: 10); - - $bytesWritten = $stream->write('!'); - - $this->assertSame(1, $bytesWritten); - $this->assertSame('Hi!', (string) $stream); - $this->assertSame(3, $stream->getPosition()); - } - public function testWriteThrowsExceptionWhenNotWritable(): void { $stream = new StringStream(writable: false); From 1b0401ea802a55c58282a779f10b047c35d33a53 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 5 Feb 2026 13:12:25 +0300 Subject: [PATCH 9/9] improve --- src/HttpMessage/StringStream.php | 4 ++++ tests/HttpMessage/StringStreamTest.php | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/HttpMessage/StringStream.php b/src/HttpMessage/StringStream.php index 01217a1..faeb473 100644 --- a/src/HttpMessage/StringStream.php +++ b/src/HttpMessage/StringStream.php @@ -186,6 +186,10 @@ public function read(int $length): string throw new RuntimeException('Stream is closed.'); } + if ($length < 0) { + throw new RuntimeException('Length cannot be negative.'); + } + if ($this->position >= $this->getContentSize()) { return ''; } diff --git a/tests/HttpMessage/StringStreamTest.php b/tests/HttpMessage/StringStreamTest.php index 1472b5c..ca3b3f2 100644 --- a/tests/HttpMessage/StringStreamTest.php +++ b/tests/HttpMessage/StringStreamTest.php @@ -399,6 +399,15 @@ public function testReadThrowsExceptionWhenClosed(): void $stream->read(5); } + public function testReadThrowsExceptionForNegativeLength(): void + { + $stream = new StringStream('content'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Length cannot be negative.'); + $stream->read(-1); + } + public function testGetContents(): void { $stream = new StringStream('Hello World');