From 89af7983677a93d0a21264d4aeb9d959f394203a Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Mon, 20 Apr 2026 13:39:08 -0300 Subject: [PATCH] fix: PSR-7 compliance in response status and stream IO. --- .claude/CLAUDE.md | 6 +- .claude/rules/php-domain.md | 96 -------------- ...ode-style.md => php-library-code-style.md} | 71 +++++++--- ...tation.md => php-library-documentation.md} | 9 +- .claude/rules/php-library-modeling.md | 125 ++++++++++++++++++ ...{php-testing.md => php-library-testing.md} | 24 ++-- .editorconfig | 18 +++ .gitattributes | 43 ++++-- .gitignore | 2 +- Makefile | 9 +- README.md | 10 +- composer.json | 68 ++++------ infection.json.dist | 3 +- src/Internal/Exceptions/BadMethodCall.php | 17 --- src/Internal/Response/InternalResponse.php | 8 +- src/Internal/Stream/Stream.php | 14 +- tests/Drivers/Laminas/LaminasTest.php | 4 +- tests/Drivers/Slim/SlimTest.php | 4 +- .../Request/RouteParameterResolverTest.php | 18 +-- tests/Internal/Stream/StreamFactoryTest.php | 4 +- tests/Internal/Stream/StreamTest.php | 14 ++ tests/RequestTest.php | 48 +++---- tests/ResponseTest.php | 27 +--- 23 files changed, 356 insertions(+), 286 deletions(-) delete mode 100644 .claude/rules/php-domain.md rename .claude/rules/{php-code-style.md => php-library-code-style.md} (62%) rename .claude/rules/{documentation.md => php-library-documentation.md} (82%) create mode 100644 .claude/rules/php-library-modeling.md rename .claude/rules/{php-testing.md => php-library-testing.md} (82%) create mode 100644 .editorconfig delete mode 100644 src/Internal/Exceptions/BadMethodCall.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3e73900..11885b0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,10 +1,12 @@ # Project -PHP microservices platform. Hexagonal architecture (ports & adapters), DDD, CQRS. +PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure +dependencies in core, small public surface area. Public API at `src/` root; implementation details +under `src/Internal/`. ## Rules -All coding standards, architecture, naming, testing, documentation, and OpenAPI conventions +All coding standards, architecture, naming, testing, and documentation conventions are defined in `rules/`. Read the applicable rule files before generating any code or documentation. ## Commands diff --git a/.claude/rules/php-domain.md b/.claude/rules/php-domain.md deleted file mode 100644 index f3b0eea..0000000 --- a/.claude/rules/php-domain.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -description: Domain modeling rules for PHP libraries — folder structure, naming, value objects, exceptions, enums, and SOLID. -paths: - - "src/**/*.php" ---- - -# Domain modeling - -Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. -Refer to `rules/code-style.md` for the pre-output checklist applied to all PHP code. - -## Folder structure - -``` -src/ -├── .php # Primary contract for consumers -├── .php # Main implementation or extension point -├── .php # Public enum -├── Contracts/ # Interfaces for data returned to consumers -├── Internal/ # Implementation details (not part of public API) -│ ├── .php -│ └── Exceptions/ # Internal exception classes -├── / # Feature-specific subdirectory when needed -└── Exceptions/ # Public exception classes (when part of the API) -``` - -**Public API boundary:** Only interfaces, extension points, enums, and thin orchestration classes live at the -`src/` root. These classes define the contract consumers interact with and delegate all real work to collaborators -inside `src/Internal/`. If a class contains substantial logic (algorithms, state machines, I/O), it belongs in -`Internal/`, not at the root. - -The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. -Never use `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. - -## Nomenclature - -1. Every class, property, method, and exception name reflects the **domain concept** the library represents. - A math library uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection - library uses `Collectible`, `Order`. -2. Never use generic technical names: `Manager`, `Helper`, `Processor`, `Data`, `Info`, `Utils`, - `Item`, `Record`, `Entity`, `Exception`, `Ensure`, `Validate`, `Check`, `Verify`, - `Assert`, `Transform`, `Parse`, `Compute`, `Sanitize`, or `Normalize` as class suffixes or prefixes. -3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. -4. Name methods after the operation in domain terms: `add()`, `convertTo()`, `splitAt()` — not `process()`, - `handle()`, `execute()`, `manage()`, `ensure()`, `validate()`, `check()`, `verify()`, `assert()`, - `transform()`, `parse()`, `compute()`, `sanitize()`, or `normalize()`. - -## Value objects - -1. Are immutable: no setters, no mutation after construction. Operations return new instances. -2. Compare by value, not by reference. -3. Validate invariants in the constructor and throw on invalid input. -4. Have no identity field. -5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation - paths exist. - -## Exceptions - -1. Extend native PHP exceptions (`DomainException`, `InvalidArgumentException`, `OverflowException`, etc.). -2. Are pure: no formatted `code`/`message` for HTTP responses. -3. Signal invariant violations only. -4. Name after the invariant violated, never after the technical type: - `PrecisionOutOfRange` — not `InvalidPrecisionException`. - `CurrencyMismatch` — not `BadCurrencyException`. - `ContainerWaitTimeout` — not `TimeoutException`. -5. Create the exception class directly with the invariant name and the appropriate native parent. The exception - is dedicated by definition when its name describes the specific invariant it guards. - -## Enums - -1. Are PHP backed enums. -2. Include domain-meaningful methods when needed (e.g., `Order::ASCENDING_KEY`). - -## Extension points - -1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` - instead of `final readonly class`. All other classes use `final readonly class`. -2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) - as the only creation path. -3. Internal state is injected via the constructor and stored in a `private readonly` property. - -## Principles - -- **Immutability**: all models and value objects adopt immutability. Operations return new instances. -- **Zero dependencies**: the library's core has no dependency on frameworks, databases, or I/O. -- **Small surface area**: expose only what consumers need. Hide implementation in `Internal/`. - -## SOLID reference - -| Principle | Failure signal | -|---------------------------|---------------------------------------------| -| S — Single responsibility | Class does two unrelated things | -| O — Open/closed | Adding a feature requires editing internals | -| L — Liskov substitution | Subclass throws on parent method | -| I — Interface segregation | Interface has unused methods | -| D — Dependency inversion | Constructor uses `new ConcreteClass()` | diff --git a/.claude/rules/php-code-style.md b/.claude/rules/php-library-code-style.md similarity index 62% rename from .claude/rules/php-code-style.md rename to .claude/rules/php-library-code-style.md index 59323ba..7ec196e 100644 --- a/.claude/rules/php-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -1,14 +1,14 @@ --- -description: Pre-output checklist, naming, typing, comparisons, and PHPDoc rules for all PHP files in libraries. +description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. paths: - - "src/**/*.php" - - "tests/**/*.php" + - "src/**/*.php" + - "tests/**/*.php" --- # Code style Semantic code rules for all PHP files. Formatting rules (PSR-1, PSR-4, PSR-12, line length) are enforced by `phpcs.xml` -and are not repeated here. Refer to `rules/domain.md` for domain modeling rules. +and are not repeated here. Refer to `php-library-modeling.md` for library modeling rules. ## Pre-output checklist @@ -29,9 +29,10 @@ Verify every item before producing any PHP code. If any item fails, revise befor 8. No generic identifiers exist. Use domain-specific names instead: `$data` → `$payload`, `$value` → `$totalAmount`, `$item` → `$element`, `$info` → `$currencyDetails`, `$result` → `$conversionOutcome`. -9. No raw arrays exist where a typed collection or value object is available. Use `tiny-blocks/collection` - (`Collection`, `Collectible`) instead of raw `array` for any list of domain objects. Raw arrays are acceptable - only for primitive configuration data, variadic pass-through, or interop at system boundaries. +9. No raw arrays exist where a typed collection or value object is available. Use the `tiny-blocks/collection` + fluent API (`Collection`, `Collectible`) when data is `Collectible`. Use `createLazyFrom` when elements are + consumed once. Raw arrays are acceptable only for primitive configuration data, variadic pass-through, and + interop at system boundaries. See "Collection usage" below for the full rule and example. 10. No private methods exist except private constructors for factory patterns. Inline trivial logic at the call site or extract it to a collaborator or value object. 11. Members are ordered: constants first, then constructor, then static methods, then instance methods. Within each @@ -39,9 +40,16 @@ Verify every item before producing any PHP code. If any item fails, revise befor no body, are ordered by name length ascending. 12. Constructor parameters are ordered by parameter name length ascending (count the name only, without `$` or type), except when parameters have an implicit semantic order (e.g., `$start/$end`, `$from/$to`, `$startAt/$endAt`), - which takes precedence. The same rule applies to named arguments at call sites. + which takes precedence. Parameters with default values go last, regardless of name length. The same rule + applies to named arguments at call sites. Example: `$id` (2) → `$value` (5) → `$status` (6) → `$precision` (9). -13. No O(N²) or worse complexity exists. +13. Time and space complexity are first-class design concerns. + - No `O(N²)` or worse time complexity exists unless the problem inherently requires it and the cost is + documented in PHPDoc on the interface method. + - Space complexity is kept minimal: prefer lazy/streaming pipelines (`createLazyFrom`) over materializing + intermediate collections. + - Never re-iterate the same source; fuse stages when possible. + - Public interface methods document time and space complexity in Big O form (see "PHPDoc" section). 14. No logic is duplicated across two or more places (DRY). 15. No abstraction exists without real duplication or isolation need (KISS). 16. All identifiers, comments, and documentation are written in American English. @@ -59,8 +67,31 @@ Verify every item before producing any PHP code. If any item fails, revise befor 23. No vertical alignment of types in parameter lists or property declarations. Use a single space between type and variable name. Never pad with extra spaces to align columns: `public OrderId $id` — not `public OrderId $id`. -24. Opening brace `{` goes on the same line as the closing parenthesis `)` for constructors, methods, and - closures: `): ReturnType {` — not `): ReturnType\n {`. Parameters with default values go last. +24. Opening brace `{` follows PSR-12: on a **new line** for classes, interfaces, traits, enums, and methods + (including constructors); on the **same line** for closures and control structures (`if`, `for`, `foreach`, + `while`, `switch`, `match`, `try`). +25. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example — `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`: + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` → `$collection->toArray()`. + Only pass the argument when the value differs from the default. +26. No trailing comma in any multi-line list. This applies to parameter lists (constructors, methods, + closures), argument lists at call sites, array literals, match arms, and any other comma-separated + multi-line structure. The last element never has a comma after it. PHP accepts trailing commas in + parameter lists, but this project prohibits them for visual consistency. + Example — correct: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP + ); + ``` + Example — prohibited: + ``` + new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, + ); + ``` ## Casing conventions @@ -70,9 +101,7 @@ Verify every item before producing any PHP code. If any item fails, revise befor ## Naming - Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. -- Generic technical verbs (`process`, `handle`, `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, - `check`, `verify`, `assert`, `transform`, `parse`, `compute`, `sanitize`, `normalize`) **should be avoided**. - Prefer names that describe the domain operation. +- Generic technical verbs are avoided. See `php-library-modeling.md` — Nomenclature. - Booleans use predicate form: `isActive`, `hasPermission`, `wasProcessed`. - Collections are always plural: `$orders`, `$lines`. - Methods returning bool use prefixes: `is`, `has`, `can`, `was`, `should`. @@ -93,12 +122,19 @@ All identifiers, enum values, comments, and error codes use American English spe ## PHPDoc -- PHPDoc is restricted to interfaces only, documenting obligations and `@throws`. +- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. - Never add PHPDoc to concrete classes. +- Document `@throws` for every exception the method may raise. +- Document time and space complexity in Big O form. When a method participates in a fused pipeline (e.g., collection + pipelines), express cost as a two-part form: call-site cost + fused-pass contribution. Include a legend defining + variables (e.g., `N` for input size, `K` for number of stages). ## Collection usage -When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions. +When a property or parameter is `Collectible`, use its fluent API. Never break out to raw array functions such as +`array_map`, `array_filter`, `iterator_to_array`, or `foreach` + accumulation. The same applies to `filter()`, +`reduce()`, `each()`, and all other `Collectible` operations. Chain them fluently. Never materialize with +`iterator_to_array` to then pass into a raw `array_*` function. **Prohibited — `array_map` + `iterator_to_array` on a Collectible:** @@ -116,6 +152,3 @@ $names = $collection ->map(transformations: static fn(Element $element): string => $element->name()) ->toArray(keyPreservation: KeyPreservation::DISCARD); ``` - -The same applies to `filter()`, `reduce()`, `each()`, and all other `Collectible` operations. Chain them -fluently. Never materialize with `iterator_to_array` to then pass into a raw `array_*` function. diff --git a/.claude/rules/documentation.md b/.claude/rules/php-library-documentation.md similarity index 82% rename from .claude/rules/documentation.md rename to .claude/rules/php-library-documentation.md index 64587c9..d7ac6da 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/php-library-documentation.md @@ -1,7 +1,7 @@ --- description: Standards for README files and all project documentation in PHP libraries. paths: - - "**/*.md" + - "**/*.md" --- # Documentation @@ -21,7 +21,8 @@ paths: frequently ask about. Each entry is a numbered question as heading (e.g., `### 01. Why does X happen?`) followed by a concise explanation. Only include entries that address real confusion points. 9. **License** and **Contributing** sections at the end. -10. Write strictly in American English. See `rules/code-style.md` American English section for spelling conventions. +10. Write strictly in American English. See `php-library-code-style.md` American English section for spelling + conventions. ## Structured data @@ -34,4 +35,6 @@ paths: 1. Keep language concise and scannable. 2. Never include placeholder content (`TODO`, `TBD`). 3. Code examples must be syntactically correct and self-contained. -4. Do not document `Internal/` classes or private API. Only document what consumers interact with. +4. Code examples include every `use` statement needed to compile. Each example stands alone — copyable into + a fresh file without modification. +5. Do not document `Internal/` classes or private API. Only document what consumers interact with. diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md new file mode 100644 index 0000000..4633fa5 --- /dev/null +++ b/.claude/rules/php-library-modeling.md @@ -0,0 +1,125 @@ +--- +description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +paths: + - "src/**/*.php" +--- + +# Library modeling + +Libraries are self-contained packages. The core has no dependency on frameworks, databases, or I/O. Refer to +`php-library-code-style.md` for the pre-output checklist applied to all PHP code. + +## Folder structure + +``` +src/ +├── .php # Primary contract for consumers +├── .php # Main implementation or extension point +├── .php # Public enum +├── Contracts/ # Interfaces for data returned to consumers +├── Internal/ # Implementation details (not part of public API) +│ ├── .php +│ └── Exceptions/ # Internal exception classes +├── / # Feature-specific subdirectory when needed +└── Exceptions/ # Public exception classes (when part of the API) +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. + +## Public API boundary + +Only interfaces, extension points, enums, and thin orchestration classes live at the `src/` root. These classes +define the contract consumers interact with and delegate all real work to collaborators inside `src/Internal/`. +If a class contains substantial logic (algorithms, state machines, I/O), it belongs in `Internal/`, not at the root. + +The `Internal/` namespace signals classes that are implementation details. Consumers must not depend on them. +Breaking changes inside `Internal/` are not semver-breaking for the library. + +## Nomenclature + +1. Every class, property, method, and exception name reflects the **concept** the library represents. A math library + uses `Precision`, `RoundingMode`; a money library uses `Currency`, `Amount`; a collection library uses + `Collectible`, `Order`. +2. Never use generic technical names as class suffixes, prefixes, or method names: `Manager`, `Helper`, `Processor`, + `Handler`, `Service`, `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`, `Exception`, `process`, `handle`, + `execute`, `mark`, `enforce`, `manage`, `ensure`, `validate`, `check`, `verify`, `assert`, `transform`, `parse`, + `compute`, `sanitize`, or `normalize`. +3. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +4. Name methods after the operation in its vocabulary: `add()`, `convertTo()`, `splitAt()`. + +## Value objects + +1. Are immutable: no setters, no mutation after construction. Operations return new instances. +2. Compare by value, not by reference. +3. Validate invariants in the constructor and throw on invalid input. +4. Have no identity field. +5. Use static factory methods (e.g., `from`, `of`, `zero`) with a private constructor when multiple creation paths + exist. The factory name communicates the semantic intent. + +## Exceptions + +1. Every failure throws a **dedicated exception class** named after the invariant it guards — never + `throw new DomainException('...')`, `throw new InvalidArgumentException('...')`, + `throw new RuntimeException('...')`, or any other generic native exception with a string message. If the + invariant is worth throwing for, it is worth a named class. +2. Dedicated exception classes **extend** the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.) — the native class is the parent, never the thing that + is thrown. Consumers that catch the broad standard types continue to work; consumers that need precise handling + can catch the specific classes. +3. Exceptions are pure: no transport-specific fields (`code`, formatted `message`). Formatting to any transport + happens at the consumer's boundary, not inside the library. +4. Exceptions signal invariant violations only, not control flow. +5. Name the class after the invariant violated, never after the technical type: + - `PrecisionOutOfRange` — not `InvalidPrecisionException`. + - `CurrencyMismatch` — not `BadCurrencyException`. + - `ContainerWaitTimeout` — not `TimeoutException`. +6. No exception-formatting constructor, no custom message argument — the class name is the message. +7. Public exceptions live in `src/Exceptions/`. Internal exceptions live in `src/Internal/Exceptions/`. + +**Prohibited** — throwing a native exception directly: + +```php +if ($value < 0) { + throw new InvalidArgumentException('Precision cannot be negative.'); +} +``` + +**Correct** — dedicated class extending the native exception: + +```php +// src/Exceptions/PrecisionOutOfRange.php +final class PrecisionOutOfRange extends InvalidArgumentException +{ +} + +// at the callsite +if ($value < 0) { + throw new PrecisionOutOfRange(); +} +``` + +## Enums + +1. Are PHP backed enums. +2. Include methods when they carry vocabulary meaning (e.g., `Order::ASCENDING_KEY`, `RoundingMode::apply()`). +3. Live at the `src/` root when public. Enums used only by internals live in `src/Internal/`. + +## Extension points + +1. When a class is designed to be extended by consumers (e.g., `Collection`, `ValueObject`), it uses `class` instead + of `final readonly class`. All other classes use `final readonly class`. +2. Extension point classes use a private constructor with static factory methods (`createFrom`, `createFromEmpty`) + as the only creation path. +3. Internal state is injected via the constructor and stored in a `private readonly` property. + +## Time and space complexity + +1. Every public method has predictable, documented complexity. Document Big O in PHPDoc on the interface + (see `php-library-code-style.md`, "PHPDoc" section). +2. Algorithms run in `O(N)` or `O(N log N)` unless the problem inherently requires worse. `O(N²)` or worse must + be justified and documented. +3. Prefer lazy/streaming evaluation over materializing intermediate results. In pipeline-style libraries, fuse + stages so a single pass suffices. +4. Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +5. Validate complexity claims with benchmarks against a reference implementation when optimizing critical paths. + Parity testing against the reference library is the validation standard for optimization work. diff --git a/.claude/rules/php-testing.md b/.claude/rules/php-library-testing.md similarity index 82% rename from .claude/rules/php-testing.md rename to .claude/rules/php-library-testing.md index 7bd9e68..610b928 100644 --- a/.claude/rules/php-testing.md +++ b/.claude/rules/php-library-testing.md @@ -1,12 +1,13 @@ --- description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. paths: - - "tests/**/*.php" + - "tests/**/*.php" --- # Testing conventions -Framework: **PHPUnit**. Refer to `rules/code-style.md` for the code style checklist, which also applies to test files. +Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to +test files. ## Structure: Given/When/Then (BDD) @@ -62,15 +63,14 @@ Use `@And` for complementary preconditions or actions within the same scenario, 5. Never include conditional logic inside tests. 6. Include one logical concept per `@Then` block. 7. Maintain strict independence between tests. No inherited state. -8. For exception tests, place `@Then` (expectException) before `@When`. -9. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts +8. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts (e.g., `Amount`, `Invoice`, `Order`). -10. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries - (e.g., `ClientMock`, `ExecutionCompletedMock`). -11. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class +9. Use mock classes in `tests/Mocks/` (or `tests/Unit/Mocks/`) for test doubles of system boundaries + (e.g., `ClientMock`, `ExecutionCompletedMock`). +10. Exercise invariants and edge cases through the library's public entry point. Create a dedicated test class for an internal model only when the condition cannot be reached through the public API. -12. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. -13. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, +11. Never use `/** @test */` annotation. Test methods are discovered by the `test` prefix in the method name. +12. Never use named arguments on PHPUnit assertions (`assertEquals`, `assertSame`, `assertTrue`, `expectException`, etc.). Pass arguments positionally. ## Test setup and fixtures @@ -104,11 +104,7 @@ tests/ └── bootstrap.php # Test bootstrap when needed ``` -- `tests/` or `tests/Unit/`: pure unit tests exercising the library's public API. -- `tests/Integration/`: tests requiring real external resources (e.g., Docker containers, databases). - Only present when the library interacts with infrastructure. -- `tests/Models/`: domain-specific fixture classes reused across test files. -- `tests/Mocks/` or `tests/Unit/Mocks/`: test doubles for system boundaries. +`tests/Integration/` is only present when the library interacts with infrastructure. ## Coverage and mutation testing diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..73e3c9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 8c85471..28337dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,30 @@ -/tests export-ignore -/vendor export-ignore - -/LICENSE export-ignore -/Makefile export-ignore -/README.md export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore - -/.github export-ignore -/.gitignore export-ignore -/.gitattributes export-ignore +# Auto detect text files and perform LF normalization +* text=auto eol=lf + +# ─── Diff drivers ──────────────────────────────────────────── +*.php diff=php +*.md diff=markdown + +# ─── Force LF ──────────────────────────────────────────────── +*.sh text eol=lf +Makefile text eol=lf + +# ─── Generated (skip diff and GitHub stats) ────────────────── +composer.lock -diff linguist-generated + +# ─── Export ignore (excluded from dist archive) ────────────── +/tests export-ignore +/vendor export-ignore +/rules export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore + +/CLAUDE.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/README.md export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 42b841a..85fc064 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ vendor report -.phpunit.* *.lock +.phpunit.* diff --git a/Makefile b/Makefile index ef9a884..07acc3b 100644 --- a/Makefile +++ b/Makefile @@ -17,13 +17,14 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment @${DOCKER_RUN} composer update --optimize-autoloader + @${DOCKER_RUN} composer normalize .PHONY: test test: ## Run all tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file -test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} .PHONY: test-no-coverage @@ -38,6 +39,10 @@ review: ## Run static code analysis show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +.PHONY: show-outdated +show-outdated: ## Show outdated direct dependencies + @${DOCKER_RUN} composer outdated --direct + .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} @@ -60,7 +65,7 @@ help: ## Display this help message | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" - @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" diff --git a/README.md b/README.md index ec49240..5916f07 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ ## Overview -Common implementations for the HTTP protocol. The library exposes concrete implementations that follow the PSR standards -and are **framework-agnostic**, designed to work consistently across any ecosystem that supports -[PSR-7](https://www.php-fig.org/psr/psr-7) and [PSR-15](https://www.php-fig.org/psr/psr-15), providing solutions for -building HTTP responses, requests, and other HTTP-related components. +Implements [PSR-7](https://www.php-fig.org/psr/psr-7) and [PSR-15](https://www.php-fig.org/psr/psr-15) HTTP primitives +for PHP, covering requests, responses, streams, cookies, headers, methods, status codes, and cache-control directives. +Ships with a fluent response builder that maps common outcomes to the correct HTTP semantics out of the box. +Interoperable with Slim, Laminas, and any PSR-compliant framework.
@@ -397,4 +397,4 @@ Http is licensed under [MIT](LICENSE). ## Contributing Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to -contribute to the project. \ No newline at end of file +contribute to the project. diff --git a/composer.json b/composer.json index a45b247..89be1e6 100644 --- a/composer.json +++ b/composer.json @@ -1,39 +1,36 @@ { "name": "tiny-blocks/http", - "type": "library", + "description": "Implements PSR-7 and PSR-15 HTTP primitives for PHP, with a fluent response builder, cookies, and cache control.\n\nThe package is designed to be used in any PHP application, and can be used as a standalone library or as part of a larger framework.", "license": "MIT", - "homepage": "https://github.com/tiny-blocks/http", - "description": "Common implementations for HTTP protocol.", - "prefer-stable": true, - "minimum-stability": "stable", - "keywords": [ - "psr", - "http", - "psr-7", - "psr-15", - "request", - "response", - "http-code", - "tiny-blocks", - "http-status", - "http-methods" - ], + "type": "library", "authors": [ { "name": "Gustavo Freze de Araujo Santos", "homepage": "https://github.com/gustavofreze" } ], + "homepage": "https://github.com/tiny-blocks/http", "support": { "issues": "https://github.com/tiny-blocks/http/issues", "source": "https://github.com/tiny-blocks/http" }, - "config": { - "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - } + "require": { + "php": "^8.5", + "psr/http-message": "^2.0", + "tiny-blocks/mapper": "^2.0" }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "laminas/laminas-httphandlerrunner": "^2.12", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "slim/psr7": "^1.7", + "slim/slim": "^4.14", + "squizlabs/php_codesniffer": "^4.0" + }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "TinyBlocks\\Http\\": "src/" @@ -44,31 +41,24 @@ "Test\\TinyBlocks\\Http\\": "tests/" } }, - "require": { - "php": "^8.5", - "psr/http-message": "^2.0", - "tiny-blocks/mapper": "^2.0" - }, - "require-dev": { - "slim/psr7": "^1.8", - "slim/slim": "^4.15", - "phpunit/phpunit": "^11.5", - "phpstan/phpstan": "^2.1", - "infection/infection": "^0.32", - "squizlabs/php_codesniffer": "^4.0", - "laminas/laminas-httphandlerrunner": "^2.13" + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true }, "scripts": { - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "review": [ "@phpcs", "@phpstan" ], + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", "tests": [ "@test", "@mutation-test" diff --git a/infection.json.dist b/infection.json.dist index 45c49fc..ee435dd 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,7 +16,8 @@ "customPath": "./vendor/bin/phpunit" }, "mutators": { - "@default": true + "@default": true, + "ProtectedVisibility": false }, "minCoveredMsi": 100, "testFramework": "phpunit" diff --git a/src/Internal/Exceptions/BadMethodCall.php b/src/Internal/Exceptions/BadMethodCall.php deleted file mode 100644 index ad1165a..0000000 --- a/src/Internal/Exceptions/BadMethodCall.php +++ /dev/null @@ -1,17 +0,0 @@ - cannot be used.'; - - parent::__construct(message: sprintf($template, $this->method)); - } -} diff --git a/src/Internal/Response/InternalResponse.php b/src/Internal/Response/InternalResponse.php index d40e7d7..5532442 100644 --- a/src/Internal/Response/InternalResponse.php +++ b/src/Internal/Response/InternalResponse.php @@ -9,7 +9,6 @@ use Psr\Http\Message\StreamInterface; use TinyBlocks\Http\Code; use TinyBlocks\Http\Headers; -use TinyBlocks\Http\Internal\Exceptions\BadMethodCall; use TinyBlocks\Http\Internal\Stream\StreamFactory; final readonly class InternalResponse implements ResponseInterface @@ -54,7 +53,12 @@ public function withBody(StreamInterface $body): MessageInterface public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface { - throw new BadMethodCall(method: __FUNCTION__); + return new InternalResponse( + body: $this->body, + code: Code::from($code), + headers: $this->headers, + protocolVersion: $this->protocolVersion + ); } public function withHeader(string $name, $value): MessageInterface diff --git a/src/Internal/Stream/Stream.php b/src/Internal/Stream/Stream.php index 3c90585..f46ff17 100644 --- a/src/Internal/Stream/Stream.php +++ b/src/Internal/Stream/Stream.php @@ -15,9 +15,6 @@ final class Stream implements StreamInterface { private const int OFFSET_ZERO = 0; - private string $content = ''; - private bool $contentFetched = false; - /** * @param resource|null $resource * @param StreamMetaData $metaData @@ -122,7 +119,7 @@ public function isReadable(): bool $mode = $this->metaData->getMode(); - return $mode === 'r' || strstr($mode, '+'); + return str_contains($mode, 'r') || str_contains($mode, '+'); } public function isWritable(): bool @@ -145,12 +142,7 @@ public function getContents(): string throw new NonReadableStream(); } - if (!$this->contentFetched) { - $this->content = stream_get_contents($this->resource); - $this->contentFetched = true; - } - - return $this->content; + return stream_get_contents($this->resource); } public function getMetadata(?string $key = null): mixed @@ -175,6 +167,6 @@ public function __toString(): string private function noResource(): bool { - return empty($this->resource); + return !is_resource($this->resource); } } diff --git a/tests/Drivers/Laminas/LaminasTest.php b/tests/Drivers/Laminas/LaminasTest.php index 7fce47c..9bfdd06 100644 --- a/tests/Drivers/Laminas/LaminasTest.php +++ b/tests/Drivers/Laminas/LaminasTest.php @@ -35,7 +35,7 @@ protected function setUp(): void public function testResponseProcessedWithLaminas(): void { /** @Given a valid request */ - $request = $this->createMock(ServerRequestInterface::class); + $request = $this->createStub(ServerRequestInterface::class); /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); @@ -53,7 +53,7 @@ public function testResponseProcessedWithLaminas(): void self::assertSame(Code::OK->value, $actual->getStatusCode()); /** @And the response body should match the expected body */ - self::assertSame($response->getBody()->getContents(), $actual->getBody()->getContents()); + self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); diff --git a/tests/Drivers/Slim/SlimTest.php b/tests/Drivers/Slim/SlimTest.php index aa8a896..f41aa45 100644 --- a/tests/Drivers/Slim/SlimTest.php +++ b/tests/Drivers/Slim/SlimTest.php @@ -35,7 +35,7 @@ protected function setUp(): void public function testResponseProcessedWithSlim(): void { /** @Given a valid request */ - $request = $this->createMock(ServerRequestInterface::class); + $request = $this->createStub(ServerRequestInterface::class); /** @And the Content-Type for the response is set to application/json with UTF-8 charset */ $contentType = ContentType::applicationJson(charset: Charset::UTF_8); @@ -53,7 +53,7 @@ public function testResponseProcessedWithSlim(): void self::assertSame(Code::OK->value, $actual->getStatusCode()); /** @And the response body should match the expected body */ - self::assertSame($response->getBody()->getContents(), $actual->getBody()->getContents()); + self::assertSame($response->getBody()->__toString(), $actual->getBody()->__toString()); /** @And the response headers should match the expected headers */ self::assertSame($response->getHeaders(), $actual->getHeaders()); diff --git a/tests/Internal/Request/RouteParameterResolverTest.php b/tests/Internal/Request/RouteParameterResolverTest.php index 6af4222..64577bf 100644 --- a/tests/Internal/Request/RouteParameterResolverTest.php +++ b/tests/Internal/Request/RouteParameterResolverTest.php @@ -13,7 +13,7 @@ final class RouteParameterResolverTest extends TestCase public function testResolveWithArrayAttribute(): void { /** @Given a request with an array attribute under __route__ */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -39,7 +39,7 @@ public function getArguments(): array } }; - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -65,7 +65,7 @@ public function getMatchedParams(): array } }; - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -88,7 +88,7 @@ public function testResolveWithObjectUsingPublicProperty(): void public array $arguments = ['key' => 'value']; }; - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -107,7 +107,7 @@ public function testResolveWithObjectUsingPublicProperty(): void public function testResolveReturnsEmptyArrayWhenAttributeIsNull(): void { /** @Given a request with no matching attribute */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturn(null); @@ -130,7 +130,7 @@ public function unknownMethod(): string } }; - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -149,7 +149,7 @@ public function unknownMethod(): string public function testResolveFromKnownAttributesScansMultipleKeys(): void { /** @Given params stored under _route_params (Symfony-style) */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -168,7 +168,7 @@ public function testResolveFromKnownAttributesScansMultipleKeys(): void public function testResolveDirectAttribute(): void { /** @Given a request with direct attributes (Laravel-style) */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { @@ -196,7 +196,7 @@ public function getArguments(): array } }; - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getAttribute') ->willReturnCallback(static fn(string $name) => match ($name) { diff --git a/tests/Internal/Stream/StreamFactoryTest.php b/tests/Internal/Stream/StreamFactoryTest.php index 1b5a591..e90f0f9 100644 --- a/tests/Internal/Stream/StreamFactoryTest.php +++ b/tests/Internal/Stream/StreamFactoryTest.php @@ -28,7 +28,7 @@ public function testWriteShouldRewindStream(): void public function testFromStreamShouldRewindBeforeAndAfterReadingWhenSeekable(): void { /** @Given a seekable stream */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream->method('isSeekable')->willReturn(true); /** @And rewind call counter */ @@ -59,7 +59,7 @@ static function () use (&$rewindCalls): string { public function testFromStreamShouldNotRewindWhenNotSeekable(): void { /** @Given a non-seekable stream */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream->method('isSeekable')->willReturn(false); /** @And rewind call counter */ diff --git a/tests/Internal/Stream/StreamTest.php b/tests/Internal/Stream/StreamTest.php index dbc2c36..a831e44 100644 --- a/tests/Internal/Stream/StreamTest.php +++ b/tests/Internal/Stream/StreamTest.php @@ -198,6 +198,20 @@ public function testGetSizeReturnsNullWhenWithoutResource(): void self::assertNull($stream->getSize()); } + public function testIsSeekableReturnsFalseWhenUnderlyingResourceIsClosedExternally(): void + { + /** @Given a stream whose underlying resource was closed outside the stream API */ + $resource = fopen('php://memory', 'w+'); + $stream = Stream::from(resource: $resource); + fclose($resource); + + /** @When checking if the stream is seekable */ + $actual = $stream->isSeekable(); + + /** @Then it should return false because the resource is no longer valid */ + self::assertFalse($actual); + } + public function testExceptionWhenNonSeekableStream(): void { /** @Given a stream */ diff --git a/tests/RequestTest.php b/tests/RequestTest.php index a04b550..2f6d767 100644 --- a/tests/RequestTest.php +++ b/tests/RequestTest.php @@ -27,12 +27,12 @@ public function testRequestDecodingWithPayload(): void ]; /** @And this payload is used to create a ServerRequestInterface */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream ->method('getContents') ->willReturn(json_encode($payload, JSON_PRESERVE_ZERO_FRACTION)); - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('POST'); @@ -65,7 +65,7 @@ public function testRequestDecodingWithRouteWithSingleAttribute(): void $attribute = 'dragon-id'; /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -98,7 +98,7 @@ public function testRequestDecodingWithRouteWithMultipleAttributes(): void ]; /** @And a ServerRequestInterface with this route attribute */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -128,7 +128,7 @@ public function testRequestWhenAttributeConversions( mixed $expected ): void { /** @Given a ServerRequestInterface with a route attribute */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -155,7 +155,7 @@ public function testRequestDecodingWithRouteAttributeAsScalar(): void $attribute = 'dragon-id'; /** @And a ServerRequestInterface with this route attribute as scalar */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -187,7 +187,7 @@ public function getArguments(): array }; /** @And a ServerRequestInterface with this route object under __route__ */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -219,7 +219,7 @@ public function getMatchedParams(): array }; /** @And a ServerRequestInterface with this route result under routeResult */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -241,7 +241,7 @@ public function getMatchedParams(): array public function testRequestDecodingWithSymfonyStyleRouteParams(): void { /** @Given Symfony stores route params as an array under _route_params */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -266,7 +266,7 @@ public function testRequestDecodingWithSymfonyStyleRouteParams(): void public function testRequestDecodingWithSymfonyStyleFallbackScan(): void { /** @Given Symfony stores route params under _route_params and default __route__ is null */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -287,7 +287,7 @@ public function testRequestDecodingWithSymfonyStyleFallbackScan(): void public function testRequestDecodingWithDirectAttributes(): void { /** @Given a framework like Laravel stores route params as direct request attributes */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -310,7 +310,7 @@ public function testRequestDecodingWithDirectAttributes(): void public function testRequestDecodingWithManualWithAttribute(): void { /** @Given a user manually injects route params via withAttribute() */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('POST'); @@ -337,7 +337,7 @@ public function testRequestDecodingWithObjectHavingPublicProperty(): void }; /** @And a ServerRequestInterface with this object under __route__ */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -359,7 +359,7 @@ public function testRequestDecodingWithObjectHavingPublicProperty(): void public function testRequestDecodingReturnsDefaultsWhenNoRouteParams(): void { /** @Given a ServerRequestInterface with no route attributes at all */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -391,12 +391,12 @@ public function testRequestDecodingWithParsedBody(): void ]; /** @And a ServerRequestInterface with an empty stream but a parsed body */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream ->method('getContents') ->willReturn(''); - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('POST'); @@ -429,13 +429,13 @@ public function testRequestDecodingWithFullUri(): void $expectedUri = 'https://api.example.com/v1/dragons?sort=name&order=asc'; /** @And a PSR-7 UriInterface mock that returns this URI */ - $uri = $this->createMock(UriInterface::class); + $uri = $this->createStub(UriInterface::class); $uri ->method('__toString') ->willReturn($expectedUri); /** @And a ServerRequestInterface that returns this UriInterface */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -464,12 +464,12 @@ public function testRequestDecodingWithQueryParameters(): void ]; /** @And a ServerRequestInterface that returns these query parameters */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream ->method('getContents') ->willReturn(''); - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -497,12 +497,12 @@ public function testRequestDecodingWithQueryParameters(): void public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty(): void { /** @Given a ServerRequestInterface with no query parameters */ - $stream = $this->createMock(StreamInterface::class); + $stream = $this->createStub(StreamInterface::class); $stream ->method('getContents') ->willReturn(''); - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('GET'); @@ -530,7 +530,7 @@ public function testRequestDecodingWithQueryParametersReturnsDefaultsWhenEmpty() public function testRequestWithMethod(): void { /** @Given a ServerRequestInterface with POST method */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn('POST'); @@ -550,7 +550,7 @@ public function testRequestWithMethod(): void public function testRequestWithDifferentHttpMethods(string $methodString, Method $expectedMethod): void { /** @Given a ServerRequestInterface with the specified HTTP method */ - $serverRequest = $this->createMock(ServerRequestInterface::class); + $serverRequest = $this->createStub(ServerRequestInterface::class); $serverRequest ->method('getMethod') ->willReturn($methodString); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index db68e94..db0dc39 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -16,7 +16,6 @@ use Test\TinyBlocks\Http\Models\Products; use Test\TinyBlocks\Http\Models\Status; use TinyBlocks\Http\Code; -use TinyBlocks\Http\Internal\Exceptions\BadMethodCall; use TinyBlocks\Http\Internal\Stream\StreamFactory; use TinyBlocks\Http\Response; @@ -34,7 +33,6 @@ public function testResponseFrom(Code $code, mixed $body, string $expectedBody): /** @And the body of the response should match the expected output */ self::assertSame($expectedBody, $actual->getBody()->__toString()); - self::assertSame($expectedBody, $actual->getBody()->getContents()); /** @And the status code should match the provided code */ self::assertSame($code->value, $actual->getStatusCode()); @@ -65,7 +63,6 @@ public function testResponseOk(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 200 */ self::assertSame(Code::OK->value, $actual->getStatusCode()); @@ -97,7 +94,6 @@ public function testResponseCreated(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 201 */ self::assertSame(Code::CREATED->value, $actual->getStatusCode()); @@ -127,7 +123,6 @@ public function testResponseAccepted(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 202 */ self::assertSame(Code::ACCEPTED->value, $actual->getStatusCode()); @@ -152,7 +147,6 @@ public function testResponseNoContent(): void /** @And the body of the response should be empty */ self::assertEmpty($actual->getBody()->__toString()); - self::assertEmpty($actual->getBody()->getContents()); /** @And the status code should be 204 */ self::assertSame(Code::NO_CONTENT->value, $actual->getStatusCode()); @@ -182,7 +176,6 @@ public function testResponseBadRequest(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 400 */ self::assertSame(Code::BAD_REQUEST->value, $actual->getStatusCode()); @@ -212,7 +205,6 @@ public function testResponseUnauthorized(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 401 */ self::assertSame(Code::UNAUTHORIZED->value, $actual->getStatusCode()); @@ -242,7 +234,6 @@ public function testResponseForbidden(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 403 */ self::assertSame(Code::FORBIDDEN->value, $actual->getStatusCode()); @@ -272,7 +263,6 @@ public function testResponseNotFound(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 404 */ self::assertSame(Code::NOT_FOUND->value, $actual->getStatusCode()); @@ -302,7 +292,6 @@ public function testResponseConflict(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 409 */ self::assertSame(Code::CONFLICT->value, $actual->getStatusCode()); @@ -332,7 +321,6 @@ public function testResponseUnprocessableEntity(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 422 */ self::assertSame(Code::UNPROCESSABLE_ENTITY->value, $actual->getStatusCode()); @@ -362,7 +350,6 @@ public function testResponseInternalServerError(): void /** @And the body of the response should match the JSON-encoded body */ self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->__toString()); - self::assertSame(json_encode($body, JSON_PRESERVE_ZERO_FRACTION), $actual->getBody()->getContents()); /** @And the status code should be 500 */ self::assertSame(Code::INTERNAL_SERVER_ERROR->value, $actual->getStatusCode()); @@ -416,7 +403,6 @@ public function testResponseBodySerialization(mixed $body, string $expected): vo /** @Then the body of the response should match the expected output */ self::assertSame($expected, $actual->getBody()->__toString()); - self::assertSame($expected, $actual->getBody()->getContents()); } public function testResponseWithBody(): void @@ -426,7 +412,6 @@ public function testResponseWithBody(): void /** @When the body of the response is initially empty */ self::assertEmpty($response->getBody()->__toString()); - self::assertEmpty($response->getBody()->getContents()); /** @And a new body is set for the response */ $body = 'This is a new body'; @@ -434,20 +419,18 @@ public function testResponseWithBody(): void /** @Then the response body should be updated to match the new content */ self::assertSame($body, $actual->getBody()->__toString()); - self::assertSame($body, $actual->getBody()->getContents()); } - public function testExceptionWhenBadMethodCallOnWithStatus(): void + public function testWithStatusReturnsResponseWithUpdatedCode(): void { /** @Given an HTTP response */ $response = Response::noContent(); - /** @Then a BadMethodCall exception should be thrown when calling withStatus */ - self::expectException(BadMethodCall::class); - self::expectExceptionMessage('Method cannot be used.'); + /** @When calling withStatus with a new code */ + $updated = $response->withStatus(Code::OK->value); - /** @When attempting to call withStatus */ - $response->withStatus(code: Code::OK->value); + /** @Then the returned response reflects the new status code */ + self::assertSame(Code::OK->value, $updated->getStatusCode()); } public static function bodyProviderData(): array