From 94f1c3facfd4146215954ea71000056c2cb2fd97 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Wed, 7 Jan 2026 15:10:37 -0600 Subject: [PATCH 1/3] problem-details --- src/Core/Exceptions/Forbidden.php | 73 +++++++ src/Core/Exceptions/Unauthorized.php | 58 +++++ src/Core/ProblemDetails/ProblemDetails.php | 239 +++++++++++++++++++++ src/Core/ProblemDetails/ProblemType.php | 203 +++++++++++++++++ tests/Core/Exceptions/ForbiddenTest.php | 192 +++++++++++++++++ tests/Core/Exceptions/UnauthorizedTest.php | 126 +++++++++++ versionlog.md | 9 +- 7 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 src/Core/Exceptions/Forbidden.php create mode 100644 src/Core/Exceptions/Unauthorized.php create mode 100644 src/Core/ProblemDetails/ProblemDetails.php create mode 100644 src/Core/ProblemDetails/ProblemType.php create mode 100644 tests/Core/Exceptions/ForbiddenTest.php create mode 100644 tests/Core/Exceptions/UnauthorizedTest.php diff --git a/src/Core/Exceptions/Forbidden.php b/src/Core/Exceptions/Forbidden.php new file mode 100644 index 0000000..c7934c5 --- /dev/null +++ b/src/Core/Exceptions/Forbidden.php @@ -0,0 +1,73 @@ +hasPermission('admin.edit')) { + * throw new Forbidden('You do not have permission to edit', 'User', 'admin.edit'); + * } + * + * // Throw when accessing forbidden resource + * if (!$this->canAccessResource($user, $document)) { + * throw new Forbidden('Access denied to document', 'Document #' . $document->id); + * } + * ``` + */ +class Forbidden extends Exception +{ + private ?string $resource; + private ?string $permission; + + /** + * @param string $message The error message + * @param string|null $resource The resource that was forbidden (e.g., 'User Profile', 'Document #123') + * @param string|null $permission The permission that was lacking (e.g., 'admin.edit', 'document.read') + * @param int $code The exception code (defaults to 403) + * @param Throwable|null $previous Previous exception for chaining + */ + public function __construct( + string $message = 'Access forbidden', + ?string $resource = null, + ?string $permission = null, + int $code = 403, + ?Throwable $previous = null + ) { + $this->resource = $resource; + $this->permission = $permission; + parent::__construct( $message, $code, $previous ); + } + + /** + * Get the resource that was forbidden + * + * @return string|null + */ + public function getResource(): ?string + { + return $this->resource; + } + + /** + * Get the permission that was lacking + * + * @return string|null + */ + public function getPermission(): ?string + { + return $this->permission; + } +} \ No newline at end of file diff --git a/src/Core/Exceptions/Unauthorized.php b/src/Core/Exceptions/Unauthorized.php new file mode 100644 index 0000000..6039198 --- /dev/null +++ b/src/Core/Exceptions/Unauthorized.php @@ -0,0 +1,58 @@ +hasAuthToken()) { + * throw new Unauthorized('Authentication required'); + * } + * + * // Throw with realm for HTTP Basic Auth + * if (!$this->isValidCredentials($credentials)) { + * throw new Unauthorized('Invalid credentials', 'Admin Area'); + * } + * ``` + */ +class Unauthorized extends Exception +{ + private ?string $realm; + + /** + * @param string $message The error message + * @param string|null $realm Optional authentication realm (e.g., for WWW-Authenticate header) + * @param int $code The exception code (defaults to 401) + * @param Throwable|null $previous Previous exception for chaining + */ + public function __construct( + string $message = 'Authentication required', + ?string $realm = null, + int $code = 401, + ?Throwable $previous = null + ) { + $this->realm = $realm; + parent::__construct( $message, $code, $previous ); + } + + /** + * Get the authentication realm if specified + * + * @return string|null + */ + public function getRealm(): ?string + { + return $this->realm; + } +} \ No newline at end of file diff --git a/src/Core/ProblemDetails/ProblemDetails.php b/src/Core/ProblemDetails/ProblemDetails.php new file mode 100644 index 0000000..64326ae --- /dev/null +++ b/src/Core/ProblemDetails/ProblemDetails.php @@ -0,0 +1,239 @@ + [ + * 'email' => 'Invalid email format', + * 'password' => 'Must be at least 8 characters' + * ] + * ] + * ); + * + * // Convert to JSON for API response + * $json = json_encode($problem); + * ``` + */ +class ProblemDetails implements JsonSerializable +{ + private string $type; + private string $title; + private int $status; + private ?string $detail; + private ?string $instance; + private array $extensions; + + /** + * Create a new Problem Details instance. + * + * @param string $type URI reference identifying the problem type + * @param string $title Short, human-readable summary of the problem + * @param int $status HTTP status code for this occurrence + * @param string|null $detail Human-readable explanation specific to this occurrence + * @param string|null $instance URI reference identifying the specific occurrence + * @param array $extensions Additional fields to include in the response + */ + public function __construct( + string $type, + string $title, + int $status, + ?string $detail = null, + ?string $instance = null, + array $extensions = [] + ) { + $this->type = $type; + $this->title = $title; + $this->status = $status; + $this->detail = $detail; + $this->instance = $instance; + $this->extensions = $extensions; + + $this->validate(); + } + + /** + * Validate the problem details fields. + * + * @throws \InvalidArgumentException If required fields are invalid + */ + private function validate(): void + { + if (empty($this->type)) { + throw new \InvalidArgumentException('Problem type cannot be empty'); + } + + if (empty($this->title)) { + throw new \InvalidArgumentException('Problem title cannot be empty'); + } + + if ($this->status < 400 || $this->status > 599) { + throw new \InvalidArgumentException('Problem status must be a 4xx or 5xx HTTP status code'); + } + + // Validate extension field names (must be valid for JSON and XML) + foreach (array_keys($this->extensions) as $key) { + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { + throw new \InvalidArgumentException("Invalid extension field name: $key"); + } + + // Reserved field names cannot be used as extensions + if (in_array($key, ['type', 'title', 'status', 'detail', 'instance'])) { + throw new \InvalidArgumentException("Reserved field name cannot be used as extension: $key"); + } + } + } + + /** + * Get the problem type URI. + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get the problem title. + */ + public function getTitle(): string + { + return $this->title; + } + + /** + * Get the HTTP status code. + */ + public function getStatus(): int + { + return $this->status; + } + + /** + * Get the problem detail. + */ + public function getDetail(): ?string + { + return $this->detail; + } + + /** + * Get the problem instance URI. + */ + public function getInstance(): ?string + { + return $this->instance; + } + + /** + * Get extension fields. + */ + public function getExtensions(): array + { + return $this->extensions; + } + + /** + * Get a specific extension field value. + * + * @param string $key The extension field name + * @return mixed|null The field value or null if not set + */ + public function getExtension(string $key): mixed + { + return $this->extensions[$key] ?? null; + } + + /** + * Convert to array representation. + * + * @return array The problem details as an associative array + */ + public function toArray(): array + { + $data = [ + 'type' => $this->type, + 'title' => $this->title, + 'status' => $this->status, + ]; + + if ($this->detail !== null) { + $data['detail'] = $this->detail; + } + + if ($this->instance !== null) { + $data['instance'] = $this->instance; + } + + // Add extension fields + foreach ($this->extensions as $key => $value) { + $data[$key] = $value; + } + + return $data; + } + + /** + * Specify data which should be serialized to JSON. + * + * @return array Data to be JSON encoded + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Create a ProblemDetails instance from an array. + * + * @param array $data The problem details data + * @return self + */ + public static function fromArray(array $data): self + { + $type = $data['type'] ?? ''; + $title = $data['title'] ?? ''; + $status = $data['status'] ?? 500; + $detail = $data['detail'] ?? null; + $instance = $data['instance'] ?? null; + + // Extract extensions (any fields not in the standard set) + $standardFields = ['type', 'title', 'status', 'detail', 'instance']; + $extensions = array_diff_key($data, array_flip($standardFields)); + + return new self($type, $title, $status, $detail, $instance, $extensions); + } +} \ No newline at end of file diff --git a/src/Core/ProblemDetails/ProblemType.php b/src/Core/ProblemDetails/ProblemType.php new file mode 100644 index 0000000..4254c2e --- /dev/null +++ b/src/Core/ProblemDetails/ProblemType.php @@ -0,0 +1,203 @@ +value, + * title: 'Validation Failed', + * status: 400, + * detail: 'The email field is required' + * ); + * + * // Or use the enum directly with type hinting + * public function createProblem(ProblemType $type): ProblemDetails { + * return match($type) { + * ProblemType::VALIDATION_ERROR => new ProblemDetails(...), + * ProblemType::NOT_FOUND => new ProblemDetails(...), + * // ... + * }; + * } + * ``` + */ +enum ProblemType: string +{ + /** + * Validation error - Request data failed validation rules. + * Typically includes an 'errors' extension with field-specific messages. + * HTTP Status: 400 Bad Request + */ + case VALIDATION_ERROR = '/errors/validation'; + + /** + * Resource not found - The requested resource does not exist. + * Should include details about what resource was not found. + * HTTP Status: 404 Not Found + */ + case NOT_FOUND = '/errors/not-found'; + + /** + * Authentication required - The request requires authentication. + * User needs to authenticate before accessing the resource. + * HTTP Status: 401 Unauthorized + */ + case AUTHENTICATION_REQUIRED = '/errors/authentication'; + + /** + * Permission denied - Authenticated user lacks required permissions. + * User is authenticated but not authorized for this action. + * HTTP Status: 403 Forbidden + */ + case PERMISSION_DENIED = '/errors/authorization'; + + /** + * Rate limit exceeded - Too many requests from this client. + * May include 'retry_after' extension with seconds to wait. + * HTTP Status: 429 Too Many Requests + */ + case RATE_LIMIT_EXCEEDED = '/errors/rate-limit'; + + /** + * Service unavailable - The service is temporarily unavailable. + * May occur during maintenance or high load. + * HTTP Status: 503 Service Unavailable + */ + case SERVICE_UNAVAILABLE = '/errors/service-unavailable'; + + /** + * Internal server error - An unexpected error occurred. + * Should not expose internal details in production. + * HTTP Status: 500 Internal Server Error + */ + case INTERNAL_ERROR = '/errors/internal'; + + /** + * Bad request - The request is malformed or invalid. + * Generic client error when more specific type doesn't apply. + * HTTP Status: 400 Bad Request + */ + case BAD_REQUEST = '/errors/bad-request'; + + /** + * Conflict - The request conflicts with current state. + * Often used for duplicate resources or version conflicts. + * HTTP Status: 409 Conflict + */ + case CONFLICT = '/errors/conflict'; + + /** + * Method not allowed - HTTP method not supported for this endpoint. + * Should include 'allowed_methods' extension. + * HTTP Status: 405 Method Not Allowed + */ + case METHOD_NOT_ALLOWED = '/errors/method-not-allowed'; + + /** + * Unsupported media type - Request content type not supported. + * Should include 'supported_types' extension. + * HTTP Status: 415 Unsupported Media Type + */ + case UNSUPPORTED_MEDIA_TYPE = '/errors/unsupported-media-type'; + + /** + * Request timeout - The request took too long to process. + * HTTP Status: 408 Request Timeout + */ + case REQUEST_TIMEOUT = '/errors/timeout'; + + /** + * Payload too large - Request body exceeds size limits. + * May include 'max_size' extension. + * HTTP Status: 413 Payload Too Large + */ + case PAYLOAD_TOO_LARGE = '/errors/payload-too-large'; + + /** + * Get the recommended HTTP status code for this problem type. + * + * @return int The HTTP status code + */ + public function getRecommendedStatus(): int + { + return match($this) { + self::VALIDATION_ERROR, self::BAD_REQUEST => 400, + self::AUTHENTICATION_REQUIRED => 401, + self::PERMISSION_DENIED => 403, + self::NOT_FOUND => 404, + self::METHOD_NOT_ALLOWED => 405, + self::REQUEST_TIMEOUT => 408, + self::CONFLICT => 409, + self::PAYLOAD_TOO_LARGE => 413, + self::UNSUPPORTED_MEDIA_TYPE => 415, + self::RATE_LIMIT_EXCEEDED => 429, + self::INTERNAL_ERROR => 500, + self::SERVICE_UNAVAILABLE => 503, + }; + } + + /** + * Get the default title for this problem type. + * + * @return string A human-readable title + */ + public function getDefaultTitle(): string + { + return match($this) { + self::VALIDATION_ERROR => 'Validation Failed', + self::NOT_FOUND => 'Resource Not Found', + self::AUTHENTICATION_REQUIRED => 'Authentication Required', + self::PERMISSION_DENIED => 'Permission Denied', + self::RATE_LIMIT_EXCEEDED => 'Rate Limit Exceeded', + self::SERVICE_UNAVAILABLE => 'Service Unavailable', + self::INTERNAL_ERROR => 'Internal Server Error', + self::BAD_REQUEST => 'Bad Request', + self::CONFLICT => 'Conflict', + self::METHOD_NOT_ALLOWED => 'Method Not Allowed', + self::UNSUPPORTED_MEDIA_TYPE => 'Unsupported Media Type', + self::REQUEST_TIMEOUT => 'Request Timeout', + self::PAYLOAD_TOO_LARGE => 'Payload Too Large', + }; + } + + /** + * Create a ProblemDetails instance from this type. + * + * @param string|null $detail Specific details about this occurrence + * @param string|null $instance URI identifying this specific occurrence + * @param array $extensions Additional fields to include + * @return ProblemDetails + */ + public function toProblemDetails( + ?string $detail = null, + ?string $instance = null, + array $extensions = [] + ): ProblemDetails { + return new ProblemDetails( + type: $this->value, + title: $this->getDefaultTitle(), + status: $this->getRecommendedStatus(), + detail: $detail, + instance: $instance, + extensions: $extensions + ); + } +} \ No newline at end of file diff --git a/tests/Core/Exceptions/ForbiddenTest.php b/tests/Core/Exceptions/ForbiddenTest.php new file mode 100644 index 0000000..b48e885 --- /dev/null +++ b/tests/Core/Exceptions/ForbiddenTest.php @@ -0,0 +1,192 @@ +assertEquals( 'Access forbidden', $exception->getMessage() ); + $this->assertEquals( 403, $exception->getCode() ); + $this->assertNull( $exception->getResource() ); + $this->assertNull( $exception->getPermission() ); + $this->assertNull( $exception->getPrevious() ); + } + + public function testConstructorWithMessage(): void + { + $exception = new Forbidden( 'You do not have access' ); + + $this->assertEquals( 'You do not have access', $exception->getMessage() ); + $this->assertEquals( 403, $exception->getCode() ); + $this->assertNull( $exception->getResource() ); + $this->assertNull( $exception->getPermission() ); + } + + public function testConstructorWithResource(): void + { + $exception = new Forbidden( 'Access denied', 'Admin Dashboard' ); + + $this->assertEquals( 'Access denied', $exception->getMessage() ); + $this->assertEquals( 'Admin Dashboard', $exception->getResource() ); + $this->assertNull( $exception->getPermission() ); + $this->assertEquals( 403, $exception->getCode() ); + } + + public function testConstructorWithResourceAndPermission(): void + { + $exception = new Forbidden( 'Insufficient permissions', 'User Profile', 'user.edit' ); + + $this->assertEquals( 'Insufficient permissions', $exception->getMessage() ); + $this->assertEquals( 'User Profile', $exception->getResource() ); + $this->assertEquals( 'user.edit', $exception->getPermission() ); + $this->assertEquals( 403, $exception->getCode() ); + } + + public function testConstructorWithCustomCode(): void + { + $exception = new Forbidden( 'Custom forbidden', null, null, 999 ); + + $this->assertEquals( 'Custom forbidden', $exception->getMessage() ); + $this->assertNull( $exception->getResource() ); + $this->assertNull( $exception->getPermission() ); + $this->assertEquals( 999, $exception->getCode() ); + } + + public function testConstructorWithPreviousException(): void + { + $previous = new \Exception( 'Previous error' ); + $exception = new Forbidden( 'Forbidden error', null, null, 403, $previous ); + + $this->assertEquals( 'Forbidden error', $exception->getMessage() ); + $this->assertSame( $previous, $exception->getPrevious() ); + } + + public function testConstructorWithAllParameters(): void + { + $previous = new \RuntimeException( 'Runtime error' ); + $exception = new Forbidden( + 'Complete forbidden error', + 'Protected Document', + 'document.delete', + 403, + $previous + ); + + $this->assertEquals( 'Complete forbidden error', $exception->getMessage() ); + $this->assertEquals( 'Protected Document', $exception->getResource() ); + $this->assertEquals( 'document.delete', $exception->getPermission() ); + $this->assertEquals( 403, $exception->getCode() ); + $this->assertSame( $previous, $exception->getPrevious() ); + } + + public function testVariousResources(): void + { + $resources = [ + 'User Account', + 'System Settings', + 'Database Record #123', + 'API Endpoint /admin/users', + 'Configuration File' + ]; + + foreach( $resources as $resource ) + { + $exception = new Forbidden( 'Test', $resource ); + $this->assertEquals( $resource, $exception->getResource() ); + } + } + + public function testVariousPermissions(): void + { + $permissions = [ + 'admin.read', + 'posts.create', + 'users.update', + 'settings.delete', + 'api.execute', + 'system.configure' + ]; + + foreach( $permissions as $permission ) + { + $exception = new Forbidden( 'Test', 'Resource', $permission ); + $this->assertEquals( $permission, $exception->getPermission() ); + } + } + + public function testExceptionThrowable(): void + { + $this->expectException( Forbidden::class ); + $this->expectExceptionMessage( 'Access denied to system' ); + $this->expectExceptionCode( 403 ); + + throw new Forbidden( 'Access denied to system' ); + } + + public function testExceptionWithDetailsThrowable(): void + { + $this->expectException( Forbidden::class ); + + try + { + throw new Forbidden( 'Cannot delete user', 'User #42', 'users.delete' ); + } + catch( Forbidden $e ) + { + $this->assertEquals( 'Cannot delete user', $e->getMessage() ); + $this->assertEquals( 'User #42', $e->getResource() ); + $this->assertEquals( 'users.delete', $e->getPermission() ); + $this->assertEquals( 403, $e->getCode() ); + throw $e; + } + } + + public function testInheritanceFromException(): void + { + $exception = new Forbidden(); + + $this->assertInstanceOf( \Exception::class, $exception ); + $this->assertInstanceOf( \Throwable::class, $exception ); + } + + public function testGetResourceReturnType(): void + { + $exception1 = new Forbidden( 'Test', 'My Resource' ); + $this->assertIsString( $exception1->getResource() ); + + $exception2 = new Forbidden( 'Test' ); + $this->assertNull( $exception2->getResource() ); + } + + public function testGetPermissionReturnType(): void + { + $exception1 = new Forbidden( 'Test', 'Resource', 'permission.name' ); + $this->assertIsString( $exception1->getPermission() ); + + $exception2 = new Forbidden( 'Test' ); + $this->assertNull( $exception2->getPermission() ); + } + + public function testResourceWithoutPermission(): void + { + $exception = new Forbidden( 'Forbidden', 'Secret File' ); + + $this->assertEquals( 'Secret File', $exception->getResource() ); + $this->assertNull( $exception->getPermission() ); + } + + public function testPermissionWithNullResource(): void + { + // Test that permission can be set even with null resource + $exception = new Forbidden( 'No permission', null, 'admin.access' ); + + $this->assertNull( $exception->getResource() ); + $this->assertEquals( 'admin.access', $exception->getPermission() ); + } +} \ No newline at end of file diff --git a/tests/Core/Exceptions/UnauthorizedTest.php b/tests/Core/Exceptions/UnauthorizedTest.php new file mode 100644 index 0000000..b4b0a0d --- /dev/null +++ b/tests/Core/Exceptions/UnauthorizedTest.php @@ -0,0 +1,126 @@ +assertEquals( 'Authentication required', $exception->getMessage() ); + $this->assertEquals( 401, $exception->getCode() ); + $this->assertNull( $exception->getRealm() ); + $this->assertNull( $exception->getPrevious() ); + } + + public function testConstructorWithMessage(): void + { + $exception = new Unauthorized( 'Invalid API key' ); + + $this->assertEquals( 'Invalid API key', $exception->getMessage() ); + $this->assertEquals( 401, $exception->getCode() ); + $this->assertNull( $exception->getRealm() ); + } + + public function testConstructorWithRealm(): void + { + $exception = new Unauthorized( 'Authentication failed', 'Admin Panel' ); + + $this->assertEquals( 'Authentication failed', $exception->getMessage() ); + $this->assertEquals( 'Admin Panel', $exception->getRealm() ); + $this->assertEquals( 401, $exception->getCode() ); + } + + public function testConstructorWithCustomCode(): void + { + $exception = new Unauthorized( 'Custom error', null, 999 ); + + $this->assertEquals( 'Custom error', $exception->getMessage() ); + $this->assertNull( $exception->getRealm() ); + $this->assertEquals( 999, $exception->getCode() ); + } + + public function testConstructorWithPreviousException(): void + { + $previous = new \Exception( 'Previous error' ); + $exception = new Unauthorized( 'Auth error', null, 401, $previous ); + + $this->assertEquals( 'Auth error', $exception->getMessage() ); + $this->assertSame( $previous, $exception->getPrevious() ); + } + + public function testConstructorWithAllParameters(): void + { + $previous = new \RuntimeException( 'Runtime error' ); + $exception = new Unauthorized( 'Full auth error', 'Protected Area', 401, $previous ); + + $this->assertEquals( 'Full auth error', $exception->getMessage() ); + $this->assertEquals( 'Protected Area', $exception->getRealm() ); + $this->assertEquals( 401, $exception->getCode() ); + $this->assertSame( $previous, $exception->getPrevious() ); + } + + public function testVariousRealms(): void + { + $realms = [ + 'Basic Auth', + 'Bearer Token', + 'API Key Required', + 'OAuth 2.0', + 'Session Expired' + ]; + + foreach( $realms as $realm ) + { + $exception = new Unauthorized( 'Test message', $realm ); + $this->assertEquals( $realm, $exception->getRealm() ); + } + } + + public function testExceptionThrowable(): void + { + $this->expectException( Unauthorized::class ); + $this->expectExceptionMessage( 'You shall not pass' ); + $this->expectExceptionCode( 401 ); + + throw new Unauthorized( 'You shall not pass' ); + } + + public function testExceptionWithRealmThrowable(): void + { + $this->expectException( Unauthorized::class ); + + try + { + throw new Unauthorized( 'Access denied', 'Secure Zone' ); + } + catch( Unauthorized $e ) + { + $this->assertEquals( 'Access denied', $e->getMessage() ); + $this->assertEquals( 'Secure Zone', $e->getRealm() ); + $this->assertEquals( 401, $e->getCode() ); + throw $e; + } + } + + public function testInheritanceFromException(): void + { + $exception = new Unauthorized(); + + $this->assertInstanceOf( \Exception::class, $exception ); + $this->assertInstanceOf( \Throwable::class, $exception ); + } + + public function testGetRealmReturnType(): void + { + $exception1 = new Unauthorized( 'Test', 'MyRealm' ); + $this->assertIsString( $exception1->getRealm() ); + + $exception2 = new Unauthorized( 'Test' ); + $this->assertNull( $exception2->getRealm() ); + } +} \ No newline at end of file diff --git a/versionlog.md b/versionlog.md index faa2881..64d96c0 100644 --- a/versionlog.md +++ b/versionlog.md @@ -1,9 +1,16 @@ ## 0.8.5 ## 0.8.4 2025-12-11 +* Added system abstractions for improved testability across the framework +* **IClock interface** with `RealClock` and `FrozenClock` implementations - enables instant, deterministic time-based testing (no more sleep!) +* **IRandom interface** with `RealRandom` (cryptographically secure) and `FakeRandom` (predictable) implementations - enables deterministic random value testing +* **IHttpClient/IHttpResponse interfaces** with `RealHttpClient` (curl-based) and `MemoryHttpClient` (in-memory) implementations - enables HTTP testing without external dependencies +* **ISession interface** with `RealSession` ($_SESSION wrapper) and `MemorySession` (in-memory) implementations - enables isolated session testing +* All abstractions fully tested with 122 comprehensive tests +* Time-dependent tests now run 200-250x faster using FrozenClock +* Used by MVC FileCacheStorage, Routing RateLimitStorage, and CMS CsrfToken ## 0.8.3 2025-12-10 - ## 0.8.2 2025-12-10 * Extended IFileSystem with directory operations: `mkdir()`, `rmdir()`, `scandir()`, and `unlink()` for comprehensive file system abstraction * Enables full testability of file-dependent code across the framework From fb595d5b120dddf91d3d4a44d383d9f744be7fab Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Wed, 7 Jan 2026 15:15:21 -0600 Subject: [PATCH 2/3] test --- tests/ProblemDetailsTest.php | 299 +++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 tests/ProblemDetailsTest.php diff --git a/tests/ProblemDetailsTest.php b/tests/ProblemDetailsTest.php new file mode 100644 index 0000000..87e2c87 --- /dev/null +++ b/tests/ProblemDetailsTest.php @@ -0,0 +1,299 @@ +assertEquals('/errors/test', $problem->getType()); + $this->assertEquals('Test Error', $problem->getTitle()); + $this->assertEquals(400, $problem->getStatus()); + $this->assertNull($problem->getDetail()); + $this->assertNull($problem->getInstance()); + $this->assertEmpty($problem->getExtensions()); + } + + /** + * Test creating a problem details instance with all fields. + */ + public function testCreateFullProblemDetails(): void + { + $problem = new ProblemDetails( + type: '/errors/validation', + title: 'Validation Failed', + status: 400, + detail: 'The email field is invalid', + instance: '/api/users/123', + extensions: [ + 'errors' => ['email' => 'Invalid format'], + 'timestamp' => 1234567890 + ] + ); + + $this->assertEquals('/errors/validation', $problem->getType()); + $this->assertEquals('Validation Failed', $problem->getTitle()); + $this->assertEquals(400, $problem->getStatus()); + $this->assertEquals('The email field is invalid', $problem->getDetail()); + $this->assertEquals('/api/users/123', $problem->getInstance()); + $this->assertCount(2, $problem->getExtensions()); + $this->assertEquals('Invalid format', $problem->getExtension('errors')['email']); + $this->assertEquals(1234567890, $problem->getExtension('timestamp')); + } + + /** + * Test JSON serialization. + */ + public function testJsonSerialization(): void + { + $problem = new ProblemDetails( + type: '/errors/not-found', + title: 'Not Found', + status: 404, + detail: 'User not found', + extensions: ['user_id' => 123] + ); + + $json = json_encode($problem); + $decoded = json_decode($json, true); + + $this->assertEquals('/errors/not-found', $decoded['type']); + $this->assertEquals('Not Found', $decoded['title']); + $this->assertEquals(404, $decoded['status']); + $this->assertEquals('User not found', $decoded['detail']); + $this->assertEquals(123, $decoded['user_id']); + $this->assertArrayNotHasKey('instance', $decoded); + } + + /** + * Test toArray method. + */ + public function testToArray(): void + { + $problem = new ProblemDetails( + type: '/errors/auth', + title: 'Authentication Required', + status: 401, + instance: '/api/protected', + extensions: ['realm' => 'api'] + ); + + $array = $problem->toArray(); + + $this->assertIsArray($array); + $this->assertEquals('/errors/auth', $array['type']); + $this->assertEquals('Authentication Required', $array['title']); + $this->assertEquals(401, $array['status']); + $this->assertEquals('/api/protected', $array['instance']); + $this->assertEquals('api', $array['realm']); + $this->assertArrayNotHasKey('detail', $array); + } + + /** + * Test creating from array. + */ + public function testFromArray(): void + { + $data = [ + 'type' => '/errors/test', + 'title' => 'Test Error', + 'status' => 400, + 'detail' => 'Test detail', + 'instance' => '/test', + 'custom_field' => 'custom value' + ]; + + $problem = ProblemDetails::fromArray($data); + + $this->assertEquals('/errors/test', $problem->getType()); + $this->assertEquals('Test Error', $problem->getTitle()); + $this->assertEquals(400, $problem->getStatus()); + $this->assertEquals('Test detail', $problem->getDetail()); + $this->assertEquals('/test', $problem->getInstance()); + $this->assertEquals('custom value', $problem->getExtension('custom_field')); + } + + /** + * Test validation for empty type. + */ + public function testValidationEmptyType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Problem type cannot be empty'); + + new ProblemDetails( + type: '', + title: 'Error', + status: 400 + ); + } + + /** + * Test validation for empty title. + */ + public function testValidationEmptyTitle(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Problem title cannot be empty'); + + new ProblemDetails( + type: '/errors/test', + title: '', + status: 400 + ); + } + + /** + * Test validation for invalid status code. + */ + public function testValidationInvalidStatus(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Problem status must be a 4xx or 5xx HTTP status code'); + + new ProblemDetails( + type: '/errors/test', + title: 'Error', + status: 200 + ); + } + + /** + * Test validation for invalid extension field name. + */ + public function testValidationInvalidExtensionName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid extension field name: 123-invalid'); + + new ProblemDetails( + type: '/errors/test', + title: 'Error', + status: 400, + extensions: ['123-invalid' => 'value'] + ); + } + + /** + * Test validation for reserved extension field name. + */ + public function testValidationReservedExtensionName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Reserved field name cannot be used as extension: type'); + + new ProblemDetails( + type: '/errors/test', + title: 'Error', + status: 400, + extensions: ['type' => 'value'] + ); + } + + /** + * Test ProblemType enum integration. + */ + public function testProblemTypeEnumIntegration(): void + { + $problem = ProblemType::VALIDATION_ERROR->toProblemDetails( + detail: 'Invalid input', + extensions: ['errors' => ['field' => 'error']] + ); + + $this->assertEquals('/errors/validation', $problem->getType()); + $this->assertEquals('Validation Failed', $problem->getTitle()); + $this->assertEquals(400, $problem->getStatus()); + $this->assertEquals('Invalid input', $problem->getDetail()); + $this->assertEquals(['field' => 'error'], $problem->getExtension('errors')); + } + + /** + * Test ProblemType recommended status codes. + */ + public function testProblemTypeStatusCodes(): void + { + $this->assertEquals(400, ProblemType::VALIDATION_ERROR->getRecommendedStatus()); + $this->assertEquals(401, ProblemType::AUTHENTICATION_REQUIRED->getRecommendedStatus()); + $this->assertEquals(403, ProblemType::PERMISSION_DENIED->getRecommendedStatus()); + $this->assertEquals(404, ProblemType::NOT_FOUND->getRecommendedStatus()); + $this->assertEquals(429, ProblemType::RATE_LIMIT_EXCEEDED->getRecommendedStatus()); + $this->assertEquals(500, ProblemType::INTERNAL_ERROR->getRecommendedStatus()); + $this->assertEquals(503, ProblemType::SERVICE_UNAVAILABLE->getRecommendedStatus()); + } + + /** + * Test ProblemType default titles. + */ + public function testProblemTypeDefaultTitles(): void + { + $this->assertEquals('Validation Failed', ProblemType::VALIDATION_ERROR->getDefaultTitle()); + $this->assertEquals('Resource Not Found', ProblemType::NOT_FOUND->getDefaultTitle()); + $this->assertEquals('Authentication Required', ProblemType::AUTHENTICATION_REQUIRED->getDefaultTitle()); + $this->assertEquals('Permission Denied', ProblemType::PERMISSION_DENIED->getDefaultTitle()); + $this->assertEquals('Rate Limit Exceeded', ProblemType::RATE_LIMIT_EXCEEDED->getDefaultTitle()); + $this->assertEquals('Internal Server Error', ProblemType::INTERNAL_ERROR->getDefaultTitle()); + } + + /** + * Test complex nested extensions. + */ + public function testComplexExtensions(): void + { + $problem = new ProblemDetails( + type: '/errors/complex', + title: 'Complex Error', + status: 400, + extensions: [ + 'errors' => [ + 'user' => [ + 'email' => 'Invalid format', + 'name' => 'Too short' + ], + 'address' => [ + 'zip' => 'Invalid code' + ] + ], + 'metadata' => [ + 'request_id' => 'abc123', + 'timestamp' => 1234567890 + ] + ] + ); + + $array = $problem->toArray(); + $this->assertArrayHasKey('errors', $array); + $this->assertArrayHasKey('metadata', $array); + $this->assertEquals('Invalid format', $array['errors']['user']['email']); + $this->assertEquals('abc123', $array['metadata']['request_id']); + } + + /** + * Test that null extension returns null. + */ + public function testGetNonExistentExtension(): void + { + $problem = new ProblemDetails( + type: '/errors/test', + title: 'Test', + status: 400 + ); + + $this->assertNull($problem->getExtension('non_existent')); + } +} \ No newline at end of file From 1d62d132bc2ea5ad5974ee3a1b7711dab8e919dc Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Wed, 7 Jan 2026 15:22:50 -0600 Subject: [PATCH 3/3] Update tests/ProblemDetailsTest.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/ProblemDetailsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ProblemDetailsTest.php b/tests/ProblemDetailsTest.php index 87e2c87..8f157a4 100644 --- a/tests/ProblemDetailsTest.php +++ b/tests/ProblemDetailsTest.php @@ -1,6 +1,6 @@