diff --git a/tests/Unit/Domain/Account/Models/AccountHistoryViewTest.php b/tests/Unit/Domain/Account/Models/AccountHistoryViewTest.php new file mode 100644 index 000000000..409546823 --- /dev/null +++ b/tests/Unit/Domain/Account/Models/AccountHistoryViewTest.php @@ -0,0 +1,100 @@ +. + */ + +namespace SP\Tests\Unit\Domain\Account\Models; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use SP\Domain\Account\Models\AccountHistoryView; +use SP\Tests\Support\UnitaryTestCase; + +/** + * AccountHistoryView enriches a plain AccountHistory row with the owner's, the group's and the + * editor's names for the history detail screen -- AccountHistory's own columns are already + * covered by AccountHistoryTest, so this covers only the four accessors this subclass adds. A + * getter reading the wrong column here would put one person's name on another's history entry + * without anything failing. + */ +#[Group('unitary')] +class AccountHistoryViewTest extends UnitaryTestCase +{ + /** + * The enriched columns, with a distinct value per column so a swapped getter shows up + * immediately. + * + * @return array + */ + private const ROW = [ + 'userName' => 'Alice Example', + 'userGroupName' => 'Admins', + 'userEditName' => 'Bob Example', + 'userEditLogin' => 'bob-login', + ]; + + #[Test] + #[DataProvider('accessorProvider')] + public function eachAccessorReadsItsOwnColumn(string $accessor, string $column): void + { + self::assertSame(self::ROW[$column], (new AccountHistoryView(self::ROW))->{$accessor}()); + } + + /** + * @return array + */ + public static function accessorProvider(): array + { + $accessors = [ + 'getUserName' => 'userName', + 'getUserGroupName' => 'userGroupName', + 'getUserEditName' => 'userEditName', + 'getUserEditLogin' => 'userEditLogin', + ]; + + $cases = []; + + foreach ($accessors as $accessor => $column) { + $cases[$accessor] = [$accessor, $column]; + } + + return $cases; + } + + /** + * These four columns are nullable like the rest of the row, and a view built from an + * incomplete result (e.g. a history entry whose editor was later deleted) must read as + * nothing rather than raising. + */ + #[Test] + public function anEmptyRowReadsAsNothing(): void + { + $view = new AccountHistoryView(); + + self::assertNull($view->getUserName()); + self::assertNull($view->getUserGroupName()); + self::assertNull($view->getUserEditName()); + self::assertNull($view->getUserEditLogin()); + } +} diff --git a/tests/Unit/Domain/Common/Adapters/SerdeTest.php b/tests/Unit/Domain/Common/Adapters/SerdeTest.php index 03d0d6364..4028f24d6 100644 --- a/tests/Unit/Domain/Common/Adapters/SerdeTest.php +++ b/tests/Unit/Domain/Common/Adapters/SerdeTest.php @@ -241,6 +241,41 @@ public function testDeserializeObjectFromJsonIgnoresPropertiesTheClassNoLongerHa $this->assertSame(42, $out->id); $this->assertSame('a label', $out->label); } + + /** + * serializeObjectToJson() reflects over every property and hands the lot to json_encode() + * with JSON_THROW_ON_ERROR -- a value json_encode() can never represent (a non-finite float, + * here) must come back as this application's own exception rather than json_decode()'s + * JsonException escaping raw, the same contract every other serialize/deserialize pair here + * keeps. This is what a preset or plugin blob that happened to compute NAN or INF would hit. + * + * @throws SPException + */ + public function testSerializeObjectToJsonWrapsAJsonEncodingFailure() + { + $subject = new SerdeSerializeObjectToJsonTestSubject(); + $subject->value = NAN; + + $this->expectException(SPException::class); + $this->expectExceptionMessage('Inf and NaN cannot be JSON encoded'); + + Serde::serializeObjectToJson($subject); + } + + /** + * deserializeObjectFromJson() decodes with JSON_THROW_ON_ERROR before it ever reaches for + * reflection, so malformed JSON -- a truncated cache write, a corrupted stored blob -- must + * surface the same way: this application's own exception, not json_decode()'s JsonException. + * + * @throws SPException + */ + public function testDeserializeObjectFromJsonWrapsAJsonDecodingFailure() + { + $this->expectException(SPException::class); + $this->expectExceptionMessage('Syntax error'); + + Serde::deserializeObjectFromJson('{"id":', SerdeDeserializeObjectFromJsonTestSubject::class); + } } /** @@ -254,3 +289,14 @@ final class SerdeDeserializeObjectFromJsonTestSubject public int $id; public string $label; } + +/** + * A minimal target for Serde::serializeObjectToJson(), holding a value the caller can set to + * something json_encode() refuses (NAN, INF) without depending on a specific domain type. + * + * @see SerdeTest::testSerializeObjectToJsonWrapsAJsonEncodingFailure() + */ +final class SerdeSerializeObjectToJsonTestSubject +{ + public float $value = 0.0; +} diff --git a/tests/Unit/Infrastructure/Adapter/In/Web/DataGrid/DataGridTest.php b/tests/Unit/Infrastructure/Adapter/In/Web/DataGrid/DataGridTest.php index d5773dd8a..9fe3ecd9b 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Web/DataGrid/DataGridTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Web/DataGrid/DataGridTest.php @@ -213,6 +213,61 @@ public function aMissingTemplateIsNotSet() self::assertNull($grid->getDataActionsTemplate()); } + /** + * setDataHeaderTemplate() has its own try/catch around checkTemplate(), separate from the + * actions, pager and row template setters -- each catches the same FileNotFoundException + * independently, so each needs its own missing-template case to be exercised. This one + * reports through processException() rather than logger(); either way the setter must not + * let the exception escape, or building a grid with one bad template name would take the + * whole page down instead of just rendering without that section. + * + * @throws Exception + */ + #[Test] + public function aMissingHeaderTemplateIsNotSet() + { + $grid = $this->buildGrid(); + + $result = $grid->setDataHeaderTemplate('no-such-template'); + + self::assertSame($grid, $result); + } + + /** + * Same degrade-and-continue contract as the header template, for the paginator's own + * template setter -- setDataPagerTemplate() reports the missing template through logger() + * rather than processException(), a second, independent catch site. + * + * @throws Exception + */ + #[Test] + public function aMissingPagerTemplateIsNotSet() + { + $grid = $this->buildGrid(); + + $result = $grid->setDataPagerTemplate('no-such-template'); + + self::assertSame($grid, $result); + self::assertNull($grid->getDataPagerTemplate()); + } + + /** + * Same degrade-and-continue contract again, for the per-row template setter -- a third, + * independent catch site, back to reporting through processException(). + * + * @throws Exception + */ + #[Test] + public function aMissingRowTemplateIsNotSet() + { + $grid = $this->buildGrid(); + + $result = $grid->setDataRowTemplate('no-such-template'); + + self::assertSame($grid, $result); + self::assertNull($grid->getDataRowTemplate()); + } + /** * A template that does exist is resolved to its full path and kept, so the screen renders * that section instead of silently skipping it. Covers both branches of the template path diff --git a/tests/Unit/Infrastructure/File/FileHandlerTest.php b/tests/Unit/Infrastructure/File/FileHandlerTest.php index c5d2ec595..0e9bc7aa5 100644 --- a/tests/Unit/Infrastructure/File/FileHandlerTest.php +++ b/tests/Unit/Infrastructure/File/FileHandlerTest.php @@ -353,6 +353,80 @@ public function testGetFileTimeThrowsWhenTheFileIsMissing(): void $handler->getFileTime(); } + /** + * save() must not report a write as done when it never got the lock in the first place -- + * otherwise a caller believing its data was persisted could be looking at whatever the file + * held before. flock() itself has no portable, permission-based way to make it fail (it + * doesn't care whether the handle is read-only), and forcing a real lock conflict would block + * rather than fail, since save() calls flock(LOCK_EX) without LOCK_NB. A stream wrapper is the + * only way to reach this without contriving something that hangs the test instead. + * + * @throws FileException + */ + public function testSaveThrowsWhenTheLockCannotBeObtained(): void + { + self::registerFlockFailureStreamWrapper(); + + $handler = new FileHandler('sp-flock-failure://obtain/' . uniqid(), 'c+'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage('Unable to obtain a lock'); + + $handler->save('data'); + } + + /** + * The matching failure on the way out: the lock is obtained and the data is written, but the + * unlock itself fails. That must surface too, rather than leaving the caller believing the + * save completed cleanly while the file is left locked. + * + * @throws FileException + */ + public function testSaveThrowsWhenTheLockCannotBeReleased(): void + { + self::registerFlockFailureStreamWrapper(); + + $handler = new FileHandler('sp-flock-failure://release/' . uniqid(), 'c+'); + + $this->expectException(FileException::class); + $this->expectExceptionMessage('Unable to release a lock'); + + $handler->save('data'); + } + + private static function registerFlockFailureStreamWrapper(): void + { + if (!in_array('sp-flock-failure', stream_get_wrappers(), true)) { + stream_wrapper_register('sp-flock-failure', FileHandlerFlockFailureStreamWrapper::class); + } + } + + /** + * delete() must report a failed unlink() as this application's own exception rather than + * treating the file as gone. A directory swapped in for the file after opening it is what + * reaches this reliably: unlink() always refuses a directory (EISDIR), regardless of + * permissions or which user runs the test -- removing write access would not, if the suite + * happens to run as root. + * + * @throws FileException + */ + public function testDeleteThrowsWhenUnlinkFails(): void + { + file_put_contents($this->file, 'x'); + $handler = new FileHandler($this->file); + + unlink($this->file); + mkdir($this->file); + + $this->expectException(FileException::class); + + try { + $handler->delete(); + } finally { + rmdir($this->file); + } + } + /** * The stat cache is what makes a file just written still look empty; clearing it is fluent, so * it chains in front of the read that needs the fresh size. @@ -520,3 +594,121 @@ public function testGetFileTimeFallsBackToZeroForAFalsyModificationTime(): void self::assertSame(0, (new FileHandler($this->file))->getFileTime()); } } + +/** + * An in-memory stream wrapper that fails exactly one flock() operation, chosen by the URL host + * ("obtain" or "release"), and otherwise behaves like an ordinary read/write file. This exists + * solely to reach FileHandler::lock()/unlock()'s error paths (see FileHandlerTest's two + * testSaveThrowsWhenTheLockCannot* tests): a real filesystem's flock() has no permission-based + * way to refuse a lock, and forcing an actual conflict would block instead of failing, since + * save() calls flock() without LOCK_NB. + */ +final class FileHandlerFlockFailureStreamWrapper +{ + private const FAIL_OBTAIN = 'obtain'; + private const FAIL_RELEASE = 'release'; + + /** @var resource|null */ + public $context; + + private string $data = ''; + private int $position = 0; + private string $failing = ''; + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + $this->failing = (string)parse_url($path, PHP_URL_HOST); + + return true; + } + + public function stream_read(int $count): string + { + $chunk = substr($this->data, $this->position, $count); + $this->position += strlen($chunk); + + return $chunk; + } + + public function stream_write(string $data): int + { + $this->data = substr_replace($this->data, $data, $this->position, strlen($data)); + $this->position += strlen($data); + + return strlen($data); + } + + public function stream_eof(): bool + { + return $this->position >= strlen($this->data); + } + + public function stream_tell(): int + { + return $this->position; + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + $base = match ($whence) { + SEEK_CUR => $this->position, + SEEK_END => strlen($this->data), + default => 0, + }; + $this->position = $base + $offset; + + return true; + } + + public function stream_truncate(int $newSize): bool + { + $this->data = substr($this->data, 0, $newSize); + $this->position = min($this->position, $newSize); + + return true; + } + + public function stream_flush(): bool + { + return true; + } + + /** + * @return array + */ + public function stream_stat(): array + { + return []; + } + + /** + * @return array + */ + public function url_stat(string $path, int $flags): array + { + return [ + 'dev' => 0, + 'ino' => 0, + 'mode' => 0100644, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => strlen($this->data), + 'atime' => 0, + 'mtime' => 0, + 'ctime' => 0, + 'blksize' => -1, + 'blocks' => -1, + ]; + } + + public function stream_lock(int $operation): bool + { + return match ($operation & 3) { + LOCK_EX => $this->failing !== self::FAIL_OBTAIN, + LOCK_UN => $this->failing !== self::FAIL_RELEASE, + default => true, + }; + } +} diff --git a/tests/Unit/Infrastructure/Http/Dtos/JsonMessageTest.php b/tests/Unit/Infrastructure/Http/Dtos/JsonMessageTest.php new file mode 100644 index 000000000..c8b65686c --- /dev/null +++ b/tests/Unit/Infrastructure/Http/Dtos/JsonMessageTest.php @@ -0,0 +1,83 @@ +. + */ + +namespace SP\Tests\Unit\Infrastructure\Http\Dtos; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use SP\Infrastructure\Http\Dtos\JsonMessage; +use SP\Tests\Support\UnitaryTestCase; + +/** + * Every JSON response body is built from one of these -- a status, a description, a payload and a + * list of messages. The description and the messages are both run through gettext on the way in, + * the same translation every other user-facing string in the application gets, rather than being + * translated at render time; each setter is fluent, which is what lets a controller build the + * whole response in one chain. + */ +#[Group('unitary')] +class JsonMessageTest extends UnitaryTestCase +{ + /** + * setDescription() passes its argument through __() rather than storing it verbatim. Nothing + * in the test suite's locale catalog matches this literal, so gettext hands it back unchanged + * -- which is enough to show the value went through translation and came out the other side, + * without depending on a specific .mo entry. The fluent return is what a controller relies on + * to keep chaining setters. + */ + #[Test] + public function setDescriptionStoresTheTranslatedDescription(): void + { + $message = new JsonMessage(); + + $result = $message->setDescription('a description nothing in the catalog translates'); + + self::assertSame($message, $result); + self::assertSame( + 'a description nothing in the catalog translates', + $message->getJsonArray()['description'] + ); + } + + /** + * setMessages() maps __() over every entry individually rather than translating the array as + * a whole -- a caller handing over several distinct notices must get each one translated, in + * the same order, not lose them to a single pass over the outer value. + */ + #[Test] + public function setMessagesTranslatesEveryEntryInOrder(): void + { + $message = new JsonMessage(); + + $result = $message->setMessages(['first untranslated notice', 'second untranslated notice']); + + self::assertSame($message, $result); + self::assertSame( + ['first untranslated notice', 'second untranslated notice'], + $message->getJsonArray()['messages'] + ); + } +}