From 65e4367c05a0c1e897d2c4920b64cdb95ab5dea3 Mon Sep 17 00:00:00 2001 From: Dev Date: Sat, 14 Feb 2026 19:28:22 +0000 Subject: [PATCH 1/2] Update to PHP 8.5/Symfony 8, improve SEO, harden code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump PHP requirement to ^8.5, PHPUnit to ^13.0 - Update README and composer.json with keyword-rich descriptions for SEO - Upgrade error views from RFC 2616 to RFC 9110 references - Add null-safe request handling in FormTrait and ApiFormTrait - Add CSRF disable config in MutationForm and QueryForm - Add field name validation in UniqueFieldValidator and HiddenEntityType - Improve type safety and strict comparisons throughout - Fix serialiseErrors → serializeErrors naming - Add CLAUDE.md and AGENTS.md project docs - Add tag workflow Co-Authored-By: Claude Opus 4.6 --- .github/workflows/php.yml | 8 +- .github/workflows/tag.yml | 41 ++++++ .gitignore | 4 + AGENTS.md | 121 +++++++++++++++--- CLAUDE.md | 91 +++++++++++++ README.md | 78 +++++++---- composer.json | 14 +- src/ApiFormTrait.php | 26 ++-- src/ChamberOrchestraFormBundle.php | 2 +- .../TranslatableExceptionInterface.php | 2 +- src/Extension/TelExtension.php | 2 +- src/FormTrait.php | 28 ++-- .../Normalizer/ProblemNormalizer.php | 2 +- src/Transformer/ArrayToStringTransformer.php | 2 +- .../DateTimeToNumberTransformer.php | 8 +- .../JsonStringToArrayTransformer.php | 2 +- src/Type/Api/MutationForm.php | 8 ++ src/Type/Api/QueryForm.php | 8 ++ src/Type/HiddenEntityType.php | 43 +++++-- src/Type/TimestampType.php | 4 +- src/Utils/CollectionUtils.php | 8 +- src/Validator/Constraints/UniqueField.php | 9 +- .../Constraints/UniqueFieldValidator.php | 42 ++++-- src/View/FailureView.php | 2 +- tests/Unit/ApiFormTraitTest.php | 55 ++++++++ tests/Unit/FormTraitTest.php | 86 ++++++++++++- .../DateTimeToNumberTransformerTest.php | 44 ++++++- .../JsonStringToArrayTransformerTest.php | 21 +++ tests/Unit/Type/Api/MutationFormTest.php | 12 ++ tests/Unit/Type/Api/QueryFormTest.php | 12 ++ .../Constraints/UniqueFieldValidatorTest.php | 51 ++++++++ tests/Unit/View/FailureViewTest.php | 2 +- 32 files changed, 712 insertions(+), 126 deletions(-) create mode 100644 .github/workflows/tag.yml create mode 100644 CLAUDE.md diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 03bca5b..fd7e72c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -16,10 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup PHP 8.4 + - name: Setup PHP 8.5 uses: shivammathur/setup-php@v2 with: - php-version: "8.4" + php-version: "8.5" tools: composer:v2 coverage: none @@ -32,9 +32,9 @@ jobs: path: | vendor ~/.composer/cache/files - key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php-8.4-composer- + ${{ runner.os }}-php-8.5-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..d11d406 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,41 @@ +name: Tag Release + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Get latest tag and compute next patch version + id: version + run: | + latest=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$latest" ]; then + echo "next=v0.0.1" >> "$GITHUB_OUTPUT" + else + major=$(echo "$latest" | cut -d. -f1) + minor=$(echo "$latest" | cut -d. -f2) + patch=$(echo "$latest" | cut -d. -f3) + next_patch=$((patch + 1)) + echo "next=${major}.${minor}.${next_patch}" >> "$GITHUB_OUTPUT" + fi + echo "Latest tag: ${latest:-none}, next: $(cat "$GITHUB_OUTPUT" | grep next | cut -d= -f2)" + + - name: Create and push tag + run: | + git tag "${{ steps.version.outputs.next }}" + git push origin "${{ steps.version.outputs.next }}" diff --git a/.gitignore b/.gitignore index 3a9875b..45d7fec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /vendor/ +/var/ composer.lock +.claude/settings.local.json +.claude/agent-memory/ +.phpunit.cache/ diff --git a/AGENTS.md b/AGENTS.md index ae941d7..b5bc3c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,24 +11,105 @@ - `composer update` refreshes dependency versions per `composer.json`. - `./vendor/bin/phpunit` runs the test suite using `phpunit.xml.dist`. -## Coding Style & Naming Conventions -- PHP 8.4 with `declare(strict_types=1);` at the top of PHP files. -- PSR-4 autoloading: `ChamberOrchestra\FormBundle\` maps to `src/`. + +## Code Conventions + +- PSR-12 style, `declare(strict_types=1)` in every file, 4-space indent +- View classes end with `View` suffix; utilities use verb naming (`BindUtils`, `ReflectionService`) +- Typed properties and return types; favor `readonly` where appropriate +- JSON structures should be explicit — avoid leaking nulls +- Namespace: `ChamberOrchestra\FormBundle\*` (PSR-4 from `src/`) - Class names use `PascalCase` (e.g., `UniqueFieldValidator`), methods and variables use `camelCase`. -- Keep one class/interface/trait per file, matching the filename. -- No formatter or linter is configured; follow existing code style (PSR-12 conventions). - -## Testing Guidelines -- PHPUnit is the test framework; config lives in `phpunit.xml.dist`. -- Tests live under `tests/` with namespaces rooted at `Tests\`. -- Prefer descriptive test class names aligned with the subject under `src/` (e.g., `FooTypeTest`). -- Run tests locally before submitting changes: `./vendor/bin/phpunit`. - -## Commit & Pull Request Guidelines -- Git history is minimal and does not define a commit message convention. Use clear, imperative summaries (e.g., "Add form transformer for JSON input"). -- PRs should include a short description, test results, and any relevant context or screenshots for UI changes. -- Link related issues if they exist and call out any backward-compatibility concerns. - -## Configuration Notes -- Runtime requirements are in `composer.json` (PHP 8.4, Symfony 8 components, Doctrine ORM). -- Test kernel is configured via `KERNEL_CLASS=Tests\Integrational\TestKernel` in `phpunit.xml.dist`. +- Follow a consistent formatting style. +- Use clear, descriptive names for variables, functions, and classes. +- Avoid non-standard abbreviations. +- Each function should have a single, well-defined responsibility. + +## Testing + +- PHPUnit 12.x; tests in `tests/` autoloaded as `Tests\` +- **Unit tests** (`tests/Unit/`) extend `TestCase`; mirror source structure +- **Integration tests** (`tests/Integrational/`) extend `KernelTestCase`; use `Tests\Integrational\TestKernel` (minimal kernel with FrameworkBundle + ChamberOrchestraViewBundle) +- Tests reset `BindUtils` and `ReflectionService` static state between runs +- Use data providers for mapping scenarios and cache behavior +- Write code that is easy to test. +- Avoid hard dependencies; use dependency injection where appropriate. +- Do not hardcode time, randomness, UUIDs, or global state. + +## Commit Style + +Short, action-oriented messages with optional bracketed scope: `[fix] ensure nulls are stripped`, `[master] bump version`. Keep commits focused; avoid unrelated formatting churn. + +## General Coding Principles + +- Write production-quality code, not illustrative examples. +- Prefer simple, readable solutions over clever ones. +- Avoid premature optimization. +- Do not introduce architectural complexity without clear justification. +- Follow Symfony bundle and directory conventions. +- Use Dependency Injection; never fetch services from the container. +- Do not use static service locators. +- Prefer configuration via services.yaml over hardcoding. +- Use autowiring and autoconfiguration where possible. +- Follow PSR-12 coding standards. +- Use strict types. +- Prefer typed properties and return types everywhere. +- Avoid magic methods unless explicitly required. +- Do not rely on global state or superglobals. + +## Structure and Architecture + +- Separate business logic, infrastructure, and presentation layers. +- Do not mix side effects with pure logic. +- Minimize coupling between modules. +- Prefer composition to inheritance. +- Services must be small and focused. +- One class — one responsibility. +- Constructor injection only. +- Do not inject the container itself. +- Prefer interfaces for public-facing services. + + +## Error Handling and Edge Cases + +- Handle errors explicitly. +- Never silently swallow exceptions. +- Validate all inputs. +- Consider edge cases and empty or null values. +- Use domain-specific exceptions. +- Do not catch exceptions unless you can handle them meaningfully. +- Fail fast on invalid state. +- Write code that is unit-testable by default. +- Avoid hard dependencies on time, randomness, or static state. +- Use interfaces or abstractions for external services. + +## Performance and Resources + +- Avoid unnecessary allocations and calls. +- Prevent N+1 queries. +- Assume the code may run on large datasets. + +## Documentation and Comments + +- Do not comment obvious code. +- Explain *why*, not *what*. +- Add comments when logic is non-trivial. +- Document public services and extension points. +- Comment non-obvious decisions, not implementation details. + +## Working with Existing Code + +- Preserve the existing codebase style and conventions. +- Do not refactor unrelated code. +- Make the smallest change necessary. + +## Assistant Behavior + +- Ask clarifying questions if requirements are ambiguous. +- If multiple solutions exist, choose the best one and briefly justify it. +- Avoid deprecated or experimental APIs unless explicitly requested. + +## Backward Compatibility + +- Do not introduce BC breaks without explicit instruction. +- Follow Symfony bundle versioning and deprecation practices. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..486658f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ChamberOrchestra Form Bundle is a Symfony bundle that streamlines JSON-first form handling for APIs. It provides controller helpers, specialized form types, data transformers, and RFC 7807-style error views for consistent API responses. + +## Build and Test Commands + +```bash +# Install dependencies +composer install + +# Run all tests +./vendor/bin/phpunit + +# Run specific test file +./vendor/bin/phpunit tests/Unit/FormTraitTest.php + +# Run tests in specific directory +./vendor/bin/phpunit tests/Unit/Transformer/ +``` + +## Architecture + +### Core Traits + +**FormTrait** (`src/FormTrait.php`): Base trait for form handling in controllers. Provides methods for creating responses (`createSuccessResponse()`, `createValidationFailedResponse()`, `createRedirectResponse()`), handling form submission flow (`handleFormCall()`, `onFormSubmitted()`), and serializing form errors into structured violations. + +**ApiFormTrait** (`src/ApiFormTrait.php`): Extends `FormTrait` for API controllers. Key method is `handleApiCall()` which automatically handles JSON payloads for `MutationForm` types and merges file uploads. Uses `convertRequestToArray()` to parse JSON content and merge with uploaded files. + +### Form Type Hierarchy + +**API Base Types** (`src/Type/Api/`): +- `GetForm`: For GET requests, sets method to GET +- `PostForm`: For POST requests, sets method to POST +- `QueryForm`: Extends `GetForm`, disables CSRF +- `MutationForm`: Extends `PostForm`, disables CSRF for JSON mutations + +All API form types use empty `block_prefix` to avoid HTML name prefixes in JSON responses. + +**Custom Types** (`src/Type/`): +- `BooleanType`: Text input that transforms to boolean via `TextToBoolTransformer` +- `TimestampType`: Integer input that transforms to DateTime via `DateTimeToNumberTransformer` +- `HiddenEntityType`: Hidden field that loads entities by ID from Doctrine repositories + +### Data Transformers + +Located in `src/Transformer/`: +- `TextToBoolTransformer`: Converts string values ("true", "1", "yes") to boolean +- `DateTimeToNumberTransformer`: Converts Unix timestamps to DateTime objects +- `ArrayToStringTransformer`: Converts arrays to comma-separated strings +- `JsonStringToArrayTransformer`: Parses JSON strings to arrays + +### View Types + +All views extend `ChamberOrchestra\ViewBundle\View\ViewInterface` (from chamber-orchestra/view-bundle): +- `FailureView`: Generic error response with HTTP status +- `ValidationFailedView`: Form validation errors (422 status) with structured violations +- `ViolationView`: Individual field violation with id, message, parameters, and path +- `RedirectView`: Redirect response for AJAX requests +- `SuccessHtmlView`: HTML fragment response for AJAX requests + +### Validation + +**UniqueField** constraint (`src/Validator/Constraints/`): Validates field uniqueness against Doctrine repositories. Use `repositoryMethod` to specify custom query method, `fields` to check multiple columns, and `errorPath` to target specific form field. + +### Service Configuration + +Services are autowired and autoconfigured via `src/Resources/config/services.php`. The config excludes `DependencyInjection`, `Resources`, `Exception`, `Transformer`, and `View` directories from auto-loading. + +## Testing + +- **Unit tests**: `tests/Unit/` - Test individual classes in isolation +- **Integration tests**: `tests/Integrational/` - Test bundle integration with Symfony and Doctrine +- **Test kernel**: `tests/Integrational/TestKernel.php` boots minimal Symfony application with FrameworkBundle, ChamberOrchestraViewBundle, ChamberOrchestraFormBundle, and optionally DoctrineBundle with in-memory SQLite + +When writing tests, follow the existing pattern: Unit tests under `tests/Unit/` mirroring the `src/` structure, integration tests under `tests/Integrational/` for service wiring and Doctrine integration. + +## Code Style + +- PHP 8.5+ with strict types (`declare(strict_types=1);`) +- PSR-4 autoloading: `ChamberOrchestra\FormBundle\` → `src/` +- One class/interface/trait per file matching the filename +- Follow existing code formatting (PSR-12 conventions) + +## Dependencies + +- Requires Symfony 8.0 components, PHP 8.5, Doctrine ORM 3.6, and chamber-orchestra/view-bundle 8.0 +- Main branch is `8.0` for Symfony 8 compatibility \ No newline at end of file diff --git a/README.md b/README.md index 07652a0..7c1b1f0 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,35 @@ # ChamberOrchestra Form Bundle -Symfony bundle that streamlines JSON‑first form handling for APIs. It provides helper traits for controller flow, specialized form types, reusable transformers, and standardized error views (RFC 7807 style) to keep API responses consistent. +A Symfony 8 bundle for JSON-first API form handling. Provides controller traits for submit/validate/response flow, specialized API form types, reusable data transformers, Doctrine-backed validation, and structured error responses following [RFC 9110](https://datatracker.ietf.org/doc/html/rfc9110#section-15). ## Features -- Controller helpers via `FormTrait` and `ApiFormTrait` for submit/validate/response flow. -- JSON and file payload handling for mutation requests. -- API form base types (`GetForm`, `PostForm`, `QueryForm`, `MutationForm`) with empty block prefixes. -- Custom form types: `BooleanType`, `TimestampType`, `HiddenEntityType`. -- Data transformers for booleans, timestamps, arrays, and JSON strings. -- Problem/validation views and violation mapping for structured error output. -- `UniqueField` validation constraint for Doctrine repositories. -- `TelType` extension to normalize phone input. + +- **Controller helpers** via `FormTrait` and `ApiFormTrait` for submit/validate/response flow with null-safe request handling. +- **JSON and file payload handling** for mutation requests with automatic merging of uploaded files. +- **API form base types** (`QueryForm`, `MutationForm`) with CSRF disabled and empty block prefixes for clean JSON payloads. +- **Custom form types**: `BooleanType`, `TimestampType`, `HiddenEntityType` with secure query builder parameterization. +- **Data transformers** for booleans, Unix timestamps, comma-separated arrays, and JSON strings. +- **RFC 9110 error views** with structured violations for consistent API error responses. +- **`UniqueField` validation constraint** for Doctrine repositories with field name validation and closure-based exclusions. +- **`TelExtension`** to normalize phone number input. +- **`CollectionUtils`** for syncing Doctrine collections. ## Requirements -- PHP 8.4 -- Symfony 8.0 components (framework-bundle, form, validator, serializer, config, dependency-injection, runtime) -- doctrine/orm 3.6.* -- chamber-orchestra/view-bundle 8.0.* + +- PHP ^8.5 +- Symfony 8.0 components (framework-bundle, form, validator, config, dependency-injection, runtime, translation, clock) +- Doctrine ORM 3.6 +- [chamber-orchestra/view-bundle](https://github.com/chamber-orchestra/view-bundle) 8.0 ## Installation + ```bash composer require chamber-orchestra/form-bundle:8.0.* ``` Enable the bundle in `config/bundles.php`: + ```php return [ // ... @@ -33,12 +38,33 @@ return [ ]; ``` -## Usage Overview -- Controller flow helpers live in `src/FormTrait.php` and `src/ApiFormTrait.php`. -- Service wiring lives in `src/Resources/config/services.php`. -- Form types are under `src/Type/`, transformers under `src/Transformer/`. +## Usage + +### Controller Traits + +Controller flow helpers live in `FormTrait` and `ApiFormTrait`. Use `handleFormCall()` for standard form submissions and `handleApiCall()` for JSON API endpoints. + +### API Form Types + +Extend `QueryForm` for GET requests or `MutationForm` for POST/PUT/PATCH requests. Both disable CSRF protection and use empty block prefixes for clean JSON input/output. + +### Data Transformers + +- `TextToBoolTransformer` -- converts `"true"`, `"1"`, `"yes"` to boolean +- `DateTimeToNumberTransformer` -- converts Unix timestamps to `DateTimeInterface` objects +- `ArrayToStringTransformer` -- converts arrays to/from comma-separated strings +- `JsonStringToArrayTransformer` -- parses JSON strings to arrays (handles empty strings) + +### HiddenEntityType + +Loads Doctrine entities by ID from a hidden form field. Supports custom `query_builder` with secure parameterized queries. + +### UniqueField Validator + +Validates field uniqueness against Doctrine repositories. Supports multiple fields, closure-based exclusions, custom normalizers, and targeted error paths. + +## Example -## ApiFormTrait Example ```php use ChamberOrchestra\FormBundle\ApiFormTrait; use ChamberOrchestra\ViewBundle\View\ViewInterface; @@ -69,15 +95,17 @@ final class SearchCourseAction } ``` -## Tests -Install dependencies: -```bash -composer install -``` +## Testing + +Install dependencies and run the full test suite: -Run the full suite: ```bash +composer install ./vendor/bin/phpunit ``` -The test kernel for integration tests is `tests/Integrational/TestKernel.php`. +The integration test kernel (`tests/Integrational/TestKernel.php`) boots a minimal Symfony application with in-memory SQLite for Doctrine tests. + +## License + +MIT diff --git a/composer.json b/composer.json index f49f1b1..1979335 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,18 @@ { "name": "chamber-orchestra/form-bundle", "type": "library", - "description": "The symfony form bundle for JSON API responses", + "description": "Symfony 8 bundle for JSON-first API form handling with RFC 9110 error responses, data transformers, and Doctrine validation", "keywords": [ "symfony", - "form-bundle", + "symfony8", "form", "api", "json", - "typescript" + "rest", + "rfc9110", + "validation", + "doctrine", + "data-transformer" ], "license": "MIT", "authors": [ @@ -20,7 +24,7 @@ } ], "require": { - "php": "^8.4", + "php": "^8.5", "chamber-orchestra/view-bundle": "8.0.*", "symfony/dependency-injection": "8.0.*", "symfony/config": "8.0.*", @@ -33,7 +37,7 @@ "doctrine/orm": "3.6.*" }, "require-dev": { - "phpunit/phpunit": "^12.5", + "phpunit/phpunit": "^13.0", "symfony/test-pack": "^1.2", "doctrine/doctrine-bundle": "^3.2" }, diff --git a/src/ApiFormTrait.php b/src/ApiFormTrait.php index 745cf72..7689ab1 100644 --- a/src/ApiFormTrait.php +++ b/src/ApiFormTrait.php @@ -15,25 +15,35 @@ use ChamberOrchestra\ViewBundle\View\ViewInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; trait ApiFormTrait { use FormTrait; - protected function handleApiCall(FormInterface|string $form, callable|null $callable = null): ViewInterface + protected function handleApiCall(FormInterface|string $form, callable|null $callable = null): Response|ViewInterface { - $request = $this->container->get('request_stack')->getCurrentRequest(); + $request = $this->getCurrentRequest(); + if ($request === null) { + throw new \LogicException('Cannot handle API call without an active request.'); + } + if (!$form instanceof FormInterface && \is_string($form)) { $form = $this->container->get('form.factory')->create($form); } - return $this->onFormSubmitted( - $form->getConfig()->getType()->getInnerType() instanceof MutationForm - ? $form->submit($this->convertRequestToArray($request)) - : $form->handleRequest($request), - $callable - ); + if ($form->getConfig()->getType()->getInnerType() instanceof MutationForm) { + $form->submit($this->convertRequestToArray($request)); + } else { + $form->handleRequest($request); + } + + if (!$form->isSubmitted()) { + throw $this->createSubmittedFormRequiredException($form::class); + } + + return $this->onFormSubmitted($form, $callable); } private function convertRequestToArray(Request $request): array diff --git a/src/ChamberOrchestraFormBundle.php b/src/ChamberOrchestraFormBundle.php index 26fbdc1..d7d314d 100644 --- a/src/ChamberOrchestraFormBundle.php +++ b/src/ChamberOrchestraFormBundle.php @@ -13,6 +13,6 @@ use Symfony\Component\HttpKernel\Bundle\Bundle; -class ChamberOrchestraFormBundle extends Bundle +final class ChamberOrchestraFormBundle extends Bundle { } \ No newline at end of file diff --git a/src/Exception/TranslatableExceptionInterface.php b/src/Exception/TranslatableExceptionInterface.php index 29e70db..d55506a 100644 --- a/src/Exception/TranslatableExceptionInterface.php +++ b/src/Exception/TranslatableExceptionInterface.php @@ -16,4 +16,4 @@ interface TranslatableExceptionInterface { public function getTranslatableMessage(): TranslatableInterface; -} \ No newline at end of file +} diff --git a/src/Extension/TelExtension.php b/src/Extension/TelExtension.php index 1aa70e5..6c46f81 100644 --- a/src/Extension/TelExtension.php +++ b/src/Extension/TelExtension.php @@ -35,7 +35,7 @@ function (string|null $value): string|null { return null; } - return \preg_replace('/[^\d]/', '', $value); + return \preg_replace('/[^\d]/', '', $value) ?? ''; } ) ); diff --git a/src/FormTrait.php b/src/FormTrait.php index ae4acb8..00ae862 100644 --- a/src/FormTrait.php +++ b/src/FormTrait.php @@ -43,9 +43,8 @@ protected function createRedirectResponse( string $url, int $status = Response::HTTP_MOVED_PERMANENTLY ): Response|RedirectView { - /** @var Request $request */ - $request = $this->container->get('request_stack')->getCurrentRequest(); - if ($request->isXmlHttpRequest()) { + $request = $this->getCurrentRequest(); + if ($request !== null && $request->isXmlHttpRequest()) { return new RedirectView($url, $status); } @@ -67,8 +66,8 @@ protected function createExceptionResponse(): FailureView protected function createSuccessHtmlResponse(string $view, array $parameters = []): Response|SuccessHtmlView { - $request = $this->container->get('request_stack')->getCurrentRequest(); - if ($request->isXmlHttpRequest()) { + $request = $this->getCurrentRequest(); + if ($request !== null && $request->isXmlHttpRequest()) { return new SuccessHtmlView([ 'html' => $this->renderView($view, $parameters), ]); @@ -105,11 +104,15 @@ protected function handleFormCall( $form = $this->container->get('form.factory')->create($form); } - $request = $this->container->get('request_stack')->getCurrentRequest(); + $request = $this->getCurrentRequest(); + if ($request === null) { + throw new \LogicException('Cannot handle form call without an active request.'); + } + $form->handleRequest($request); if (!$form->isSubmitted()) { - throw $this->createSubmittedFormRequiredException(\get_class($form)); + throw $this->createSubmittedFormRequiredException($form::class); } return $this->createSubmittedFormResponse($form, $callable); @@ -150,7 +153,7 @@ protected function onFormSubmitted(FormInterface $form, callable|null $callable protected function serializeFormErrors(FormInterface $form): array { - return $this->serialiseErrors($form->getErrors(true, false)); + return $this->serializeErrors($form->getErrors(true, false)); } protected function createNotXmlHttpRequestException(): XmlHttpRequestRequiredException @@ -163,7 +166,12 @@ protected function createSubmittedFormRequiredException(string $type): Submitted return new SubmittedFormRequiredException($type); } - private function serialiseErrors(FormErrorIterator $iterator, array $paths = []): array + private function getCurrentRequest(): ?Request + { + return $this->container->get('request_stack')->getCurrentRequest(); + } + + private function serializeErrors(FormErrorIterator $iterator, array $paths = []): array { if ('' !== $name = $iterator->getForm()->getName()) { $paths[] = $name; @@ -174,7 +182,7 @@ private function serialiseErrors(FormErrorIterator $iterator, array $paths = []) $violations = []; foreach ($iterator as $formErrorIterator) { if ($formErrorIterator instanceof FormErrorIterator) { - $violations = \array_merge($violations, $this->serialiseErrors($formErrorIterator, $paths)); + \array_push($violations, ...$this->serializeErrors($formErrorIterator, $paths)); continue; } diff --git a/src/Serializer/Normalizer/ProblemNormalizer.php b/src/Serializer/Normalizer/ProblemNormalizer.php index cb5501c..d3f362e 100644 --- a/src/Serializer/Normalizer/ProblemNormalizer.php +++ b/src/Serializer/Normalizer/ProblemNormalizer.php @@ -35,4 +35,4 @@ public function normalize(mixed $object, ?string $format = null, array $context return $data; } -} \ No newline at end of file +} diff --git a/src/Transformer/ArrayToStringTransformer.php b/src/Transformer/ArrayToStringTransformer.php index fe7f10a..bf29765 100644 --- a/src/Transformer/ArrayToStringTransformer.php +++ b/src/Transformer/ArrayToStringTransformer.php @@ -36,7 +36,7 @@ public function reverseTransform($value): array } return \array_map( - fn(string $value) => \preg_replace('/[^\d]/ui', '', $value), + fn(string $value): string => \preg_replace('/[^\d]/', '', $value), \explode(',', $value) ); } diff --git a/src/Transformer/DateTimeToNumberTransformer.php b/src/Transformer/DateTimeToNumberTransformer.php index 25617d2..c37ae62 100644 --- a/src/Transformer/DateTimeToNumberTransformer.php +++ b/src/Transformer/DateTimeToNumberTransformer.php @@ -17,8 +17,10 @@ { public function __construct(private string $class) { - if (!\in_array(\DateTimeInterface::class, \class_implements($class))) { - throw new \TypeError(\sprintf('Passed value must implement %s.', \DateTimeInterface::class)); + if (!\in_array(\DateTimeInterface::class, \class_implements($class) ?: [])) { + throw new \InvalidArgumentException( + \sprintf('Class "%s" must implement %s.', $class, \DateTimeInterface::class) + ); } } @@ -39,6 +41,6 @@ public function reverseTransform($value): \DateTimeInterface|null ); } - return $value ? (new $this->class)->setTimestamp($value) : null; + return $value !== null ? (new $this->class)->setTimestamp($value) : null; } } \ No newline at end of file diff --git a/src/Transformer/JsonStringToArrayTransformer.php b/src/Transformer/JsonStringToArrayTransformer.php index 4483084..0e8869d 100644 --- a/src/Transformer/JsonStringToArrayTransformer.php +++ b/src/Transformer/JsonStringToArrayTransformer.php @@ -33,7 +33,7 @@ public function transform($value): string|null public function reverseTransform($value): array|null { - if (null === $value) { + if (null === $value || '' === $value) { return null; } diff --git a/src/Type/Api/MutationForm.php b/src/Type/Api/MutationForm.php index 832cc3a..fdf3073 100644 --- a/src/Type/Api/MutationForm.php +++ b/src/Type/Api/MutationForm.php @@ -12,9 +12,17 @@ namespace ChamberOrchestra\FormBundle\Type\Api; use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; abstract class MutationForm extends AbstractType { + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => false, + ]); + } + public function getBlockPrefix(): string { return ''; diff --git a/src/Type/Api/QueryForm.php b/src/Type/Api/QueryForm.php index 923b9fc..e0f5494 100644 --- a/src/Type/Api/QueryForm.php +++ b/src/Type/Api/QueryForm.php @@ -12,9 +12,17 @@ namespace ChamberOrchestra\FormBundle\Type\Api; use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; abstract class QueryForm extends AbstractType { + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => false, + ]); + } + public function getBlockPrefix(): string { return ''; diff --git a/src/Type/HiddenEntityType.php b/src/Type/HiddenEntityType.php index e8d0e36..d327211 100644 --- a/src/Type/HiddenEntityType.php +++ b/src/Type/HiddenEntityType.php @@ -32,10 +32,9 @@ public function __construct(private readonly EntityManagerInterface $em) public function buildForm(FormBuilderInterface $builder, array $options): void { $em = $this->em; - $that = $this; $builder->addViewTransformer( new CallbackTransformer( - function (object|null $value) use ($options, $em) { + function (object|null $value) use ($options, $em): string|null { if (null === $value) { return null; } @@ -43,23 +42,23 @@ function (object|null $value) use ($options, $em) { $class = $em->getClassMetadata($options['class']); $id = $class->getFieldValue($value, $options['choice_value']); - if (!\is_scalar($id) && !\is_object($id) && !\method_exists($id, '__toString')) { + if (!\is_scalar($id) && (!\is_object($id) || !\method_exists($id, '__toString'))) { throw TransformationFailedException::notAllowedType($value, ['scalar', 'string']); } return (string)$id; }, - function ($id) use ($options, $em, $that) { + function (mixed $id) use ($options, $em): object|null { if (!\is_scalar($id)) { throw TransformationFailedException::notAllowedType($id, ['scalar']); } - if (null === $id || false == $id) { + if ($id === null || $id === false || $id === '') { return null; } if (null !== $options['query_builder']) { - $qb = $that->prepareQueryBuilder($options['query_builder'], $options['choice_value'], $id); + $qb = $this->prepareQueryBuilder($options['query_builder'], $options['choice_value'], $id); $entity = $qb->getQuery()->getOneOrNullResult(); } else { $er = $em->getRepository($options['class']); @@ -93,7 +92,16 @@ public function configureOptions(OptionsResolver $resolver): void ->setRequired('class') ->setAllowedTypes('class', 'string') ->setAllowedValues('class', function ($value) use ($em): bool { - return \class_exists($value) && $this->em->getClassMetadata($value); + if (!\class_exists($value)) { + return false; + } + + try { + $em->getClassMetadata($value); + return true; + } catch (\Throwable) { + return false; + } }); $resolver @@ -112,7 +120,7 @@ public function configureOptions(OptionsResolver $resolver): void \sprintf( 'Parameter "query_builder" must return instance of "%s", "%s" returned.', QueryBuilder::class, - \get_class($qb) + \get_debug_type($qb) ) ); } @@ -144,13 +152,24 @@ public function getParent(): string return HiddenType::class; } - private function prepareQueryBuilder(QueryBuilder $qb, string $idFieldName, $id): QueryBuilder + private function prepareQueryBuilder(QueryBuilder $qb, string $idFieldName, mixed $id): QueryBuilder { - $alias = $qb->getRootAliases()[0]; - $param = 'param'.\uniqid(); + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $idFieldName)) { + throw new InvalidArgumentException( + \sprintf('Invalid field name "%s".', $idFieldName) + ); + } + + $aliases = $qb->getRootAliases(); + if (empty($aliases)) { + throw new InvalidArgumentException('QueryBuilder must have at least one root alias.'); + } + + $alias = $aliases[0]; + $param = 'param_'.\bin2hex(\random_bytes(8)); $qb - ->andWhere($qb->expr()->eq($alias.'.'.$idFieldName, ':'.$param)) + ->andWhere($qb->expr()->eq(\sprintf('%s.%s', $alias, $idFieldName), ':'.$param)) ->setParameter($param, $id); return $qb; diff --git a/src/Type/TimestampType.php b/src/Type/TimestampType.php index 60dce32..5f3d49f 100644 --- a/src/Type/TimestampType.php +++ b/src/Type/TimestampType.php @@ -36,9 +36,7 @@ public function configureOptions(OptionsResolver $resolver): void public function buildForm(FormBuilderInterface $builder, array $options): void { - match ($options['input']) { - 'datetime', 'datetime_immutable' => $builder->addModelTransformer(new DateTimeToNumberTransformer(DatePoint::class)), - }; + $builder->addModelTransformer(new DateTimeToNumberTransformer(DatePoint::class)); } public function getParent(): string diff --git a/src/Utils/CollectionUtils.php b/src/Utils/CollectionUtils.php index d190902..d48d927 100644 --- a/src/Utils/CollectionUtils.php +++ b/src/Utils/CollectionUtils.php @@ -1,6 +1,6 @@ |\Closure AND condition */ - public $exclude = []; + public array|\Closure $exclude = []; public string|null $errorPath = null; public ?\Closure $normalizer = null; public bool $allowEmptyString = false; @@ -38,10 +37,6 @@ class UniqueField extends Constraint public function __construct(?array $options = null) { parent::__construct($options); - - if (!\is_array($this->exclude) && !\is_callable($this->exclude)) { - throw new UnexpectedTypeException($this->exclude, 'string[]|callable'); - } } public function getTargets(): array diff --git a/src/Validator/Constraints/UniqueFieldValidator.php b/src/Validator/Constraints/UniqueFieldValidator.php index 8b8d9c6..3f71c72 100644 --- a/src/Validator/Constraints/UniqueFieldValidator.php +++ b/src/Validator/Constraints/UniqueFieldValidator.php @@ -40,9 +40,16 @@ public function validate($value, Constraint $constraint): void return; } + if (empty($constraint->fields)) { + throw new ConstraintDefinitionException('UniqueField constraint requires at least one field.'); + } + $normalized = $value; if (null !== $constraint->normalizer) { - $normalized = \call_user_func($constraint->normalizer, $value); + $normalized = ($constraint->normalizer)($value); + if (null === $normalized) { + return; + } } $criteria = $this->buildCriteria($constraint, $normalized, $value); @@ -102,7 +109,7 @@ private function getManager(UniqueField $constraint): ObjectManager $em = $this->doctrine->getManagerForClass($constraint->entityClass); if (null === $em) { throw new ConstraintDefinitionException( - sprintf('Class "%s" is not managed by doctrine', $constraint->entityClass) + \sprintf('Class "%s" is not managed by Doctrine.', $constraint->entityClass) ); } @@ -136,6 +143,7 @@ private function buildCriteria(UniqueField $constraint, $value, $origin): Criter // build includes fields with OR join foreach ($constraint->fields as $field) { + $this->assertValidFieldName($field); $this->addComparisonToCriteria( $criteria, $this->buildComparison($field, $value), @@ -144,24 +152,32 @@ private function buildCriteria(UniqueField $constraint, $value, $origin): Criter } // build exclude fields with AND join - $exclude = \is_callable($constraint->exclude) - ? \call_user_func($constraint->exclude, $origin, $value) + $exclude = $constraint->exclude instanceof \Closure + ? ($constraint->exclude)($origin, $value) : $constraint->exclude; if (!\is_array($exclude)) { - throw new ConstraintDefinitionException('Constraint `exclude` as callable must return array.'); + throw new ConstraintDefinitionException('Constraint `exclude` as Closure must return array.'); } - if (\count($exclude)) { - foreach ($exclude as $field => $param) { - $this->addComparisonToCriteria( - $criteria, - $this->buildComparison($field, $param, true), - CompositeExpression::TYPE_AND - ); - } + foreach ($exclude as $field => $param) { + $this->assertValidFieldName($field); + $this->addComparisonToCriteria( + $criteria, + $this->buildComparison($field, $param, true), + CompositeExpression::TYPE_AND + ); } return $criteria; } + + private function assertValidFieldName(mixed $field): void + { + if (!\is_string($field) || !\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $field)) { + throw new ConstraintDefinitionException( + \sprintf('Invalid field name "%s" in constraint criteria.', $field) + ); + } + } } diff --git a/src/View/FailureView.php b/src/View/FailureView.php index 534d2d4..641dbd2 100644 --- a/src/View/FailureView.php +++ b/src/View/FailureView.php @@ -17,7 +17,7 @@ class FailureView extends ResponseView { - protected string $type = 'https://tools.ietf.org/html/rfc2616#section-10'; + protected string $type = 'https://datatracker.ietf.org/doc/html/rfc9110#section-15'; protected readonly string $title; public function __construct(int $status = JsonResponse::HTTP_BAD_REQUEST, string $message = 'Validation Failed') diff --git a/tests/Unit/ApiFormTraitTest.php b/tests/Unit/ApiFormTraitTest.php index 8676053..56825af 100644 --- a/tests/Unit/ApiFormTraitTest.php +++ b/tests/Unit/ApiFormTraitTest.php @@ -6,7 +6,10 @@ use ChamberOrchestra\FormBundle\ApiFormTrait; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; final class ApiFormTraitTest extends TestCase @@ -63,4 +66,56 @@ public function exposeConvertRequestToArray(Request $request): array $host->exposeConvertRequestToArray($request); } + + public function testConvertRequestToArrayWithFilesOnly(): void + { + $host = new class() { + use ApiFormTrait; + + public function exposeConvertRequestToArray(Request $request): array + { + return $this->convertRequestToArray($request); + } + }; + + $request = Request::create('/test', 'POST'); + $request->files->set('file', ['name' => 'upload.txt']); + + $result = $host->exposeConvertRequestToArray($request); + + self::assertSame(['file' => ['name' => 'upload.txt']], $result); + } + + public function testHandleApiCallThrowsOnNullRequest(): void + { + $stack = new RequestStack(); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); + + $host = new class($container) { + use ApiFormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function exposeHandleApiCall(FormInterface|string $form): mixed + { + return $this->handleApiCall($form); + } + }; + + $form = $this->createStub(FormInterface::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot handle API call without an active request.'); + + $host->exposeHandleApiCall($form); + } } diff --git a/tests/Unit/FormTraitTest.php b/tests/Unit/FormTraitTest.php index a9b2a88..8a1f2ca 100644 --- a/tests/Unit/FormTraitTest.php +++ b/tests/Unit/FormTraitTest.php @@ -195,7 +195,9 @@ public function testCreateRedirectResponseReturnsViewForXmlHttpRequest(): void $stack->push($request); $container = $this->createStub(ContainerInterface::class); - $container->method('get')->with('request_stack')->willReturn($stack); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); $host = new class($container) { use FormTrait; @@ -234,7 +236,9 @@ public function testCreateRedirectResponseReturnsResponseForNonXmlHttpRequest(): $stack->push($request); $container = $this->createStub(ContainerInterface::class); - $container->method('get')->with('request_stack')->willReturn($stack); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); $host = new class($container) { use FormTrait; @@ -273,7 +277,9 @@ public function testCreateSuccessHtmlResponseHandlesXmlHttpRequest(): void $stack->push($request); $container = $this->createStub(ContainerInterface::class); - $container->method('get')->with('request_stack')->willReturn($stack); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); $host = new class($container) { use FormTrait; @@ -316,7 +322,9 @@ public function testCreateSuccessHtmlResponseHandlesNonXmlHttpRequest(): void $stack->push($request); $container = $this->createStub(ContainerInterface::class); - $container->method('get')->with('request_stack')->willReturn($stack); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); $host = new class($container) { use FormTrait; @@ -349,4 +357,74 @@ public function exposeCreateSuccessHtmlResponse(string $view, array $parameters self::assertInstanceOf(Response::class, $response); self::assertSame('html', $response->getContent()); } + + public function testHandleFormCallThrowsOnNullRequest(): void + { + $stack = new RequestStack(); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturnCallback(function (string $id) use ($stack) { + return match ($id) { + 'request_stack' => $stack, + 'form.factory' => Forms::createFormFactory(), + }; + }); + $container->method('has')->willReturn(true); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + public function exposeHandleFormCall(FormInterface|string $form): mixed + { + return $this->handleFormCall($form); + } + }; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot handle form call without an active request.'); + + $host->exposeHandleFormCall(FormType::class); + } + + public function testCreateRedirectResponseFallsBackWhenNoRequest(): void + { + $stack = new RequestStack(); + + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturnCallback(fn(string $id) => match ($id) { + 'request_stack' => $stack, + }); + + $host = new class($container) { + use FormTrait; + + protected \Psr\Container\ContainerInterface $container; + + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + protected function redirect(string $url, int $status = 302): RedirectResponse + { + return new RedirectResponse($url, $status); + } + + public function exposeCreateRedirectResponse(string $url, int $status): Response + { + return $this->createRedirectResponse($url, $status); + } + }; + + $response = $host->exposeCreateRedirectResponse('/target', Response::HTTP_FOUND); + + self::assertInstanceOf(RedirectResponse::class, $response); + } } diff --git a/tests/Unit/Transformer/DateTimeToNumberTransformerTest.php b/tests/Unit/Transformer/DateTimeToNumberTransformerTest.php index bfbabbc..4adf2d2 100644 --- a/tests/Unit/Transformer/DateTimeToNumberTransformerTest.php +++ b/tests/Unit/Transformer/DateTimeToNumberTransformerTest.php @@ -36,10 +36,52 @@ public function testDatePointTransformAndReverseTransform(): void self::assertSame($date->getTimestamp(), $restored->getTimestamp()); } - public function testConstructorRejectsInvalidClass(): void + public function testReverseTransformHandlesTimestampZero(): void + { + $transformer = new DateTimeToNumberTransformer(\DateTimeImmutable::class); + + $result = $transformer->reverseTransform(0); + + self::assertInstanceOf(\DateTimeImmutable::class, $result); + self::assertSame(0, $result->getTimestamp()); + } + + public function testTransformReturnsNullForNull(): void + { + $transformer = new DateTimeToNumberTransformer(\DateTimeImmutable::class); + + self::assertNull($transformer->transform(null)); + } + + public function testReverseTransformReturnsNullForNull(): void { + $transformer = new DateTimeToNumberTransformer(\DateTimeImmutable::class); + + self::assertNull($transformer->reverseTransform(null)); + } + + public function testTransformRejectsInvalidType(): void + { + $transformer = new DateTimeToNumberTransformer(\DateTimeImmutable::class); + $this->expectException(\TypeError::class); + $transformer->transform('not-a-date'); + } + + public function testReverseTransformRejectsInvalidType(): void + { + $transformer = new DateTimeToNumberTransformer(\DateTimeImmutable::class); + + $this->expectException(\TypeError::class); + + $transformer->reverseTransform('not-an-int'); + } + + public function testConstructorRejectsInvalidClass(): void + { + $this->expectException(\InvalidArgumentException::class); + new DateTimeToNumberTransformer(\stdClass::class); } } diff --git a/tests/Unit/Transformer/JsonStringToArrayTransformerTest.php b/tests/Unit/Transformer/JsonStringToArrayTransformerTest.php index bed5c94..62e7b8d 100644 --- a/tests/Unit/Transformer/JsonStringToArrayTransformerTest.php +++ b/tests/Unit/Transformer/JsonStringToArrayTransformerTest.php @@ -29,4 +29,25 @@ public function testReverseTransformRejectsInvalidJson(): void $transformer->reverseTransform('{'); } + + public function testTransformReturnsNullForNull(): void + { + $transformer = new JsonStringToArrayTransformer(); + + self::assertNull($transformer->transform(null)); + } + + public function testReverseTransformReturnsNullForNull(): void + { + $transformer = new JsonStringToArrayTransformer(); + + self::assertNull($transformer->reverseTransform(null)); + } + + public function testReverseTransformReturnsNullForEmptyString(): void + { + $transformer = new JsonStringToArrayTransformer(); + + self::assertNull($transformer->reverseTransform('')); + } } diff --git a/tests/Unit/Type/Api/MutationFormTest.php b/tests/Unit/Type/Api/MutationFormTest.php index 8270e9c..92ffb2b 100644 --- a/tests/Unit/Type/Api/MutationFormTest.php +++ b/tests/Unit/Type/Api/MutationFormTest.php @@ -7,6 +7,7 @@ use ChamberOrchestra\FormBundle\Type\Api\MutationForm; use ChamberOrchestra\FormBundle\Type\Api\PostForm; use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\OptionsResolver; final class MutationFormTest extends TestCase { @@ -17,4 +18,15 @@ public function testParentAndBlockPrefix(): void self::assertSame('', $form->getBlockPrefix()); self::assertSame(PostForm::class, $form->getParent()); } + + public function testCsrfProtectionIsDisabled(): void + { + $form = new class() extends MutationForm {}; + $resolver = new OptionsResolver(); + + $form->configureOptions($resolver); + $options = $resolver->resolve([]); + + self::assertFalse($options['csrf_protection']); + } } diff --git a/tests/Unit/Type/Api/QueryFormTest.php b/tests/Unit/Type/Api/QueryFormTest.php index cfb0693..209f64e 100644 --- a/tests/Unit/Type/Api/QueryFormTest.php +++ b/tests/Unit/Type/Api/QueryFormTest.php @@ -7,6 +7,7 @@ use ChamberOrchestra\FormBundle\Type\Api\GetForm; use ChamberOrchestra\FormBundle\Type\Api\QueryForm; use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\OptionsResolver; final class QueryFormTest extends TestCase { @@ -17,4 +18,15 @@ public function testParentAndBlockPrefix(): void self::assertSame('', $form->getBlockPrefix()); self::assertSame(GetForm::class, $form->getParent()); } + + public function testCsrfProtectionIsDisabled(): void + { + $form = new class() extends QueryForm {}; + $resolver = new OptionsResolver(); + + $form->configureOptions($resolver); + $options = $resolver->resolve([]); + + self::assertFalse($options['csrf_protection']); + } } diff --git a/tests/Unit/Validator/Constraints/UniqueFieldValidatorTest.php b/tests/Unit/Validator/Constraints/UniqueFieldValidatorTest.php index 91caf5b..a9d385b 100644 --- a/tests/Unit/Validator/Constraints/UniqueFieldValidatorTest.php +++ b/tests/Unit/Validator/Constraints/UniqueFieldValidatorTest.php @@ -98,6 +98,57 @@ public function testExcludeCallableMustReturnArray(): void $this->validator->validate('value', $constraint); } + public function testEmptyFieldsThrows(): void + { + $constraint = new UniqueField(); + $constraint->entityClass = DummyEntity::class; + $constraint->fields = []; + + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('requires at least one field'); + + $this->validator->validate('value', $constraint); + } + + public function testNormalizerReturningNullSkipsValidation(): void + { + $this->repository = new SelectableRepository(1); + + $constraint = new UniqueField(); + $constraint->entityClass = DummyEntity::class; + $constraint->fields = ['email']; + $constraint->normalizer = static fn() => null; + + $this->validator->validate('value', $constraint); + + $this->assertNoViolation(); + } + + public function testInvalidFieldNameThrows(): void + { + $constraint = new UniqueField(); + $constraint->entityClass = DummyEntity::class; + $constraint->fields = ['email; DROP TABLE users']; + + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('Invalid field name'); + + $this->validator->validate('value', $constraint); + } + + public function testInvalidExcludeFieldNameThrows(): void + { + $constraint = new UniqueField(); + $constraint->entityClass = DummyEntity::class; + $constraint->fields = ['email']; + $constraint->exclude = ['id; DROP TABLE' => 1]; + + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('Invalid field name'); + + $this->validator->validate('value', $constraint); + } + public function testRepositoryMustBeSelectable(): void { $this->repository = new NonSelectableRepository(); diff --git a/tests/Unit/View/FailureViewTest.php b/tests/Unit/View/FailureViewTest.php index bf5f8e0..9b7dce5 100644 --- a/tests/Unit/View/FailureViewTest.php +++ b/tests/Unit/View/FailureViewTest.php @@ -32,6 +32,6 @@ public function testNormalizeUsesNormalizer(): void $data = $view->normalize($normalizer); self::assertSame('Bad', $data['title']); - self::assertSame('https://tools.ietf.org/html/rfc2616#section-10', $data['type']); + self::assertSame('https://datatracker.ietf.org/doc/html/rfc9110#section-15', $data['type']); } } From b68336e6a5c28b092f8f7a18b8f477f551f19363 Mon Sep 17 00:00:00 2001 From: Dev Date: Sat, 14 Feb 2026 20:56:19 +0000 Subject: [PATCH 2/2] Exclude Utils from service auto-loading and use explicit array - Add Utils/ to exclusion list (CollectionUtils is static-only) - Replace glob brace expansion with explicit array for readability Co-Authored-By: Claude Opus 4.6 --- src/Resources/config/services.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 4d5c8e7..e15ed68 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -16,7 +16,14 @@ $services ->load('ChamberOrchestra\\FormBundle\\', __DIR__.'/../../') - ->exclude(__DIR__.'/../../{DependencyInjection,Resources,Exception,Transformer,View}'); + ->exclude([ + __DIR__.'/../../DependencyInjection/', + __DIR__.'/../../Resources/', + __DIR__.'/../../Exception/', + __DIR__.'/../../Transformer/', + __DIR__.'/../../Utils/', + __DIR__.'/../../View/', + ]); $services ->set(ProblemNormalizer::class)