diff --git a/README.md b/README.md index 4358e56..fc12070 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Focused package documentation lives next to the relevant source folders: - [Logging](./src/Logging/README.md) - [Mongo](./src/Mongo/README.md) - [Middleware](./src/Middleware/README.md) +- [Queue](./src/Queue/README.md) - [Rate Limiting](./src/RateLimit/README.md) - [Redis](./src/Redis/README.md) - [Routing](./src/Routing/README.md) @@ -30,6 +31,12 @@ Focused package documentation lives next to the relevant source folders: - [Support and Facades](./src/Support/README.md) - [Validation](./src/Validation/README.md) +## Install via Composer + +```bash +composer require 200mph/myxa-framework +``` + ## Docker Setup The repository includes a PHP 8.4 CLI container and a MySQL container. diff --git a/composer.json b/composer.json index d7437ef..eb0f1c6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "200mph/myxa-framework", - "description": "Myxa core framework package", + "description": "The core framework package for Myxa, a lightweight and flexible AI-powered PHP framework.", "type": "library", "license": "MIT", "authors": [ diff --git a/src/Database/Factory/Factory.php b/src/Database/Factory/Factory.php new file mode 100644 index 0000000..c482561 --- /dev/null +++ b/src/Database/Factory/Factory.php @@ -0,0 +1,221 @@ +|callable(array, FakeData): array>> */ + private array $states = []; + + private int $count = 1; + + public function __construct( + protected FakeData $faker = new FakeData(), + protected ?DatabaseManager $manager = null, + ) { + } + + /** + * Create a new factory instance. + */ + public static function new(?FakeData $faker = null, ?DatabaseManager $manager = null): static + { + return new static($faker ?? new FakeData(), $manager); + } + + /** + * Return the target model class for this factory. + * + * @return class-string + */ + abstract protected function model(): string; + + /** + * Return the default attributes for a model instance. + * + * @return array + */ + abstract protected function definition(): array; + + /** + * Return the fake data generator used by the factory. + */ + public function faker(): FakeData + { + return $this->faker; + } + + /** + * Return a clone that uses the given database manager. + */ + public function withManager(?DatabaseManager $manager): static + { + $factory = clone $this; + $factory->manager = $manager; + + return $factory; + } + + /** + * Return a clone that will build the given number of models. + */ + public function count(int $count): static + { + if ($count < 1) { + throw new InvalidArgumentException('Factory count must be at least 1.'); + } + + $factory = clone $this; + $factory->count = $count; + + return $factory; + } + + /** + * Return a clone with an extra state transformation. + * + * @param array|callable(array, FakeData): array $state + */ + public function state(array|callable $state): static + { + $factory = clone $this; + $factory->states[] = $state; + + return $factory; + } + + /** + * Return the raw attribute payload. + * + * @param array $attributes + * @return array|list> + */ + public function raw(array $attributes = []): array + { + if ($this->count === 1) { + return $this->buildAttributes($attributes); + } + + $rows = []; + + for ($index = 0; $index < $this->count; $index++) { + $rows[] = $this->buildAttributes($attributes); + } + + return $rows; + } + + /** + * Build unsaved model instances. + * + * @param array $attributes + * @return Model|list + */ + public function make(array $attributes = []): Model|array + { + if ($this->count === 1) { + return $this->makeOne($attributes); + } + + $models = []; + + for ($index = 0; $index < $this->count; $index++) { + $models[] = $this->makeOne($attributes); + } + + return $models; + } + + /** + * Build and persist model instances. + * + * @param array $attributes + * @return Model|list + */ + public function create(array $attributes = []): Model|array + { + if ($this->count === 1) { + return $this->createOne($attributes); + } + + $models = []; + + for ($index = 0; $index < $this->count; $index++) { + $models[] = $this->createOne($attributes); + } + + return $models; + } + + /** + * Hook for adjusting a model after make(). + */ + protected function afterMaking(Model $model): void + { + } + + /** + * Hook for adjusting a model after create(). + */ + protected function afterCreating(Model $model): void + { + } + + /** + * @param array $attributes + * @return array + */ + private function buildAttributes(array $attributes): array + { + $payload = $this->definition(); + + foreach ($this->states as $state) { + $resolved = is_array($state) + ? $state + : $state($payload, $this->faker); + + if (!is_array($resolved)) { + throw new InvalidArgumentException('Factory state callbacks must return an attribute array.'); + } + + $payload = array_replace($payload, $resolved); + } + + return array_replace($payload, $attributes); + } + + /** + * @param array $attributes + */ + private function makeOne(array $attributes): Model + { + $modelClass = $this->model(); + $model = new $modelClass($this->buildAttributes($attributes), $this->manager); + $this->afterMaking($model); + + return $model; + } + + /** + * @param array $attributes + */ + private function createOne(array $attributes): Model + { + $model = $this->makeOne($attributes); + $model->save(); + $this->afterCreating($model); + + return $model; + } +} diff --git a/src/Database/Factory/FakeData.php b/src/Database/Factory/FakeData.php new file mode 100644 index 0000000..ae24f07 --- /dev/null +++ b/src/Database/Factory/FakeData.php @@ -0,0 +1,328 @@ +> */ + private array $uniqueValues = []; + + public function __construct( + private readonly Randomizer $random = new Randomizer(), + ) { + } + + /** + * Return a proxy that forces the next generator calls to produce unique values. + */ + public function unique(?string $scope = null, int $maxAttempts = 1000): UniqueFakeData + { + if ($maxAttempts < 1) { + throw new InvalidArgumentException('Unique max attempts must be at least 1.'); + } + + return new UniqueFakeData($this, $scope, $maxAttempts); + } + + /** + * Generate a unique value from a custom callback. + */ + public function uniqueValue(callable $generator, ?string $scope = null, int $maxAttempts = 1000): mixed + { + if ($maxAttempts < 1) { + throw new InvalidArgumentException('Unique max attempts must be at least 1.'); + } + + $scope ??= 'default'; + + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + $value = $generator(); + $fingerprint = $this->fingerprint($value); + + if (!isset($this->uniqueValues[$scope][$fingerprint])) { + $this->uniqueValues[$scope][$fingerprint] = true; + + return $value; + } + } + + throw new BadMethodCallException(sprintf( + 'Unable to generate a unique fake value for scope "%s" after %d attempts.', + $scope, + $maxAttempts, + )); + } + + /** + * Clear tracked unique values. + */ + public function resetUnique(?string $scope = null): self + { + if ($scope === null) { + $this->uniqueValues = []; + + return $this; + } + + unset($this->uniqueValues[$scope]); + + return $this; + } + + /** + * Generate a random alphanumeric string. + */ + public function string(int $length = 16): string + { + return $this->characters($length, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'); + } + + /** + * Generate a random alphabetic string. + */ + public function alpha(int $length = 12): string + { + return $this->characters($length, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + + /** + * Generate a random numeric string. + */ + public function digits(int $length = 6): string + { + return $this->characters($length, '0123456789'); + } + + /** + * Generate a random integer inside the given inclusive bounds. + */ + public function number(int $min = 0, int $max = 100): int + { + if ($min > $max) { + throw new InvalidArgumentException('Fake number minimum cannot be greater than maximum.'); + } + + return $this->random->getInt($min, $max); + } + + /** + * Generate a random float inside the given inclusive bounds. + */ + public function decimal(float $min = 0, float $max = 100, int $precision = 2): float + { + if ($min > $max) { + throw new InvalidArgumentException('Fake decimal minimum cannot be greater than maximum.'); + } + + if ($precision < 0) { + throw new InvalidArgumentException('Fake decimal precision cannot be negative.'); + } + + $multiplier = 10 ** $precision; + + return $this->number((int) round($min * $multiplier), (int) round($max * $multiplier)) / $multiplier; + } + + /** + * Generate a random boolean. + */ + public function boolean(int $truePercentage = 50): bool + { + if ($truePercentage < 0 || $truePercentage > 100) { + throw new InvalidArgumentException('Fake boolean true percentage must be between 0 and 100.'); + } + + return $this->number(1, 100) <= $truePercentage; + } + + /** + * Pick one value from a non-empty list. + * + * @template TValue + * @param list $values + * @return TValue + */ + public function choice(array $values): mixed + { + if ($values === []) { + throw new InvalidArgumentException('Fake choice values cannot be empty.'); + } + + return $values[$this->number(0, count($values) - 1)]; + } + + /** + * Generate a pseudo-natural word. + */ + public function word(int $minLength = 3, int $maxLength = 10): string + { + $this->assertLengthRange($minLength, $maxLength, 'Fake word'); + + $consonants = [ + 'b', 'br', 'c', 'ch', 'cl', 'cr', 'd', 'dr', 'f', 'fl', 'g', 'gl', 'gr', + 'h', 'j', 'k', 'kl', 'l', 'm', 'n', 'p', 'ph', 'pl', 'pr', 'qu', 'r', + 's', 'sh', 'sk', 'sl', 'sm', 'sn', 'sp', 'st', 't', 'th', 'tr', 'v', + 'w', 'x', 'y', 'z', + ]; + $vowels = ['a', 'e', 'i', 'o', 'u', 'ae', 'ai', 'ea', 'ee', 'ie', 'oa', 'oo', 'ou']; + + $word = ''; + $useConsonant = $this->boolean(); + $targetLength = $this->number($minLength, $maxLength); + + while (strlen($word) < $targetLength) { + $pool = $useConsonant ? $consonants : $vowels; + $chunk = $this->choice($pool); + + if (strlen($word . $chunk) > $targetLength) { + $chunk = substr($chunk, 0, $targetLength - strlen($word)); + } + + if ($chunk === '') { + break; + } + + $word .= $chunk; + $useConsonant = !$useConsonant; + } + + return strtolower($word); + } + + /** + * Generate several pseudo-natural words. + * + * @return list + */ + public function words(int $count = 3, int $minLength = 3, int $maxLength = 10): array + { + if ($count < 1) { + throw new InvalidArgumentException('Fake words count must be at least 1.'); + } + + $words = []; + + for ($index = 0; $index < $count; $index++) { + $words[] = $this->word($minLength, $maxLength); + } + + return $words; + } + + /** + * Generate a sentence with a trailing period. + */ + public function sentence(int $minWords = 4, int $maxWords = 9): string + { + $this->assertLengthRange($minWords, $maxWords, 'Fake sentence word'); + + $sentence = implode(' ', $this->words($this->number($minWords, $maxWords))); + + return ucfirst($sentence) . '.'; + } + + /** + * Generate a small paragraph. + */ + public function paragraph(int $sentences = 3): string + { + if ($sentences < 1) { + throw new InvalidArgumentException('Fake paragraph sentence count must be at least 1.'); + } + + $paragraph = []; + + for ($index = 0; $index < $sentences; $index++) { + $paragraph[] = $this->sentence(); + } + + return implode(' ', $paragraph); + } + + /** + * Generate a simple email address. + */ + public function email(string $domain = 'example.test'): string + { + $domain = trim(strtolower($domain)); + if ($domain === '') { + throw new InvalidArgumentException('Fake email domain cannot be empty.'); + } + + return sprintf( + '%s@%s', + strtolower($this->slug(2, '.')) . $this->digits(3), + $domain, + ); + } + + /** + * Generate a slug from random words. + */ + public function slug(int $words = 3, string $separator = '-'): string + { + if ($words < 1) { + throw new InvalidArgumentException('Fake slug word count must be at least 1.'); + } + + $separator = trim($separator); + if ($separator === '') { + throw new InvalidArgumentException('Fake slug separator cannot be empty.'); + } + + return implode($separator, $this->words($words, 3, 8)); + } + + private function characters(int $length, string $alphabet): string + { + if ($length < 1) { + throw new InvalidArgumentException('Fake string length must be at least 1.'); + } + + if ($alphabet === '') { + throw new InvalidArgumentException('Fake alphabet cannot be empty.'); + } + + $characters = ''; + $lastIndex = strlen($alphabet) - 1; + + for ($index = 0; $index < $length; $index++) { + $characters .= $alphabet[$this->random->getInt(0, $lastIndex)]; + } + + return $characters; + } + + private function assertLengthRange(int $min, int $max, string $label): void + { + if ($min < 1 || $max < 1) { + throw new InvalidArgumentException(sprintf('%s length must be at least 1.', $label)); + } + + if ($min > $max) { + throw new InvalidArgumentException(sprintf('%s minimum cannot be greater than maximum.', $label)); + } + } + + private function fingerprint(mixed $value): string + { + if (is_object($value)) { + return sprintf('object:%s:%s', $value::class, serialize($value)); + } + + if (is_array($value)) { + return 'array:' . serialize($value); + } + + return sprintf('%s:%s', get_debug_type($value), var_export($value, true)); + } +} diff --git a/src/Database/Factory/README.md b/src/Database/Factory/README.md new file mode 100644 index 0000000..5073d5c --- /dev/null +++ b/src/Database/Factory/README.md @@ -0,0 +1,213 @@ +# Factory + +Factories provide a lightweight way to create fake model data for tests, demos, and local tooling. + +The framework ships the factory base class and fake data helpers. Your app owns the concrete factories and decides what “valid fake data” looks like for each model. + +## Define A Factory + +```php +use Myxa\Database\Factory\Factory; + +final class UserFactory extends Factory +{ + protected function model(): string + { + return User::class; + } + + protected function definition(): array + { + return [ + 'email' => $this->faker()->unique()->email(), + 'status' => $this->faker()->choice(['draft', 'active']), + 'display_name' => $this->faker()->sentence(2, 3), + ]; + } +} +``` + +## Attach A Factory To A Model + +If you want `User::factory()` style access, add `HasFactory` to the model and return the concrete factory: + +```php +use Myxa\Database\Factory\Factory; +use Myxa\Database\Model\HasFactory; +use Myxa\Database\Model\Model; + +final class User extends Model +{ + use HasFactory; + + protected string $table = 'users'; + + protected ?int $id = null; + protected string $email = ''; + protected string $status = ''; + protected string $display_name = ''; + + protected static function newFactory(): Factory + { + return UserFactory::new(); + } +} +``` + +Then both styles work: + +```php +$user = UserFactory::new()->make(); +$user = User::factory()->make(); +``` + +## Build Modes + +```php +$raw = UserFactory::new()->raw(); +$user = UserFactory::new()->make(); +$persisted = UserFactory::new()->create(); + +$users = UserFactory::new() + ->count(3) + ->create(); +``` + +What each method does: + +- `raw()` returns the final attribute array without creating a model +- `make()` returns an unsaved model instance +- `create()` returns a saved model instance +- `count(3)` repeats the same operation multiple times and returns a list + +Examples: + +```php +$attributes = UserFactory::new()->raw(); + +$draft = UserFactory::new()->make(); +self::assertFalse($draft->exists()); + +$persisted = UserFactory::new()->create(); +self::assertTrue($persisted->exists()); +``` + +## States And Overrides + +`state()` changes the factory defaults before the model is built. + +```php +$admin = UserFactory::new() + ->state(['status' => 'admin']) + ->create(); +``` + +`create([...])`, `make([...])`, and `raw([...])` apply final one-off overrides. + +```php +$admin = UserFactory::new() + ->state(['status' => 'admin']) + ->create([ + 'email' => 'admin@example.com', + ]); +``` + +This means the final attribute order is: + +1. `definition()` +2. all `state(...)` calls +3. the attributes passed to `raw()`, `make()`, or `create()` + +So these are all valid: + +```php +UserFactory::new()->create([ + 'email' => 'admin@example.com', + 'status' => 'admin', +]); + +UserFactory::new() + ->state(['status' => 'admin']) + ->create([ + 'email' => 'admin@example.com', + ]); + +UserFactory::new() + ->state([ + 'email' => 'admin@example.com', + 'status' => 'admin', + ]) + ->create(); +``` + +Use `state()` when a value describes a reusable variant of the factory. Use `create([...])` when a value is specific to this one record. + +You can also use a callback state when the next values depend on the current payload or faker: + +```php +$user = UserFactory::new() + ->state(function (array $attributes, \Myxa\Database\Factory\FakeData $faker): array { + return [ + 'status' => 'admin', + 'display_name' => strtoupper($faker->word() . ' ' . $faker->word()), + ]; + }) + ->create(); +``` + +## Fake Data Helpers + +Available helpers include: + +- `string()` +- `alpha()` +- `digits()` +- `number()` +- `decimal()` +- `boolean()` +- `choice([...])` +- `word()` +- `words()` +- `sentence()` +- `paragraph()` +- `slug()` +- `email()` +- `unique()->...` + +Typical usage: + +```php +$payload = [ + 'email' => $this->faker()->unique()->email(), + 'status' => $this->faker()->choice(['draft', 'active', 'archived']), + 'age' => $this->faker()->number(18, 80), + 'score' => $this->faker()->decimal(10, 99, 2), + 'nickname' => $this->faker()->alpha(10), + 'bio' => $this->faker()->sentence(), + 'slug' => $this->faker()->slug(), + 'is_public' => $this->faker()->boolean(), +]; +``` + +`unique()` wraps the next generator call and tracks values per scope: + +```php +$email = $this->faker()->unique()->email(); +$slug = $this->faker()->unique('post-slugs')->slug(); +``` + +If you need a custom unique rule, use `value()`: + +```php +$code = $this->faker() + ->unique('invite-codes') + ->value(fn (): string => strtoupper($this->faker()->alpha(6))); +``` + +## Notes + +- factories are intentionally small and framework-native +- `state()` calls are cumulative and are applied in the order you add them +- later overrides win over earlier values +- faker uniqueness is tracked in memory on that faker instance +- concrete factory classes belong in the consumer app, not in the framework itself diff --git a/src/Database/Factory/UniqueFakeData.php b/src/Database/Factory/UniqueFakeData.php new file mode 100644 index 0000000..d89be8b --- /dev/null +++ b/src/Database/Factory/UniqueFakeData.php @@ -0,0 +1,44 @@ +faker->uniqueValue($generator, $scope ?? $this->scope, $this->maxAttempts); + } + + /** + * Forward generator methods to the base faker and enforce uniqueness. + */ + public function __call(string $name, array $arguments): mixed + { + if (!method_exists($this->faker, $name) || $name === 'unique') { + throw new BadMethodCallException(sprintf('Fake data method "%s" is not supported.', $name)); + } + + return $this->faker->uniqueValue( + fn (): mixed => $this->faker->{$name}(...$arguments), + $this->scope ?? $name, + $this->maxAttempts, + ); + } +} diff --git a/src/Database/Model/HasFactory.php b/src/Database/Model/HasFactory.php new file mode 100644 index 0000000..222fef1 --- /dev/null +++ b/src/Database/Model/HasFactory.php @@ -0,0 +1,26 @@ +withManager($manager); + } + + /** + * Build the base factory for the model. + */ + abstract protected static function newFactory(): Factory; +} diff --git a/src/Database/Model/README.md b/src/Database/Model/README.md index 0e37e5d..46d98af 100644 --- a/src/Database/Model/README.md +++ b/src/Database/Model/README.md @@ -97,6 +97,83 @@ $first = User::query()->where('status', '=', 'active')->first(); $exists = User::query()->where('status', '=', 'active')->exists(); ``` +## Factories + +Factories are intentionally lightweight. The framework provides the base `Factory`, a small fake data generator, and a `HasFactory` trait. Your app defines the concrete factory class. + +For the complete factory workflow, including `raw()`, `make()`, `create()`, `state()`, and the full fake data helper list, see [Factory](../Factory/README.md). + +```php +use Myxa\Database\Factory\Factory; +use Myxa\Database\Model\HasFactory; + +final class User extends Model +{ + use HasFactory; + + protected string $table = 'users'; + + protected ?int $id = null; + protected string $email = ''; + protected string $status = ''; + + protected static function newFactory(): Factory + { + return UserFactory::new(); + } +} + +final class UserFactory extends Factory +{ + protected function model(): string + { + return User::class; + } + + protected function definition(): array + { + return [ + 'email' => $this->faker()->unique()->email(), + 'status' => $this->faker()->choice(['draft', 'active']), + ]; + } +} +``` + +Usage: + +```php +$user = User::factory()->make(); +$persisted = User::factory()->create(); + +$users = User::factory() + ->count(3) + ->create(); + +$admin = User::factory() + ->state(['status' => 'admin']) + ->create([ + 'email' => 'admin@example.com', + ]); +``` + +Available fake data helpers include: + +- `$this->faker()->string(16)` +- `$this->faker()->alpha(12)` +- `$this->faker()->digits(6)` +- `$this->faker()->number(1, 100)` +- `$this->faker()->decimal(10, 99, 2)` +- `$this->faker()->boolean()` +- `$this->faker()->choice(['draft', 'active'])` +- `$this->faker()->word()` +- `$this->faker()->words(3)` +- `$this->faker()->sentence()` +- `$this->faker()->paragraph()` +- `$this->faker()->slug()` +- `$this->faker()->email()` +- `$this->faker()->unique()->email()` + ## Guarded, Hidden, and Internal Attributes ```php diff --git a/src/Database/README.md b/src/Database/README.md index 2e73135..65d8143 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -5,6 +5,7 @@ The database layer is split into a few focused parts: - [Connection](./Connection/README.md): PDO-backed connection configuration and registry - [Query Builder](./Query/README.md): fluent query builder with driver-aware SQL grammars - [Model](./Model/README.md): active-record style models with declared properties +- [Factory](./Factory/README.md): lightweight model factories and fake data helpers - [Schema](./Schema/README.md): schema builder, reverse engineering, snapshots, and diffing - [Migrations](./Migrations/README.md): migration base class and schema-first workflow diff --git a/src/Http/README.md b/src/Http/README.md index 1d2732c..31d775c 100644 --- a/src/Http/README.md +++ b/src/Http/README.md @@ -14,10 +14,76 @@ use Myxa\Support\Facades\Request; $method = Request::method(); $allInput = Request::all(); +$page = Request::query('page', 1); +$email = Request::post('email'); +$search = Request::input('search'); +$sessionId = Request::cookie('session'); $token = Request::bearerToken(); $isJson = Request::expectsJson(); ``` +Common access patterns: + +```php +use Myxa\Support\Facades\Request; + +// Query string: /users?page=2&filter=active +$page = Request::query('page', 1); +$filter = Request::query('filter'); +$allQuery = Request::query(); + +// POST body fields +$email = Request::post('email'); +$password = Request::post('password'); +$allPost = Request::post(); + +// Merged query + POST input +$search = Request::input('search'); +$allInput = Request::all(); + +// Cookies +$sessionId = Request::cookie('session'); +$theme = Request::cookie('theme', 'light'); +$allCookies = Request::cookie(); + +// Headers are case-insensitive +$contentType = Request::header('Content-Type'); +$acceptLanguage = Request::header('accept-language', 'en'); +$allHeaders = Request::headers(); + +// Uploaded files and server values +$avatar = Request::file('avatar'); +$remoteAddress = Request::server('REMOTE_ADDR'); +``` + +Request metadata helpers: + +```php +use Myxa\Support\Facades\Request; + +$path = Request::path(); // /users/list +$requestUri = Request::requestUri(); // /users/list?page=2 +$queryString = Request::queryString(); // page=2 +$url = Request::url(); // https://example.com/users/list +$fullUrl = Request::fullUrl(); // https://example.com/users/list?page=2 + +$scheme = Request::scheme(); // http or https +$isSecure = Request::secure(); +$host = Request::host(); +$port = Request::port(); +$ip = Request::ip(); + +$isAjax = Request::ajax(); +$expectsJson = Request::expectsJson(); +$rawBody = Request::content(); +``` + +Useful notes: + +- `Request::query('key', $default)`, `Request::post('key', $default)`, `Request::input('key', $default)`, `Request::cookie('key', $default)`, `Request::file('key', $default)`, and `Request::header('name', $default)` all support a default value. +- `Request::input()` and `Request::all()` merge query and POST data. When the same key exists in both places, POST wins. +- `Request::expectsJson()` returns `true` for JSON `Accept` or `Content-Type` headers, AJAX requests, and `/api` routes. + ## Response ```php @@ -32,12 +98,58 @@ return Response::json([ Other helpers: ```php +use Myxa\Support\Facades\Response; + Response::text('Created', 201); Response::html('

