diff --git a/src/OpenedFile.php b/src/OpenedFile.php new file mode 100644 index 00000000..f92eea72 --- /dev/null +++ b/src/OpenedFile.php @@ -0,0 +1,271 @@ +base = $base; + } + + public function getBaseFile(): vfsStreamFile + { + return $this->base; + } + + /** + * simply open the file + */ + public function open(): void + { + $this->base->open(); + } + + /** + * open file and set pointer to end of file + */ + public function openForAppend(): void + { + $this->base->openForAppend(); + $this->savePosition(); + } + + /** + * open file and truncate content + */ + public function openWithTruncate(): void + { + $this->base->openWithTruncate(); + $this->savePosition(); + } + + /** + * reads the given amount of bytes from content + */ + public function read(int $count): string + { + $this->restorePosition(); + $data = $this->base->read($count); + $this->savePosition(); + + return $data; + } + + /** + * returns the content until its end from current offset + */ + public function readUntilEnd(): string + { + $this->restorePosition(); + $data = $this->base->readUntilEnd(); + $this->savePosition(); + + return $data; + } + + /** + * writes an amount of data + * + * @return int number of bytes written + */ + public function write(string $data): int + { + $this->restorePosition(); + $bytes = $this->base->write($data); + $this->savePosition(); + + return $bytes; + } + + /** + * Truncates a file to a given length + * + * @param int $size length to truncate file to + */ + public function truncate(int $size): bool + { + $this->restorePosition(); + + return $this->base->truncate($size); + } + + /** + * checks whether pointer is at end of file + */ + public function eof(): bool + { + $this->restorePosition(); + + return $this->base->eof(); + } + + /** + * returns the current position within the file + */ + public function getBytesRead(): int + { + $this->restorePosition(); + + $this->position = $this->base->getBytesRead(); + + return $this->position; + } + + /** + * seeks to the given offset + */ + public function seek(int $offset, int $whence): bool + { + if ($whence !== SEEK_SET) { + $this->restorePosition(); + } + + $success = $this->base->seek($offset, $whence); + $this->savePosition(); + + return $success; + } + + /** + * returns size of content + */ + public function size(): int + { + return $this->base->size(); + } + + /** + * locks file + * + * @param resource|vfsStreamWrapper $resource + */ + public function lock($resource, int $operation): bool + { + return $this->base->lock($resource, $operation); + } + + /** + * returns the type of the container + */ + public function getType(): int + { + return $this->base->getType(); + } + + /** + * returns the last modification time of the stream content + */ + public function filemtime(): int + { + return $this->base->filemtime(); + } + + /** + * returns the last access time of the stream content + */ + public function fileatime(): int + { + return $this->base->fileatime(); + } + + /** + * returns the last attribute modification time of the stream content + */ + public function filectime(): int + { + return $this->base->filectime(); + } + + /** + * returns permissions + */ + public function getPermissions(): int + { + return $this->base->getPermissions(); + } + + /** + * checks whether content is readable + * + * @param int $user id of user to check for + * @param int $group id of group to check for + */ + public function isReadable(int $user, int $group): bool + { + return $this->base->isReadable($user, $group); + } + + /** + * checks whether content is writable + * + * @param int $user id of user to check for + * @param int $group id of group to check for + */ + public function isWritable(int $user, int $group): bool + { + return $this->base->isWritable($user, $group); + } + + /** + * checks whether content is executable + * + * @param int $user id of user to check for + * @param int $group id of group to check for + */ + public function isExecutable(int $user, int $group): bool + { + return $this->base->isExecutable($user, $group); + } + + /** + * returns owner of file + */ + public function getUser(): int + { + return $this->base->getUser(); + } + + /** + * returns owner group of file + */ + public function getGroup(): int + { + return $this->base->getGroup(); + } + + private function restorePosition(): void + { + $this->base->getContentObject()->seek($this->position, SEEK_SET); + } + + private function savePosition(): void + { + $this->position = $this->base->getContentObject()->bytesRead(); + } +} diff --git a/src/vfsStreamFile.php b/src/vfsStreamFile.php index bb52547c..7a8f4ed1 100644 --- a/src/vfsStreamFile.php +++ b/src/vfsStreamFile.php @@ -136,6 +136,16 @@ public function getContent(): string return $this->content->content(); } + /** + * returns the raw content object. + * + * @internal + */ + public function getContentObject(): FileContent + { + return $this->content; + } + /** * simply open the file * diff --git a/src/vfsStreamWrapper.php b/src/vfsStreamWrapper.php index 8a27affb..c93bf5a1 100644 --- a/src/vfsStreamWrapper.php +++ b/src/vfsStreamWrapper.php @@ -112,7 +112,7 @@ class vfsStreamWrapper /** * shortcut to file container * - * @var vfsStreamFile|null + * @var OpenedFile|null */ protected $content; /** @@ -329,7 +329,7 @@ public function stream_open(string $path, string $mode, int $options, ?string $o /** @var vfsStreamFile|null $content */ $content = $this->getContentOfType($path, vfsStreamContent::TYPE_FILE); if ($content !== null) { - $this->content = $content; + $this->content = new OpenedFile($content); if ($mode === self::WRITE) { if (($options & STREAM_REPORT_ERRORS) === STREAM_REPORT_ERRORS) { trigger_error( @@ -370,7 +370,7 @@ public function stream_open(string $path, string $mode, int $options, ?string $o return false; } - $this->content = $content; + $this->content = new OpenedFile($content); return true; } @@ -693,7 +693,7 @@ public function stream_stat() $fileStat = [ 'dev' => 0, - 'ino' => spl_object_id($this->content), + 'ino' => spl_object_id($this->content->getBaseFile()), 'mode' => $this->content->getType() | $this->content->getPermissions(), 'nlink' => 0, 'uid' => $this->content->getUser(), diff --git a/tests/phpunit/OpenedFileTestCase.php b/tests/phpunit/OpenedFileTestCase.php new file mode 100644 index 00000000..543b7fdb --- /dev/null +++ b/tests/phpunit/OpenedFileTestCase.php @@ -0,0 +1,688 @@ +content = NewInstance::of(StringBasedFileContent::class, ['foobarbaz']); + $this->base = NewInstance::of(vfsStreamFile::class, [uniqid()]); + $this->base->withContent($this->content); + + $this->fixture = new OpenedFile($this->base); + } + + public function testGetBaseFile(): void + { + $actual = $this->fixture->getBaseFile(); + + assertThat($actual, isSameAs($this->base)); + } + + /** + * @doesNotPerformAssertions + */ + public function testOpenCallsBase(): void + { + $this->fixture->open(); + + verify($this->base, 'open')->wasCalledOnce(); + verify($this->base, 'open')->receivedNothing(); + } + + /** + * @doesNotPerformAssertions + */ + public function testOpenForAppendCallsBase(): void + { + $this->fixture->openForAppend(); + + verify($this->base, 'openForAppend')->wasCalledOnce(); + verify($this->base, 'openForAppend')->receivedNothing(); + } + + /** + * @doesNotPerformAssertions + */ + public function testOpenForAppendChecksPosition(): void + { + $this->fixture->openForAppend(); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + /** + * @doesNotPerformAssertions + */ + public function testOpenWithTruncateCallsBase(): void + { + $this->fixture->openWithTruncate(); + + verify($this->base, 'openWithTruncate')->wasCalledOnce(); + verify($this->base, 'openWithTruncate')->receivedNothing(); + } + + /** + * @doesNotPerformAssertions + */ + public function testOpenWithTruncateChecksPosition(): void + { + $this->fixture->openWithTruncate(); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + public function testReadCallsBase(): void + { + $bytes = rand(1, 10); + + $this->fixture->read($bytes); + + verify($this->base, 'read')->wasCalledOnce(); + verify($this->base, 'read')->received($bytes); + } + + public function testReadRestoresPreviousPosition(): void + { + $this->fixture->read(3); + $this->fixture->read(6); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 3, SEEK_SET); + } + + /** + * @doesNotPerformAssertions + */ + public function testReadChecksPosition(): void + { + $this->fixture->read(rand(1, 10)); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + public function testReadResponse(): void + { + $data = uniqid(); + $this->base->returns(['read' => $data]); + + $actual = $this->fixture->read(strlen($data)); + + assertThat($actual, equals($data)); + } + + /** + * @doesNotPerformAssertions + */ + public function testReadUntilEndCallsBase(): void + { + $this->fixture->readUntilEnd(); + + verify($this->base, 'readUntilEnd')->wasCalledOnce(); + verify($this->base, 'readUntilEnd')->receivedNothing(); + } + + public function testReadUntilEndRestoresPreviousPosition(): void + { + $this->fixture->read(3); + $this->fixture->readUntilEnd(); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 3, SEEK_SET); + } + + /** + * @doesNotPerformAssertions + */ + public function testReadUntilEndChecksPosition(): void + { + $this->fixture->readUntilEnd(); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + public function testReadUntilEndResponse(): void + { + $data = uniqid(); + $this->base->returns(['readUntilEnd' => $data]); + + $actual = $this->fixture->readUntilEnd(); + + assertThat($actual, equals($data)); + } + + public function testWriteCallsBase(): void + { + $data = uniqid(); + + $this->fixture->write($data); + + verify($this->base, 'write')->wasCalledOnce(); + verify($this->base, 'write')->received($data); + } + + public function testWriteRestoresPreviousPosition(): void + { + $this->fixture->write('foobar'); + $this->fixture->write(uniqid()); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 6, SEEK_SET); + } + + /** + * @doesNotPerformAssertions + */ + public function testWriteChecksPosition(): void + { + $this->fixture->write(uniqid()); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + public function testWriteResponse(): void + { + $bytes = rand(1, 10); + $this->base->returns(['write' => $bytes]); + + $actual = $this->fixture->write(uniqid()); + + assertThat($actual, equals($bytes)); + } + + public function testTruncateCallsBase(): void + { + $bytes = rand(1, 10); + + $this->fixture->truncate($bytes); + + verify($this->base, 'truncate')->wasCalledOnce(); + verify($this->base, 'truncate')->received($bytes); + } + + public function testTruncateRestoresPreviousPosition(): void + { + $this->fixture->read(3); + $this->fixture->truncate(6); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 3, SEEK_SET); + } + + /** + * @doesNotPerformAssertions + */ + public function testTruncateDoesNotCheckPosition(): void + { + $this->fixture->truncate(rand(1, 10)); + + // truncate does not move the pointer + verify($this->content, 'bytesRead')->wasNeverCalled(); + } + + public function testTruncateResponse(): void + { + $response = (bool) rand(0, 1); + $this->base->returns(['truncate' => $response]); + + $actual = $this->fixture->truncate(rand(1, 10)); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testEofCallsBase(): void + { + $this->fixture->eof(); + + verify($this->base, 'eof')->wasCalledOnce(); + verify($this->base, 'eof')->receivedNothing(); + } + + public function testEofRestoresPreviousPosition(): void + { + $this->fixture->read(3); + $this->fixture->eof(); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 3, SEEK_SET); + } + + /** + * @doesNotPerformAssertions + */ + public function testEofDoesNotCheckPosition(): void + { + $this->fixture->eof(); + + // eof does not move the pointer + verify($this->content, 'bytesRead')->wasNeverCalled(); + } + + public function testEofResponse(): void + { + $response = (bool) rand(0, 1); + $this->base->returns(['eof' => $response]); + + $actual = $this->fixture->eof(); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetBytesReadCallsBase(): void + { + $this->fixture->getBytesRead(); + + verify($this->base, 'getBytesRead')->wasCalledOnce(); + verify($this->base, 'getBytesRead')->receivedNothing(); + } + + public function testGetBytesReadRestoresPreviousPosition(): void + { + $this->fixture->read(3); + $this->fixture->getBytesRead(); + + verify($this->content, 'seek')->wasCalled(2); + verify($this->content, 'seek')->receivedOn(1, 0, SEEK_SET); + verify($this->content, 'seek')->receivedOn(2, 3, SEEK_SET); + } + + public function testGetBytesReadResponse(): void + { + $bytes = rand(1, 10); + $this->fixture->read($bytes); + + $actual = $this->fixture->getBytesRead(); + + assertThat($actual, equals($bytes)); + } + + public function testSeekCallsBase(): void + { + $offset = rand(1, 10); + $whence = rand(1, 10); + + $this->fixture->seek($offset, $whence); + + verify($this->base, 'seek')->wasCalledOnce(); + verify($this->base, 'seek')->received($offset, $whence); + } + + /** + * @param int[] $expected + * + * @dataProvider sampleSeeks + */ + public function testSeekCallsContentSeek(int $offset, int $whence, array $expected): void + { + $this->base->returns(['seek' => (bool) rand(0, 1)]); + + $this->fixture->seek($offset, $whence); + + verify($this->content, 'seek')->wasCalledOnce(); + verify($this->content, 'seek')->received(...$expected); + } + + /** + * @return mixed[] + */ + public function sampleSeeks(): array + { + $offset = rand(); + + return [ + 'SEEK_CUR' => [ + 'offset' => $offset, + 'whence' => SEEK_CUR, + 'expected' => [0, SEEK_SET], + ], + 'SEEK_END' => [ + 'offset' => $offset, + 'whence' => SEEK_END, + 'expected' => [0, SEEK_SET], + ], + ]; + } + + /** + * @doesNotPerformAssertions + */ + public function testSeekDoesNotCallContentSeek(): void + { + $this->base->returns(['seek' => (bool) rand(0, 1)]); + + $this->fixture->seek(rand(1, 10), SEEK_SET); + + verify($this->content, 'seek')->wasNeverCalled(); + } + + /** + * @doesNotPerformAssertions + */ + public function testSeekChecksPosition(): void + { + $this->fixture->seek(rand(1, 10), SEEK_SET); + + verify($this->content, 'bytesRead')->wasCalledOnce(); + verify($this->content, 'bytesRead')->receivedNothing(); + } + + public function testSeekResponse(): void + { + $response = (bool) rand(0, 1); + $this->base->returns(['seek' => $response]); + + $actual = $this->fixture->seek(rand(1, 10), SEEK_SET); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testSizeCallsBase(): void + { + $this->fixture->size(); + + verify($this->base, 'size')->wasCalledOnce(); + verify($this->base, 'size')->receivedNothing(); + } + + public function testSizeResponse(): void + { + $size = rand(1, 10); + $this->base->returns(['size' => $size]); + + $actual = $this->fixture->size(); + + assertThat($actual, equals($size)); + } + + public function testLockCallsBase(): void + { + $resource = new vfsStreamWrapper(); + $operation = rand(); + + $this->fixture->lock($resource, $operation); + + verify($this->base, 'lock')->wasCalledOnce(); + verify($this->base, 'lock')->received($resource, $operation); + } + + public function testLockResponse(): void + { + $resource = new vfsStreamWrapper(); + $response = (bool) rand(0, 1); + $this->base->returns(['lock' => $response]); + + $actual = $this->fixture->lock($resource, rand()); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetTypeCallsBase(): void + { + $this->fixture->getType(); + + verify($this->base, 'getType')->wasCalledOnce(); + verify($this->base, 'getType')->receivedNothing(); + } + + public function testGetTypeResponse(): void + { + $type = rand(1, 10); + $this->base->returns(['getType' => $type]); + + $actual = $this->fixture->getType(); + + assertThat($actual, equals($type)); + } + + /** + * @doesNotPerformAssertions + */ + public function testFilemtimeCallsBase(): void + { + $this->fixture->filemtime(); + + verify($this->base, 'filemtime')->wasCalledOnce(); + verify($this->base, 'filemtime')->receivedNothing(); + } + + public function testFilemtimeResponse(): void + { + $time = rand(1, 10); + $this->base->returns(['filemtime' => $time]); + + $actual = $this->fixture->filemtime(); + + assertThat($actual, equals($time)); + } + + /** + * @doesNotPerformAssertions + */ + public function testFileatimeCallsBase(): void + { + $this->fixture->fileatime(); + + verify($this->base, 'fileatime')->wasCalledOnce(); + verify($this->base, 'fileatime')->receivedNothing(); + } + + public function testFileatimeResponse(): void + { + $time = rand(1, 10); + $this->base->returns(['fileatime' => $time]); + + $actual = $this->fixture->fileatime(); + + assertThat($actual, equals($time)); + } + + /** + * @doesNotPerformAssertions + */ + public function testFilectimeCallsBase(): void + { + $this->fixture->filectime(); + + verify($this->base, 'filectime')->wasCalledOnce(); + verify($this->base, 'filectime')->receivedNothing(); + } + + public function testFilectimeResponse(): void + { + $time = rand(1, 10); + $this->base->returns(['filectime' => $time]); + + $actual = $this->fixture->filectime(); + + assertThat($actual, equals($time)); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetPermissionsCallsBase(): void + { + $this->fixture->getPermissions(); + + verify($this->base, 'getPermissions')->wasCalledOnce(); + verify($this->base, 'getPermissions')->receivedNothing(); + } + + public function testGetPermissionsResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['getPermissions' => $response]); + + $actual = $this->fixture->getPermissions(); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetUserCallsBase(): void + { + $this->fixture->getUser(); + + verify($this->base, 'getUser')->wasCalledOnce(); + verify($this->base, 'getUser')->receivedNothing(); + } + + public function testGetUserResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['getUser' => $response]); + + $actual = $this->fixture->getUser(); + + assertThat($actual, equals($response)); + } + + /** + * @doesNotPerformAssertions + */ + public function testGetGroupCallsBase(): void + { + $this->fixture->getGroup(); + + verify($this->base, 'getGroup')->wasCalledOnce(); + verify($this->base, 'getGroup')->receivedNothing(); + } + + public function testGetGroupResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['getGroup' => $response]); + + $actual = $this->fixture->getGroup(); + + assertThat($actual, equals($response)); + } + + public function testIsReadableCallsBase(): void + { + $user = rand(); + $group = rand(); + + $this->fixture->isReadable($user, $group); + + verify($this->base, 'isReadable')->wasCalledOnce(); + verify($this->base, 'isReadable')->received($user, $group); + } + + public function testIsReadableResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['isReadable' => $response]); + + $actual = $this->fixture->isReadable(rand(), rand()); + + assertThat($actual, equals($response)); + } + + public function testIsWritableCallsBase(): void + { + $user = rand(); + $group = rand(); + + $this->fixture->isWritable($user, $group); + + verify($this->base, 'isWritable')->wasCalledOnce(); + verify($this->base, 'isWritable')->received($user, $group); + } + + public function testIsWritableResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['isWritable' => $response]); + + $actual = $this->fixture->isWritable(rand(), rand()); + + assertThat($actual, equals($response)); + } + + public function testIsExecutableCallsBase(): void + { + $user = rand(); + $group = rand(); + + $this->fixture->isExecutable($user, $group); + + verify($this->base, 'isExecutable')->wasCalledOnce(); + verify($this->base, 'isExecutable')->received($user, $group); + } + + public function testIsExecutableResponse(): void + { + $response = rand(1, 10); + $this->base->returns(['isExecutable' => $response]); + + $actual = $this->fixture->isExecutable(rand(), rand()); + + assertThat($actual, equals($response)); + } +} diff --git a/tests/phpunit/vfsStreamFileTestCase.php b/tests/phpunit/vfsStreamFileTestCase.php index 7e0e85ea..31d555ec 100644 --- a/tests/phpunit/vfsStreamFileTestCase.php +++ b/tests/phpunit/vfsStreamFileTestCase.php @@ -13,6 +13,7 @@ use bovigo\callmap\NewInstance; use bovigo\vfs\content\FileContent; +use bovigo\vfs\content\StringBasedFileContent; use bovigo\vfs\vfsStream; use bovigo\vfs\vfsStreamContent; use bovigo\vfs\vfsStreamException; @@ -28,6 +29,7 @@ use function bovigo\assert\assertTrue; use function bovigo\assert\expect; use function bovigo\assert\predicate\equals; +use function uniqid; /** * Test for bovigo\vfs\vfsStreamFile. @@ -487,4 +489,17 @@ public function withContentThrowsInvalidArgumentExceptionWhenContentIsNoStringAn }) ->throws(InvalidArgumentException::class); } + + /** + * @test + */ + public function getContentObject(): void + { + $content = new StringBasedFileContent(uniqid()); + $this->file->setContent($content); + + $actual = $this->file->getContentObject(); + + assertThat($content, equals($actual)); + } } diff --git a/tests/phpunit/vfsStreamWrapperTestCase.php b/tests/phpunit/vfsStreamWrapperTestCase.php index 12e59ee5..e2fa370b 100644 --- a/tests/phpunit/vfsStreamWrapperTestCase.php +++ b/tests/phpunit/vfsStreamWrapperTestCase.php @@ -46,8 +46,10 @@ use function fileperms; use function filesize; use function fopen; +use function fread; use function fstat; use function ftruncate; +use function fwrite; use function is_executable; use function is_readable; use function is_writable; @@ -57,6 +59,7 @@ use function stripos; use function time; use function touch; +use function uniqid; use function unlink; /** @@ -862,4 +865,43 @@ public function fileCopy(): void assertTrue($this->root->hasChild('baz3')); assertThat($baz3URL, isNotEqualTo($this->fileInSubdir->url())); } + + /** + * @test + */ + public function multipleReadsOnSameFileHaveDifferentPointers(): void + { + $content = uniqid(); + $this->fileInSubdir->setContent($content); + + $fp1 = fopen($this->fileInSubdir->url(), 'rb'); + $fp2 = fopen($this->fileInSubdir->url(), 'rb'); + + assertThat(fread($fp1, 4096), equals($content)); + assertThat(fread($fp2, 4096), equals($content)); + + fclose($fp1); + fclose($fp2); + } + + /** + * @test + */ + public function multipleWritesOnSameFileHaveDifferentPointers(): void + { + $contentA = uniqid('a'); + $contentB = uniqid('b'); + $url = $this->fileInSubdir->url(); + + $fp1 = fopen($url, 'wb'); + $fp2 = fopen($url, 'wb'); + + fwrite($fp1, $contentA . $contentA); + fwrite($fp2, $contentB); + + fclose($fp1); + fclose($fp2); + + assertThat(file_get_contents($url), equals($contentB . $contentA)); + } }