Hello

'); Response::redirect('/login'); Response::noContent(); ``` +Headers and cookies can be chained onto the response: + +```php +use Myxa\Support\Facades\Response; + +return Response::json([ + 'ok' => true, + 'user' => ['id' => 1], +], 200) + ->setHeader('X-Trace-Id', 'req-123') + ->cookie( + name: 'session', + value: 'token-123', + expires: time() + 3600, + path: '/', + domain: '', + secure: true, + httpOnly: true, + sameSite: 'Strict', + ); +``` + +You can also build a plain response manually: + +```php +use Myxa\Http\Response; + +return (new Response()) + ->status(202) + ->setHeader('X-App', 'myxa') + ->body('Accepted'); +``` + +Cookie helpers: + +```php +use Myxa\Support\Facades\Response; + +Response::cookie('theme', 'forest'); +Response::hasCookie('theme'); +Response::cookies(); +Response::removeCookie('theme'); +``` + ## Controllers ```php @@ -49,7 +161,11 @@ final class UserController extends Controller { protected function get(Request $request): mixed { - return Response::json(['path' => $request->path()]); + return Response::json([ + 'path' => $request->path(), + 'page' => $request->query('page', 1), + 'session' => $request->cookie('session'), + ]); } } ``` @@ -59,3 +175,5 @@ final class UserController extends Controller - `RequestServiceProvider` registers the current request - `ResponseServiceProvider` registers the shared response - `ExceptionHandlerServiceProvider` binds the default exception renderer/reporter +- request headers are case-insensitive +- response cookies support `expires`, `path`, `domain`, `secure`, `httpOnly`, and `sameSite` diff --git a/src/Queue/JobEnvelope.php b/src/Queue/JobEnvelope.php new file mode 100644 index 0000000..82ecb06 --- /dev/null +++ b/src/Queue/JobEnvelope.php @@ -0,0 +1,23 @@ + $context + */ + public function __construct( + public string $id, + public JobInterface $job, + public ?string $queue = null, + public int $attempts = 0, + public array $context = [], + ) { + } +} diff --git a/src/Queue/JobInterface.php b/src/Queue/JobInterface.php new file mode 100644 index 0000000..de1f0c4 --- /dev/null +++ b/src/Queue/JobInterface.php @@ -0,0 +1,16 @@ + $context + */ + public function push(JobInterface $job, array $context = [], ?string $queue = null): string; + + /** + * Reserve the next available queued message, if one exists. + */ + public function pop(?string $queue = null): ?JobEnvelope; + + /** + * Mark a previously reserved message as completed. + */ + public function ack(JobEnvelope $message): void; + + /** + * Return a reserved message to the queue, optionally delayed. + */ + public function release(JobEnvelope $message, int $delaySeconds = 0): void; + + /** + * Mark a message as failed and optionally attach the underlying error. + */ + public function fail(JobEnvelope $message, ?Throwable $error = null): void; +} diff --git a/src/Queue/QueueServiceProvider.php b/src/Queue/QueueServiceProvider.php new file mode 100644 index 0000000..8e9c2d6 --- /dev/null +++ b/src/Queue/QueueServiceProvider.php @@ -0,0 +1,64 @@ +registerShared(QueueInterface::class, $this->queue); + $this->registerShared(WorkerInterface::class, $this->worker); + $this->registerShared(RetryPolicyInterface::class, $this->retryPolicy); + + if ($this->queue !== null) { + $this->app()->singleton( + 'queue', + static fn (Application $app): QueueInterface => $app->make(QueueInterface::class), + ); + } + + if ($this->worker !== null) { + $this->app()->singleton( + 'queue.worker', + static fn (Application $app): WorkerInterface => $app->make(WorkerInterface::class), + ); + } + + if ($this->retryPolicy !== null) { + $this->app()->singleton( + 'queue.retry-policy', + static fn (Application $app): RetryPolicyInterface => $app->make(RetryPolicyInterface::class), + ); + } + } + + private function registerShared( + string $abstract, + QueueInterface|WorkerInterface|RetryPolicyInterface|Closure|string|null $concrete, + ): void { + if ($concrete === null) { + return; + } + + if (is_object($concrete) && !$concrete instanceof Closure) { + $this->app()->instance($abstract, $concrete); + + return; + } + + $this->app()->singleton($abstract, $concrete); + } +} diff --git a/src/Queue/QueuedJobInterface.php b/src/Queue/QueuedJobInterface.php new file mode 100644 index 0000000..a5f6cd3 --- /dev/null +++ b/src/Queue/QueuedJobInterface.php @@ -0,0 +1,26 @@ + 'signup-42'], +); +``` + +### `QueueInterface` + +Represents the queue transport itself. + +```php +use Myxa\Queue\JobEnvelope; +use Myxa\Queue\JobInterface; +use Myxa\Queue\QueueInterface; + +final class InMemoryQueue implements QueueInterface +{ + /** @var list */ + private array $messages = []; + + public function push(JobInterface $job, array $context = [], ?string $queue = null): string + { + $id = 'job-' . (count($this->messages) + 1); + + $this->messages[] = new JobEnvelope( + id: $id, + job: $job, + queue: $queue, + context: $context, + ); + + return $id; + } + + public function pop(?string $queue = null): ?JobEnvelope + { + foreach ($this->messages as $index => $message) { + if ($queue !== null && $message->queue !== $queue) { + continue; + } + + unset($this->messages[$index]); + + return $message; + } + + return null; + } + + public function ack(JobEnvelope $message): void + { + } + + public function release(JobEnvelope $message, int $delaySeconds = 0): void + { + $this->messages[] = new JobEnvelope( + id: $message->id, + job: $message->job, + queue: $message->queue, + attempts: $message->attempts + 1, + context: $message->context, + ); + } + + public function fail(JobEnvelope $message, ?\Throwable $error = null): void + { + } +} +``` + +### `RetryPolicyInterface` + +Keeps retry decisions separate from the worker loop. + +```php +use Myxa\Queue\JobEnvelope; +use Myxa\Queue\RetryPolicyInterface; + +final class SimpleRetryPolicy implements RetryPolicyInterface +{ + public function shouldRetry(JobEnvelope $message, \Throwable $error): bool + { + $maxAttempts = $message->job instanceof \Myxa\Queue\QueuedJobInterface + ? $message->job->maxAttempts() + : 3; + + return $message->attempts < $maxAttempts; + } + + public function delaySeconds(JobEnvelope $message, \Throwable $error): int + { + return ($message->attempts + 1) * 30; + } +} +``` + +### `WorkerInterface` + +Represents the process that consumes messages from the queue and executes them. + +```php +use Myxa\Queue\JobEnvelope; +use Myxa\Queue\QueueInterface; +use Myxa\Queue\RetryPolicyInterface; +use Myxa\Queue\WorkerInterface; + +final class SimpleWorker implements WorkerInterface +{ + private bool $running = true; + + public function __construct( + private QueueInterface $queue, + private RetryPolicyInterface $retryPolicy, + ) { + } + + public function run(?string $queue = null): int + { + while ($this->running) { + $message = $this->queue->pop($queue); + + if ($message === null) { + break; + } + + $this->process($message); + } + + return 0; + } + + public function process(JobEnvelope $message): void + { + try { + $message->job->handle(); + $this->queue->ack($message); + } catch (\Throwable $error) { + if ($this->retryPolicy->shouldRetry($message, $error)) { + $this->queue->release($message, $this->retryPolicy->delaySeconds($message, $error)); + + return; + } + + $this->queue->fail($message, $error); + } + } + + public function stop(): void + { + $this->running = false; + } +} +``` + +## Real-Life Example + +This is the shape of a typical signup email flow: + +1. A controller or service creates `SendWelcomeEmailJob`. +2. `QueueInterface::push()` stores the job in your queue backend. +3. A worker reserves the next `JobEnvelope`. +4. The worker runs `$message->job->handle()`. +5. On success, the worker calls `ack()`. +6. On failure, the worker asks `RetryPolicyInterface` whether to retry. +7. The queue either gets `release()` with a delay or `fail()`. + +## Register Implementations With the Service Provider + +`QueueServiceProvider` is optional. It is mainly a convenience wrapper around container bindings. + +```php +use Myxa\Application; +use Myxa\Queue\QueueInterface; +use Myxa\Queue\QueueServiceProvider; +use Myxa\Queue\RetryPolicyInterface; +use Myxa\Queue\WorkerInterface; + +$app = new Application(); + +$app->register(new QueueServiceProvider( + queue: InMemoryQueue::class, + worker: SimpleWorker::class, + retryPolicy: SimpleRetryPolicy::class, +)); + +$app->boot(); + +$queue = $app->make(QueueInterface::class); +$worker = $app->make(WorkerInterface::class); +$retry = $app->make(RetryPolicyInterface::class); +``` + +The provider also registers convenience aliases when you supply those implementations: + +- `'queue'` +- `'queue.worker'` +- `'queue.retry-policy'` + +## Push and Process a Job + +```php +$queue->push( + new SendWelcomeEmailJob(42), + context: ['trace_id' => 'signup-42'], + queue: 'emails', +); + +$worker->run('emails'); +``` + +## Notes + +- the queue layer defines contracts only, not a built-in backend +- these contracts can support Redis, RabbitMQ, database-backed queues, SQS, or sync/in-memory adapters +- `JobEnvelope` is the transport-neutral queue message wrapper between your queue implementation and your worker +- `QueuedJobInterface` is optional; plain `JobInterface` jobs can still be queued +- `QueueServiceProvider` only registers implementations you explicitly provide diff --git a/src/Queue/RetryPolicyInterface.php b/src/Queue/RetryPolicyInterface.php new file mode 100644 index 0000000..2248f54 --- /dev/null +++ b/src/Queue/RetryPolicyInterface.php @@ -0,0 +1,23 @@ +basePath = rtrim($resolvedBasePath, DIRECTORY_SEPARATOR); + } + + /** + * Return the resolved base path used for view lookup. + */ + public function basePath(): string + { + return $this->basePath; + } + + /** + * Determine whether a named PHP view exists in the base path. + */ + public function exists(string $view): bool + { + $path = $this->viewPath($view); + + return is_file($path) && is_readable($path); + } + + /** + * Render a PHP view file with extracted local variables. + * + * Templates receive two helper variables: + * - `$_html`: the current renderer for nested partials/layouts + * - `$_e`: HTML escaping closure for safe output + * + * @param array $data + */ + public function render(string $view, array $data = []): string + { + $path = $this->viewPath($view); + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException(sprintf('View [%s] was not found at [%s].', $view, $path)); + } + + return $this->renderPath($path, $data); + } + + /** + * Render a body view inside a layout. + * + * The rendered body is injected into the layout using the provided key. + * + * @param array $pageData + * @param array $layoutData + */ + public function renderPage( + string $pageView, + array $pageData = [], + string $layoutView = 'layouts/app', + array $layoutData = [], + string $bodyKey = 'body', + ): string { + if ($bodyKey === '') { + throw new InvalidArgumentException('Layout body key cannot be empty.'); + } + + $body = $this->render($pageView, $pageData); + + return $this->render($layoutView, [ + ...$layoutData, + $bodyKey => $body, + ]); + } + + /** + * Escape a value for safe HTML output. + */ + public static function escape(null|bool|int|float|string|Stringable $value): string + { + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + return htmlspecialchars((string) $value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8', false); + } + + private function viewPath(string $view): string + { + $relativePath = $this->normalizeView($view); + + return $this->basePath . DIRECTORY_SEPARATOR . $relativePath; + } + + /** + * @param array $data + */ + private function renderPath(string $path, array $data): string + { + ob_start(); + + $_html = $this; + $_e = static fn (null|bool|int|float|string|Stringable $value): string => self::escape($value); + + extract($data, \EXTR_SKIP); + + try { + include $path; + } catch (Throwable $exception) { + ob_end_clean(); + + throw $exception; + } + + return (string) ob_get_clean(); + } + + private function normalizeView(string $view): string + { + $normalized = trim(str_replace('\\', '/', $view)); + if ($normalized === '') { + throw new InvalidArgumentException('View name cannot be empty.'); + } + + if (str_contains($normalized, "\0")) { + throw new InvalidArgumentException('View name cannot contain null bytes.'); + } + + $normalized = ltrim($normalized, '/'); + if ($normalized === '') { + throw new InvalidArgumentException('View name cannot be empty.'); + } + + if (preg_match('#(^|/)\.\.(/|$)#', $normalized) === 1) { + throw new InvalidArgumentException(sprintf('View [%s] cannot traverse outside the base path.', $view)); + } + + if (!str_ends_with($normalized, '.php')) { + $normalized .= '.php'; + } + + return str_replace('/', DIRECTORY_SEPARATOR, $normalized); + } +} diff --git a/src/Support/README.md b/src/Support/README.md index baf1e53..9433fda 100644 --- a/src/Support/README.md +++ b/src/Support/README.md @@ -2,6 +2,31 @@ Support contains shared framework helpers, service-provider base classes, facades, and storage support wrappers. +## HTML Helpers + +Use `Myxa\Support\Html\Html` when you want to render PHP view files for HTML responses: + +```php +use Myxa\Support\Html\Html; + +$html = new Html(__DIR__ . '/views'); + +$content = $html->render('pages/home', [ + 'title' => 'Dashboard', + 'user' => $user, +]); + +$page = $html->renderPage( + 'pages/home', + ['user' => $user], + 'layouts/app', + ['title' => 'Dashboard'], +); +``` + +Inside a template, `$_html` renders nested partials and `$_e` escapes output safely. +Use `renderPage()` when you want a layout to inject a rendered body view into `layouts/app.php` or another layout. + ## Facades Available facades include: diff --git a/tests/Unit/Database/FactoryTest.php b/tests/Unit/Database/FactoryTest.php new file mode 100644 index 0000000..a014df9 --- /dev/null +++ b/tests/Unit/Database/FactoryTest.php @@ -0,0 +1,223 @@ + $this->faker()->unique('factory-user-email')->email(), + 'status' => $this->faker()->choice(['draft', 'active', 'archived']), + 'title' => $this->faker()->sentence(3, 5), + ]; + } +} + +#[CoversClass(FakeData::class)] +#[CoversClass(UniqueFakeData::class)] +#[CoversClass(Factory::class)] +#[CoversClass(FactoryUser::class)] +final class FactoryTest extends TestCase +{ + private const string CONNECTION_ALIAS = 'factory-test'; + + protected function setUp(): void + { + PdoConnection::register(self::CONNECTION_ALIAS, $this->makeInMemoryConnection(), true); + Model::setManager($this->makeManager()); + } + + protected function tearDown(): void + { + Model::clearManager(); + PdoConnection::unregister(self::CONNECTION_ALIAS); + } + + public function testFakeDataGeneratesCommonPrimitiveValues(): void + { + $faker = new FakeData(); + + $string = $faker->string(20); + $alpha = $faker->alpha(12); + $digits = $faker->digits(8); + $number = $faker->number(10, 20); + $decimal = $faker->decimal(1, 5, 2); + $sentence = $faker->sentence(4, 4); + $slug = $faker->slug(3); + $email = $faker->email(); + + self::assertSame(20, strlen($string)); + self::assertMatchesRegularExpression('/^[A-Za-z0-9]+$/', $string); + self::assertSame(12, strlen($alpha)); + self::assertMatchesRegularExpression('/^[A-Za-z]+$/', $alpha); + self::assertSame(8, strlen($digits)); + self::assertMatchesRegularExpression('/^[0-9]+$/', $digits); + self::assertGreaterThanOrEqual(10, $number); + self::assertLessThanOrEqual(20, $number); + self::assertGreaterThanOrEqual(1.0, $decimal); + self::assertLessThanOrEqual(5.0, $decimal); + self::assertStringEndsWith('.', $sentence); + self::assertCount(4, explode(' ', rtrim($sentence, '.'))); + self::assertMatchesRegularExpression('/^[a-z]+(?:-[a-z]+){2}$/', $slug); + self::assertMatchesRegularExpression('/^[a-z0-9.]+@example\.test$/', $email); + self::assertContains($faker->choice(['draft', 'active']), ['draft', 'active']); + } + + public function testFakeDataSupportsUniqueValuesAndScopeReset(): void + { + $faker = new FakeData(); + + $first = $faker->unique('emails')->email(); + $second = $faker->unique('emails')->email(); + + self::assertNotSame($first, $second); + + $faker->unique('fixed-value', 2)->value(static fn (): string => 'same'); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Unable to generate a unique fake value for scope "fixed-value" after 2 attempts.'); + + try { + $faker->unique('fixed-value', 2)->value(static fn (): string => 'same'); + } finally { + $faker->resetUnique('fixed-value'); + self::assertSame('same', $faker->unique('fixed-value', 2)->value(static fn (): string => 'same')); + } + } + + public function testFactoryCanMakeModelsWithoutPersistingThem(): void + { + $user = FactoryUser::factory()->make(); + + self::assertInstanceOf(FactoryUser::class, $user); + self::assertFalse($user->exists()); + self::assertMatchesRegularExpression('/@example\.test$/', $user->email); + self::assertContains($user->status, ['draft', 'active', 'archived']); + self::assertStringEndsWith('.', $user->title); + } + + public function testFactoryCanPersistMultipleModels(): void + { + $users = FactoryUser::factory()->count(3)->create(); + + self::assertCount(3, $users); + self::assertContainsOnlyInstancesOf(FactoryUser::class, $users); + self::assertSame(3, (int) $this->makeManager()->select('SELECT COUNT(*) AS total FROM users')[0]['total']); + self::assertNotSame($users[0]->email, $users[1]->email); + self::assertNotSame($users[1]->email, $users[2]->email); + } + + public function testFactorySupportsStatesOverridesAndRawAttributes(): void + { + $factory = FactoryUser::factory() + ->state(['status' => 'archived']) + ->state(fn (array $attributes, FakeData $faker): array => [ + 'title' => strtoupper($faker->sentence(2, 2)), + 'email' => sprintf('state-%s@example.test', $faker->digits(3)), + ]); + + $raw = $factory->raw(['email' => 'override@example.test']); + $user = $factory->make(['email' => 'override@example.test']); + + self::assertSame('archived', $raw['status']); + self::assertSame('override@example.test', $raw['email']); + self::assertSame('archived', $user->status); + self::assertSame('override@example.test', $user->email); + self::assertSame(strtoupper($user->title), $user->title); + } + + public function testFactoryCanUseAnExplicitManager(): void + { + $manager = new DatabaseManager('factory-explicit'); + $manager->addConnection('factory-explicit', $this->makeInMemoryConnection()); + + $user = FactoryUser::factory($manager)->create(['status' => 'active']); + + self::assertTrue($user->exists()); + self::assertSame( + 1, + (int) $manager->select('SELECT COUNT(*) AS total FROM users WHERE status = ?', ['active'])[0]['total'], + ); + self::assertSame( + 0, + (int) $this->makeManager()->select('SELECT COUNT(*) AS total FROM users WHERE status = ?', ['active'])[0]['total'], + ); + } + + private function makeManager(): DatabaseManager + { + return new DatabaseManager(self::CONNECTION_ALIAS); + } + + private function makeInMemoryConnection(): PdoConnection + { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec( + 'CREATE TABLE users (' + . 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + . 'email TEXT NOT NULL, ' + . 'status TEXT NOT NULL, ' + . 'title TEXT NOT NULL, ' + . 'created_at TEXT NULL, ' + . 'updated_at TEXT NULL' + . ')', + ); + + $connection = new PdoConnection( + new PdoConnectionConfig( + engine: 'mysql', + database: 'placeholder', + host: '127.0.0.1', + ), + ); + + $pdoProperty = new ReflectionProperty(PdoConnection::class, 'pdo'); + $pdoProperty->setValue($connection, $pdo); + + return $connection; + } +} diff --git a/tests/Unit/Queue/JobEnvelopeTest.php b/tests/Unit/Queue/JobEnvelopeTest.php new file mode 100644 index 0000000..cd838aa --- /dev/null +++ b/tests/Unit/Queue/JobEnvelopeTest.php @@ -0,0 +1,38 @@ + 'abc-123'], + ); + + self::assertSame('job-123', $envelope->id); + self::assertSame($job, $envelope->job); + self::assertSame('emails', $envelope->queue); + self::assertSame(2, $envelope->attempts); + self::assertSame(['trace_id' => 'abc-123'], $envelope->context); + } +} diff --git a/tests/Unit/Queue/QueueServiceProviderTest.php b/tests/Unit/Queue/QueueServiceProviderTest.php new file mode 100644 index 0000000..d1b95c6 --- /dev/null +++ b/tests/Unit/Queue/QueueServiceProviderTest.php @@ -0,0 +1,137 @@ +register(new QueueServiceProvider( + queue: $queue, + worker: $worker, + retryPolicy: $retryPolicy, + )); + $app->boot(); + + self::assertSame($queue, $app->make(QueueInterface::class)); + self::assertSame($worker, $app->make(WorkerInterface::class)); + self::assertSame($retryPolicy, $app->make(RetryPolicyInterface::class)); + self::assertSame($queue, $app->make('queue')); + self::assertSame($worker, $app->make('queue.worker')); + self::assertSame($retryPolicy, $app->make('queue.retry-policy')); + } + + public function testProviderSkipsBindingsWhenNoImplementationsAreProvided(): void + { + $app = new Application(); + $app->register(new QueueServiceProvider()); + $app->boot(); + + self::assertFalse($app->has('queue')); + self::assertFalse($app->has('queue.worker')); + self::assertFalse($app->has('queue.retry-policy')); + } + + public function testProviderRegistersConfiguredQueueBindingsAndAliases(): void + { + $app = new Application(); + $app->register(new QueueServiceProvider( + queue: QueueServiceProviderTestQueue::class, + worker: QueueServiceProviderTestWorker::class, + retryPolicy: QueueServiceProviderTestRetryPolicy::class, + )); + $app->boot(); + + $queue = $app->make(QueueInterface::class); + $worker = $app->make(WorkerInterface::class); + $retryPolicy = $app->make(RetryPolicyInterface::class); + + self::assertInstanceOf(QueueServiceProviderTestQueue::class, $queue); + self::assertInstanceOf(QueueServiceProviderTestWorker::class, $worker); + self::assertInstanceOf(QueueServiceProviderTestRetryPolicy::class, $retryPolicy); + self::assertSame($queue, $app->make('queue')); + self::assertSame($worker, $app->make('queue.worker')); + self::assertSame($retryPolicy, $app->make('queue.retry-policy')); + self::assertSame($queue, $worker->queue); + self::assertSame($retryPolicy, $worker->retryPolicy); + } +} + +final class QueueServiceProviderTestQueue implements QueueInterface +{ + public function push(JobInterface $job, array $context = [], ?string $queue = null): string + { + return 'job-1'; + } + + public function pop(?string $queue = null): ?JobEnvelope + { + return null; + } + + public function ack(JobEnvelope $message): void + { + } + + public function release(JobEnvelope $message, int $delaySeconds = 0): void + { + } + + public function fail(JobEnvelope $message, ?Throwable $error = null): void + { + } +} + +final readonly class QueueServiceProviderTestWorker implements WorkerInterface +{ + public function __construct( + public QueueInterface $queue, + public RetryPolicyInterface $retryPolicy, + ) { + } + + public function run(?string $queue = null): int + { + return 0; + } + + public function process(JobEnvelope $message): void + { + } + + public function stop(): void + { + } +} + +final class QueueServiceProviderTestRetryPolicy implements RetryPolicyInterface +{ + public function shouldRetry(JobEnvelope $message, Throwable $error): bool + { + return false; + } + + public function delaySeconds(JobEnvelope $message, Throwable $error): int + { + return 0; + } +} diff --git a/tests/Unit/Support/Html/HtmlTest.php b/tests/Unit/Support/Html/HtmlTest.php new file mode 100644 index 0000000..a50cd70 --- /dev/null +++ b/tests/Unit/Support/Html/HtmlTest.php @@ -0,0 +1,193 @@ +viewsPath = sys_get_temp_dir() . '/myxa-html-' . uniqid('', true); + + mkdir($this->viewsPath . '/layouts', 0777, true); + mkdir($this->viewsPath . '/pages', 0777, true); + mkdir($this->viewsPath . '/partials', 0777, true); + + file_put_contents($this->viewsPath . '/partials/header.php', <<<'PHP' +
+PHP); + + file_put_contents($this->viewsPath . '/partials/footer.php', <<<'PHP' +
+PHP); + + file_put_contents($this->viewsPath . '/layouts/app.php', <<<'PHP' +render('partials/header', ['title' => $title]) ?> + +render('partials/footer', ['footer' => $footer]) ?> +PHP); + + file_put_contents($this->viewsPath . '/pages/home.php', <<<'PHP' +render('partials/header', ['title' => $title]) ?> +
+PHP); + } + + protected function tearDown(): void + { + $files = [ + $this->viewsPath . '/layouts/app.php', + $this->viewsPath . '/pages/home.php', + $this->viewsPath . '/partials/footer.php', + $this->viewsPath . '/partials/header.php', + ]; + + foreach ($files as $file) { + if (is_file($file)) { + unlink($file); + } + } + + $directories = [ + $this->viewsPath . '/layouts', + $this->viewsPath . '/pages', + $this->viewsPath . '/partials', + $this->viewsPath, + ]; + + foreach ($directories as $directory) { + if (is_dir($directory)) { + rmdir($directory); + } + } + } + + public function testRenderRendersPhpViewsWithDataAndNestedPartials(): void + { + $html = new Html($this->viewsPath); + + $output = $html->render('pages/home', [ + 'title' => 'Welcome ', + 'message' => 'Safe & sound', + ]); + + self::assertSame( + "
Welcome <Admin>
Safe & sound
", + preg_replace('/>\s+<', trim($output)), + ); + } + + public function testExistsChecksViewPresence(): void + { + $html = new Html($this->viewsPath); + + self::assertTrue($html->exists('pages/home')); + self::assertFalse($html->exists('pages/missing')); + } + + public function testEscapeIsAvailableForTemplateSafeOutput(): void + { + self::assertSame( + '<script>alert("x")</script>', + Html::escape(''), + ); + } + + public function testRenderPageInjectsBodyIntoLayout(): void + { + $html = new Html($this->viewsPath); + + $output = $html->renderPage( + 'pages/home', + [ + 'title' => 'Welcome ', + 'message' => 'Safe & sound', + ], + 'layouts/app', + [ + 'title' => 'Dashboard ', + 'footer' => 'All rights reserved', + ], + ); + + self::assertSame( + '
Dashboard <Root>
Welcome <Admin>
Safe & sound
All rights reserved
', + preg_replace('/>\s+<', trim($output)), + ); + } + + public function testRenderPageSupportsCustomBodyKey(): void + { + file_put_contents($this->viewsPath . '/layouts/custom.php', <<<'PHP' +
+PHP); + + $html = new Html($this->viewsPath); + + $output = $html->renderPage( + 'pages/home', + [ + 'title' => 'Welcome ', + 'message' => 'Safe & sound', + ], + 'layouts/custom', + [], + 'slot', + ); + + self::assertSame( + '
Welcome <Admin>
Safe & sound
', + preg_replace('/>\s+<', trim($output)), + ); + + unlink($this->viewsPath . '/layouts/custom.php'); + } + + public function testRenderPageRejectsEmptyBodyKey(): void + { + $html = new Html($this->viewsPath); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Layout body key cannot be empty.'); + + $html->renderPage('pages/home', bodyKey: ''); + } + + public function testRenderRejectsMissingViews(): void + { + $html = new Html($this->viewsPath); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View [missing] was not found'); + + $html->render('missing'); + } + + public function testConstructorRejectsMissingBasePath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('HTML view base path'); + + new Html($this->viewsPath . '/unknown'); + } + + public function testRenderRejectsTraversalAttempt(): void + { + $html = new Html($this->viewsPath); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot traverse outside the base path'); + + $html->render('../secrets'); + } +}