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..bedb733 --- /dev/null +++ b/.claude/rules/php-library-modeling.md @@ -0,0 +1,163 @@ +--- +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. Name classes after what they represent: `Money`, `Color`, `Pipeline` — not after what they do technically. +3. Name methods after the operation in the library's vocabulary: `add()`, `convertTo()`, `splitAt()`. + +### Always banned + +These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: + +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). + +### Anemic verbs (banned by default) + +These verbs hide what is actually happening behind a generic action. Banned unless the verb **is** the operation +that constitutes the library's reason to exist (e.g., a JSON parser may have `parse()`; a hashing library may +have `compute()`): + +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, + `transform`, `parse`. + +When in doubt, prefer the domain operation name. `Password::hash()` beats `Password::compute()`; `Email::parse()` +is fine in a parser library but suspicious elsewhere (use `Email::from()` instead). + +### Architectural roles (allowed with justification) + +These names describe a role the library offers as a building block. Acceptable when the class **is** that role +(e.g., `EventHandler` in an events library, `CacheManager` in a cache library, `Upcaster` in an event-sourcing +library). Not acceptable on domain objects inside the library (value objects, enums, contract interfaces): + +- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. + +The test: if the consumer instantiates or extends this class to integrate with the library, the role name is +legitimate. If the class models a concept the consumer manipulates (a money amount, a country code, a color), +the role name is wrong. + +## 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 thrown directly. 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` populated with HTTP status, formatted `message` meant + for end-user display). 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. A descriptive `message` argument is allowed and encouraged when it carries **debugging context** — the violating + value, the boundary that was crossed, the state the library was in. The class name identifies the invariant; + the message describes the specific violation for stack traces and test assertions. Do not build messages meant + for end-user display or transport rendering. Keep them short, factual, and in American English. +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, no message (class name is sufficient): + +```php +// src/Exceptions/PrecisionOutOfRange.php +final class PrecisionOutOfRange extends InvalidArgumentException +{ +} + +// at the callsite +if ($value < 0) { + throw new PrecisionOutOfRange(); +} +``` + +**Correct** — dedicated class with debugging context: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); +} +``` + +## 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/.gitattributes b/.gitattributes index 28337dc..744a43b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,30 +1,22 @@ -# Auto detect text files and perform LF normalization * text=auto eol=lf -# ─── Diff drivers ──────────────────────────────────────────── -*.php diff=php -*.md diff=markdown +*.php text diff=php -# ─── 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 +# Dev-only — excluded from the Packagist tarball +/.github export-ignore +/tests export-ignore +/.claude export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon export-ignore +/phpstan.neon.dist export-ignore +/phpcs.xml export-ignore +/phpcs.xml.dist export-ignore +/infection.json export-ignore +/infection.json.dist export-ignore +/Makefile export-ignore +/CONTRIBUTING.md export-ignore +/CHANGES.md export-ignore diff --git a/.gitignore b/.gitignore index 85fc064..bd5baa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,20 @@ -.idea +# Agent/IDE +.claude/ +.idea/ +.vscode/ +.cursor/ -vendor -report +# Composer +/vendor/ +composer.lock -*.lock -.phpunit.* +# PHPUnit / coverage +.phpunit.cache/ +.phpunit.result.cache +report/ +coverage/ +build/ + +# OS +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE index 3b630e0..7f30a7e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024-2026 Tiny Blocks +Copyright (c) 2026 Tiny Blocks Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9ab48cf..f63025b 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,10 @@ ## Overview -The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`, -`Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox -or an event-sourced store. It is persistence-agnostic and framework-agnostic. - -Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not -replace PSR-14; it defines what flows through it. +Implements tactical DDD building blocks for PHP, covering entities, single and compound identities, aggregate roots, +domain events, event records, snapshots, and upcasters. Supports both the transactional outbox pattern and event +sourcing through sibling aggregate variants. Persistence-agnostic and PSR-14 friendly, keeping infrastructure concerns +out of the domain layer. ## Installation diff --git a/composer.json b/composer.json index 076c563..1945212 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "tiny-blocks/building-blocks", - "description": "Tactical DDD building blocks for PHP: Entity, Aggregate Root, and domain events with transactional outbox and event sourcing support. Persistence-agnostic and PSR-14 friendly.", + "description": "Implements tactical DDD building blocks for PHP: entities, aggregate roots, domain events, snapshots, and upcasters.", "license": "MIT", "type": "library", "authors": [ @@ -18,10 +18,9 @@ "php": "^8.5", "ramsey/uuid": "^4.9", "tiny-blocks/collection": "^2.3", - "tiny-blocks/immutable-object": "^1.1", "tiny-blocks/mapper": "^2.0", "tiny-blocks/time": "^1.5", - "tiny-blocks/value-object": "^3.2" + "tiny-blocks/value-object": "^4.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php index 1c36c77..9f6ea06 100644 --- a/src/Aggregate/AggregateRootBehavior.php +++ b/src/Aggregate/AggregateRootBehavior.php @@ -33,7 +33,7 @@ public function getModelVersion(): SequenceNumber public function buildAggregateName(): string { - return new ReflectionClass(objectOrClass: static::class)->getShortName(); + return new ReflectionClass(static::class)->getShortName(); } protected function modelVersion(): int @@ -51,7 +51,7 @@ protected function generateSnapshotData(): SnapshotData $state = get_object_vars($this); unset($state['recordedEvents']); - return new SnapshotData(data: $state); + return new SnapshotData(payload: $state); } protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php index 2503bbd..eb6ff6b 100644 --- a/src/Aggregate/EventSourcingRootBehavior.php +++ b/src/Aggregate/EventSourcingRootBehavior.php @@ -4,6 +4,7 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; +use LogicException; use ReflectionClass; use ReflectionProperty; use TinyBlocks\BuildingBlocks\Entity\Identity; @@ -29,9 +30,9 @@ public function recordedEvents(): EventRecords public static function blank(Identity $identity): static { - $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor(); + $aggregate = new ReflectionClass(static::class)->newInstanceWithoutConstructor(); new ReflectionProperty($aggregate, $aggregate->identityName()) - ->setValue(objectOrValue: $aggregate, value: $identity); + ->setValue($aggregate, $identity); $aggregate->sequenceNumber = SequenceNumber::initial(); $aggregate->recordedEvents = EventRecords::createFromEmpty(); @@ -67,7 +68,17 @@ protected function when(DomainEvent $event, Revision $revision): void protected function applyEvent(EventRecord $record): void { - $methodName = 'when' . new ReflectionClass(objectOrClass: $record->event)->getShortName(); + $eventClass = $record->event::class; + $separatorPosition = strrpos($eventClass, '\\'); + $shortName = $separatorPosition === false ? $eventClass : substr($eventClass, $separatorPosition + 1); + $methodName = sprintf('when%s', $shortName); + + if (!method_exists($this, $methodName)) { + $template = 'Handler method <%s> not found in aggregate <%s>.'; + + throw new LogicException(sprintf($template, $methodName, static::class)); + } + $this->{$methodName}($record->event); $this->sequenceNumber = $record->sequenceNumber; } diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php index 4d57625..ce16abf 100644 --- a/src/Aggregate/EventualAggregateRootBehavior.php +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -30,6 +30,6 @@ protected function push(DomainEvent $event, Revision $revision): void { $this->nextSequenceNumber(); $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) - ->add($this->buildEventRecord(event: $event, revision: $revision)); + ->add(elements: $this->buildEventRecord(event: $event, revision: $revision)); } } diff --git a/src/Entity/CompoundIdentity.php b/src/Entity/CompoundIdentity.php index df8d82c..65fc0d7 100644 --- a/src/Entity/CompoundIdentity.php +++ b/src/Entity/CompoundIdentity.php @@ -4,8 +4,6 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\Vo\ValueObject; - /** * {@see Identity} composed of multiple fields treated as a tuple. * @@ -16,6 +14,6 @@ *

All declared properties participate in the identity: getIdentityValue() returns them * as an associative array keyed by property name.

*/ -interface CompoundIdentity extends Identity, ValueObject +interface CompoundIdentity extends Identity { } diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php index 7a84bfb..82407ce 100644 --- a/src/Entity/EntityBehavior.php +++ b/src/Entity/EntityBehavior.php @@ -15,7 +15,7 @@ public function getIdentityName(): string $name = $this->identityName(); if (!property_exists($this, $name)) { - throw new MissingIdentityProperty(propertyName: $name, className: static::class); + throw new MissingIdentityProperty(className: static::class, propertyName: $name); } return $name; @@ -38,6 +38,6 @@ public function sameIdentityOf(Entity $other): bool public function identityEquals(Identity $other): bool { - return $this->getIdentity() == $other; + return $this->getIdentity()->equals(other: $other); } } diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php index d6f1255..38c3d0e 100644 --- a/src/Entity/Identity.php +++ b/src/Entity/Identity.php @@ -4,14 +4,14 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\Immutable\Immutable; +use TinyBlocks\Vo\ValueObject; /** * Immutable value that uniquely identifies an {@see Entity} within its aggregate boundary. * *

Identity is the stable thread that allows an Entity to be recognized across distinct representations - * and lifecycle states. Being {@see Immutable}, it cannot change once constructed: a new identity must be - * created for a new entity.

+ * and lifecycle states. Implementations are expected to be immutable; in PHP 8.5+ this is achieved through + * `final readonly class`.

* *

Implementations are expected to also be value objects for equality purposes. See the two shipped * variants: {@see SingleIdentity} for scalar-backed identifiers and {@see CompoundIdentity} for @@ -20,7 +20,7 @@ * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software * (Addison-Wesley, 2003), Chapter 5 "Entities". */ -interface Identity extends Immutable +interface Identity extends ValueObject { /** * Returns the raw value of this identity. diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php index e9d0c9b..599111a 100644 --- a/src/Entity/SingleIdentity.php +++ b/src/Entity/SingleIdentity.php @@ -4,8 +4,6 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\Vo\ValueObject; - /** * {@see Identity} composed of a single scalar value. * @@ -16,6 +14,6 @@ *

Implementations should declare exactly one property holding the scalar value; the default trait * reads it by reflection and returns it from getIdentityValue().

*/ -interface SingleIdentity extends Identity, ValueObject +interface SingleIdentity extends Identity { } diff --git a/src/Event/EventType.php b/src/Event/EventType.php index 4937e19..c788cd5 100644 --- a/src/Event/EventType.php +++ b/src/Event/EventType.php @@ -17,14 +17,14 @@ private function __construct(public string $value) { - if (!preg_match(pattern: self::PATTERN, subject: $value)) { + if (!preg_match(self::PATTERN, $value)) { throw new InvalidEventType(value: $value, pattern: self::PATTERN); } } public static function fromEvent(DomainEvent $event): EventType { - return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName()); + return new EventType(value: new ReflectionClass($event)->getShortName()); } public static function fromString(string $value): EventType diff --git a/src/Event/SnapshotData.php b/src/Event/SnapshotData.php index dded14b..6102589 100644 --- a/src/Event/SnapshotData.php +++ b/src/Event/SnapshotData.php @@ -11,17 +11,17 @@ { use ValueObjectBehavior; - public function __construct(private array $data) + public function __construct(private array $payload) { } public function toArray(): array { - return $this->data; + return $this->payload; } public function toJson(int $flags = JSON_PRESERVE_ZERO_FRACTION): string { - return json_encode(value: $this->data, flags: $flags | JSON_THROW_ON_ERROR); + return json_encode($this->payload, $flags | JSON_THROW_ON_ERROR); } } diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Internal/Exceptions/InvalidEventType.php index edc61c3..34de069 100644 --- a/src/Internal/Exceptions/InvalidEventType.php +++ b/src/Internal/Exceptions/InvalidEventType.php @@ -11,7 +11,7 @@ final class InvalidEventType extends InvalidArgumentException public function __construct(public readonly string $value, public readonly string $pattern) { parent::__construct( - message: sprintf('Event type <%s> does not match the required pattern <%s>.', $value, $pattern) + sprintf('Event type <%s> does not match the required pattern <%s>.', $value, $pattern) ); } } diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Internal/Exceptions/InvalidRevision.php index df12692..1583e13 100644 --- a/src/Internal/Exceptions/InvalidRevision.php +++ b/src/Internal/Exceptions/InvalidRevision.php @@ -11,7 +11,7 @@ final class InvalidRevision extends InvalidArgumentException public function __construct(public readonly int $value) { parent::__construct( - message: sprintf('Revision must be greater than or equal to 1, got <%d>.', $value) + sprintf('Revision must be greater than or equal to 1, got <%d>.', $value) ); } } diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php index 8258fcf..8035107 100644 --- a/src/Internal/Exceptions/InvalidSequenceNumber.php +++ b/src/Internal/Exceptions/InvalidSequenceNumber.php @@ -11,7 +11,7 @@ final class InvalidSequenceNumber extends InvalidArgumentException public function __construct(public readonly int $value) { parent::__construct( - message: sprintf('Sequence number must be greater than or equal to 0, got <%d>.', $value) + sprintf('Sequence number must be greater than or equal to 0, got <%d>.', $value) ); } } diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Internal/Exceptions/MissingIdentityProperty.php index 2faebea..b5455c1 100644 --- a/src/Internal/Exceptions/MissingIdentityProperty.php +++ b/src/Internal/Exceptions/MissingIdentityProperty.php @@ -8,11 +8,11 @@ final class MissingIdentityProperty extends RuntimeException { - public function __construct(public readonly string $propertyName, public readonly string $className) + public function __construct(public readonly string $className, public readonly string $propertyName) { parent::__construct( - message: sprintf( - 'Property <%s> referenced by IDENTITY constant does not exist in <%s>.', + sprintf( + 'Property <%s> referenced by identityName() does not exist in <%s>.', $propertyName, $className ) diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php index bdaac1c..119204e 100644 --- a/src/Snapshot/Snapshot.php +++ b/src/Snapshot/Snapshot.php @@ -26,11 +26,11 @@ public function __construct( public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot { - $reflection = new ReflectionObject(object: $aggregate); + $reflection = new ReflectionObject($aggregate); $aggregateState = []; foreach ($reflection->getProperties() as $property) { - if (!in_array(needle: $property->getName(), haystack: ['recordedEvents', 'sequenceNumber'], strict: true)) { + if (!in_array($property->getName(), ['recordedEvents', 'sequenceNumber'], true)) { $aggregateState[$property->getName()] = $property->getValue($aggregate); } } diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php index b6e882f..95a68e5 100644 --- a/src/Upcast/IntermediateEvent.php +++ b/src/Upcast/IntermediateEvent.php @@ -6,8 +6,8 @@ use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; -use TinyBlocks\Mapper\ObjectMapper; use TinyBlocks\Mapper\ObjectMappability; +use TinyBlocks\Mapper\ObjectMapper; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -23,6 +23,18 @@ public function __construct( ) { } + public function equals(ValueObject $other): bool + { + if ($other::class !== static::class) { + return false; + } + + /** @var IntermediateEvent $other */ + return $this->type->equals(other: $other->type) + && $this->revision->equals(other: $other->revision) + && $this->serializedEvent === $other->serializedEvent; + } + public function withRevision(Revision $revision): IntermediateEvent { return new IntermediateEvent( diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php index 5d0712d..6ad0b41 100644 --- a/src/Upcast/SingleUpcasterBehavior.php +++ b/src/Upcast/SingleUpcasterBehavior.php @@ -19,9 +19,9 @@ public function upcast(IntermediateEvent $event): IntermediateEvent } return $event - ->withSerializedEvent(serializedEvent: $this->doUpcast(data: $event->serializedEvent)) + ->withSerializedEvent(serializedEvent: $this->rewrite(payload: $event->serializedEvent)) ->withRevision(revision: Revision::of(value: static::TO_REVISION)); } - abstract protected function doUpcast(array $data): array; + abstract protected function rewrite(array $payload): array; } diff --git a/src/Upcast/Upcaster.php b/src/Upcast/Upcaster.php index 278871d..0cf26ef 100644 --- a/src/Upcast/Upcaster.php +++ b/src/Upcast/Upcaster.php @@ -14,7 +14,7 @@ * *

The shipped {@see SingleUpcasterBehavior} trait binds an upcaster to a specific * (EXPECTED_EVENT_TYPE, FROM_REVISION, TO_REVISION) triple through class constants and - * delegates the payload transformation to an abstract doUpcast() hook.

+ * delegates the payload transformation to an abstract rewrite() hook.

* *

To apply a sequence of upcasters in order, use {@see Upcasters::chain}.

* diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php index 4561e60..9492c33 100644 --- a/tests/Aggregate/EventSourcingRootBehaviorTest.php +++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php @@ -4,9 +4,11 @@ namespace Test\TinyBlocks\BuildingBlocks\Aggregate; +use LogicException; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; +use Test\TinyBlocks\BuildingBlocks\Models\CartWithoutHandler; use Test\TinyBlocks\BuildingBlocks\Models\ProductAdded; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; @@ -231,7 +233,9 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void $snapshot = Snapshot::fromAggregate(aggregate: $cart); $cart->addProduct(productId: 'prod-2'); $laterRecords = $cart->recordedEvents()->filter( - predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber()) + predicates: static fn($record): bool => $record->sequenceNumber->isAfter( + other: $snapshot->getSequenceNumber() + ) ); /** @When reconstituting from the snapshot and the later records */ @@ -250,7 +254,9 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen $snapshot = Snapshot::fromAggregate(aggregate: $cart); $cart->addProduct(productId: 'prod-2'); $laterRecords = $cart->recordedEvents()->filter( - predicates: static fn($record): bool => $record->sequenceNumber->isAfter(other: $snapshot->getSequenceNumber()) + predicates: static fn($record): bool => $record->sequenceNumber->isAfter( + other: $snapshot->getSequenceNumber() + ) ); /** @When reconstituting from the snapshot and the later records */ @@ -273,4 +279,24 @@ public function testReconstitutedAggregateHasNoRecordedEvents(): void /** @Then the reconstituted aggregate has no fresh recorded events */ self::assertTrue($reconstituted->recordedEvents()->isEmpty()); } + + public function testReconstituteThrowsWhenHandlerMethodIsMissing(): void + { + /** @Given a recorded event whose aggregate has no matching when handler */ + $cartId = new CartId(value: 'cart-10'); + $original = Cart::blank(identity: $cartId); + $original->addProduct(productId: 'prod-x'); + + /** @Then a LogicException pointing to the missing handler should be thrown */ + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + sprintf( + 'Handler method not found in aggregate <%s>.', + CartWithoutHandler::class + ) + ); + + /** @When reconstituting an aggregate without the handler */ + CartWithoutHandler::reconstitute(identity: $cartId, records: $original->recordedEvents()); + } } diff --git a/tests/Entity/CompoundIdentityBehaviorTest.php b/tests/Entity/CompoundIdentityBehaviorTest.php index 11ad0c3..888829b 100644 --- a/tests/Entity/CompoundIdentityBehaviorTest.php +++ b/tests/Entity/CompoundIdentityBehaviorTest.php @@ -30,10 +30,10 @@ public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are considered equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseWhenTenantDiffers(): void @@ -45,10 +45,10 @@ public function testEqualsReturnsFalseWhenTenantDiffers(): void $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } public function testEqualsReturnsFalseWhenAppointmentDiffers(): void @@ -60,9 +60,9 @@ public function testEqualsReturnsFalseWhenAppointmentDiffers(): void $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } } diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php index 056e4d9..7f26a39 100644 --- a/tests/Entity/EntityBehaviorTest.php +++ b/tests/Entity/EntityBehaviorTest.php @@ -73,10 +73,10 @@ public function testSameIdentityOfReturnsTrueForAggregatesWithEqualIdentity(): v $second = Order::place(orderId: new OrderId(value: 'ord-1'), item: 'pen'); /** @When comparing their identities */ - $result = $first->sameIdentityOf(other: $second); + $haveSameIdentity = $first->sameIdentityOf(other: $second); /** @Then the comparison yields true */ - self::assertTrue($result); + self::assertTrue($haveSameIdentity); } public function testSameIdentityOfReturnsFalseForAggregatesWithDifferentIdentity(): void @@ -88,10 +88,10 @@ public function testSameIdentityOfReturnsFalseForAggregatesWithDifferentIdentity $second = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen'); /** @When comparing their identities */ - $result = $first->sameIdentityOf(other: $second); + $haveSameIdentity = $first->sameIdentityOf(other: $second); /** @Then the comparison yields false */ - self::assertFalse($result); + self::assertFalse($haveSameIdentity); } public function testIdentityEqualsReturnsTrueForEqualIdentity(): void @@ -103,10 +103,10 @@ public function testIdentityEqualsReturnsTrueForEqualIdentity(): void $sameIdentity = new OrderId(value: 'ord-5'); /** @When comparing the identity */ - $result = $order->identityEquals(other: $sameIdentity); + $hasEqualIdentity = $order->identityEquals(other: $sameIdentity); /** @Then the comparison yields true */ - self::assertTrue($result); + self::assertTrue($hasEqualIdentity); } public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void @@ -118,10 +118,10 @@ public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void $otherIdentity = new OrderId(value: 'ord-9'); /** @When comparing the identity */ - $result = $order->identityEquals(other: $otherIdentity); + $hasEqualIdentity = $order->identityEquals(other: $otherIdentity); /** @Then the comparison yields false */ - self::assertFalse($result); + self::assertFalse($hasEqualIdentity); } public function testShipThrowsWhenIdentityPropertyIsMissing(): void diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Entity/SingleIdentityBehaviorTest.php index aea3603..ba26f2a 100644 --- a/tests/Entity/SingleIdentityBehaviorTest.php +++ b/tests/Entity/SingleIdentityBehaviorTest.php @@ -30,10 +30,10 @@ public function testEqualsReturnsTrueForIdenticalSingleIdentities(): void $second = new OrderId(value: 'ord-1'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are considered equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentSingleIdentities(): void @@ -45,9 +45,9 @@ public function testEqualsReturnsFalseForDifferentSingleIdentities(): void $second = new OrderId(value: 'ord-2'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } } diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php index 8663cd2..537661f 100644 --- a/tests/Event/EventRecordTest.php +++ b/tests/Event/EventRecordTest.php @@ -26,7 +26,7 @@ public function testEventRecordExposesEveryConstructorField(): void $eventType = EventType::fromString(value: 'OrderPlaced'); $revision = Revision::initial(); $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(data: ['status' => 'placed']); + $snapshotData = new SnapshotData(payload: ['status' => 'placed']); $sequenceNumber = SequenceNumber::first(); /** @When constructing the EventRecord */ @@ -63,7 +63,7 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void $eventType = EventType::fromString(value: 'OrderPlaced'); $revision = Revision::initial(); $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(data: []); + $snapshotData = new SnapshotData(payload: []); $sequenceNumber = SequenceNumber::first(); /** @And two records constructed from those identical values */ @@ -91,10 +91,10 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void ); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void @@ -105,7 +105,7 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void $eventType = EventType::fromString(value: 'OrderPlaced'); $revision = Revision::initial(); $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(data: []); + $snapshotData = new SnapshotData(payload: []); $sequenceNumber = SequenceNumber::first(); /** @And two records with different UUIDs */ @@ -133,9 +133,9 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void ); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } } diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php index 7133d65..1d97f8d 100644 --- a/tests/Event/EventRecordsTest.php +++ b/tests/Event/EventRecordsTest.php @@ -24,10 +24,10 @@ public function testCreateFromEmptyYieldsAnEmptyCollection(): void $records = EventRecords::createFromEmpty(); /** @When checking whether it is empty */ - $result = $records->isEmpty(); + $isEmpty = $records->isEmpty(); /** @Then the collection is empty */ - self::assertTrue($result); + self::assertTrue($isEmpty); } public function testAddingARecordYieldsACollectionOfOneElement(): void @@ -43,7 +43,7 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void identity: new OrderId(value: 'ord-1'), revision: Revision::initial(), occurredOn: Instant::now(), - snapshotData: new SnapshotData(data: []), + snapshotData: new SnapshotData(payload: []), aggregateType: 'Order', sequenceNumber: SequenceNumber::first() ); @@ -65,7 +65,7 @@ public function testFirstElementRoundTripsTheAddedRecord(): void identity: new OrderId(value: 'ord-1'), revision: Revision::initial(), occurredOn: Instant::now(), - snapshotData: new SnapshotData(data: []), + snapshotData: new SnapshotData(payload: []), aggregateType: 'Order', sequenceNumber: SequenceNumber::first() ); diff --git a/tests/Event/EventTypeTest.php b/tests/Event/EventTypeTest.php index f84daaf..537479c 100644 --- a/tests/Event/EventTypeTest.php +++ b/tests/Event/EventTypeTest.php @@ -55,10 +55,10 @@ public function testEqualsReturnsTrueForSameValue(): void $second = EventType::fromString(value: 'OrderPlaced'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentValues(): void @@ -70,10 +70,10 @@ public function testEqualsReturnsFalseForDifferentValues(): void $second = EventType::fromString(value: 'OrderShipped'); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } #[DataProvider('invalidPatterns')] @@ -115,11 +115,11 @@ public function testInvalidEventTypeCarriesTheOffendingValue(): void public static function invalidPatterns(): array { return [ - 'lowercase start' => ['orderPlaced'], - 'contains spaces' => ['Order Placed'], - 'empty string' => [''], + 'lowercase start' => ['orderPlaced'], + 'contains spaces' => ['Order Placed'], + 'empty string' => [''], 'contains underscore' => ['Order_Placed'], - 'single character' => ['O'] + 'single character' => ['O'] ]; } } diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php index 7ccde97..b72baa0 100644 --- a/tests/Event/RevisionTest.php +++ b/tests/Event/RevisionTest.php @@ -62,10 +62,10 @@ public function testEqualsReturnsTrueForSameRevision(): void $second = Revision::of(value: 2); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentRevisions(): void @@ -77,10 +77,10 @@ public function testEqualsReturnsFalseForDifferentRevisions(): void $second = Revision::of(value: 2); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } #[DataProvider('invalidValues')] @@ -89,7 +89,7 @@ public function testOfRejectsNonPositiveValue(int $invalidValue): void /** @Given a value that violates the revision invariant */ /** @Then an InvalidRevision exception carrying the invalid value is thrown */ $this->expectException(InvalidRevision::class); - $this->expectExceptionMessage((string) $invalidValue); + $this->expectExceptionMessage((string)$invalidValue); /** @When constructing with a non-positive value */ Revision::of(value: $invalidValue); @@ -122,7 +122,7 @@ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void public static function invalidValues(): array { return [ - 'zero' => [0], + 'zero' => [0], 'negative one' => [-1], 'negative ten' => [-10] ]; diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php index 1c326cf..75b30de 100644 --- a/tests/Event/SequenceNumberTest.php +++ b/tests/Event/SequenceNumberTest.php @@ -86,10 +86,10 @@ public function testIsAfterReturnsTrueWhenStrictlyGreater(): void $smaller = SequenceNumber::of(value: 5); /** @When checking if the larger is after the smaller */ - $result = $larger->isAfter(other: $smaller); + $isAfter = $larger->isAfter(other: $smaller); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($isAfter); } public function testIsAfterReturnsFalseWhenEqual(): void @@ -101,10 +101,10 @@ public function testIsAfterReturnsFalseWhenEqual(): void $second = SequenceNumber::of(value: 3); /** @When checking if one is strictly after the other */ - $result = $first->isAfter(other: $second); + $isAfter = $first->isAfter(other: $second); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($isAfter); } public function testIsAfterReturnsFalseWhenStrictlySmaller(): void @@ -116,10 +116,10 @@ public function testIsAfterReturnsFalseWhenStrictlySmaller(): void $larger = SequenceNumber::of(value: 8); /** @When checking if the smaller is after the larger */ - $result = $smaller->isAfter(other: $larger); + $isAfter = $smaller->isAfter(other: $larger); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($isAfter); } public function testEqualsReturnsTrueForSameValue(): void @@ -131,10 +131,10 @@ public function testEqualsReturnsTrueForSameValue(): void $second = SequenceNumber::of(value: 7); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentValues(): void @@ -146,10 +146,10 @@ public function testEqualsReturnsFalseForDifferentValues(): void $second = SequenceNumber::of(value: 2); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } #[DataProvider('negativeValues')] @@ -158,7 +158,7 @@ public function testOfRejectsNegativeValue(int $negativeValue): void /** @Given a value that violates the sequence-number invariant */ /** @Then an InvalidSequenceNumber exception carrying the invalid value is thrown */ $this->expectException(InvalidSequenceNumber::class); - $this->expectExceptionMessage((string) $negativeValue); + $this->expectExceptionMessage((string)$negativeValue); /** @When constructing with a negative value */ SequenceNumber::of(value: $negativeValue); diff --git a/tests/Event/SnapshotDataTest.php b/tests/Event/SnapshotDataTest.php index 8295140..def9529 100644 --- a/tests/Event/SnapshotDataTest.php +++ b/tests/Event/SnapshotDataTest.php @@ -13,19 +13,19 @@ final class SnapshotDataTest extends TestCase public function testToArrayReturnsTheOriginalPayload(): void { /** @Given snapshot data with a payload */ - $snapshotData = new SnapshotData(data: ['status' => 'placed', 'amount' => 100]); + $snapshotData = new SnapshotData(payload: ['status' => 'placed', 'amount' => 100]); /** @When converting to array */ - $result = $snapshotData->toArray(); + $payload = $snapshotData->toArray(); /** @Then the original data is returned */ - self::assertSame(['status' => 'placed', 'amount' => 100], $result); + self::assertSame(['status' => 'placed', 'amount' => 100], $payload); } public function testToJsonProducesValidJson(): void { /** @Given snapshot data with a simple payload */ - $snapshotData = new SnapshotData(data: ['status' => 'shipped']); + $snapshotData = new SnapshotData(payload: ['status' => 'shipped']); /** @When converting to JSON */ $json = $snapshotData->toJson(); @@ -37,7 +37,7 @@ public function testToJsonProducesValidJson(): void public function testToJsonPreservesZeroFractionOnFloats(): void { /** @Given snapshot data with a float value */ - $snapshotData = new SnapshotData(data: ['amount' => 1.0]); + $snapshotData = new SnapshotData(payload: ['amount' => 1.0]); /** @When converting to JSON with default flags */ $json = $snapshotData->toJson(); @@ -49,7 +49,7 @@ public function testToJsonPreservesZeroFractionOnFloats(): void public function testToJsonHonorsAdditionalFlags(): void { /** @Given snapshot data with a nested payload */ - $snapshotData = new SnapshotData(data: ['amount' => 1.0]); + $snapshotData = new SnapshotData(payload: ['amount' => 1.0]); /** @When converting to JSON with an additional pretty-print flag */ $json = $snapshotData->toJson(flags: JSON_PRESERVE_ZERO_FRACTION | JSON_PRETTY_PRINT); @@ -62,7 +62,7 @@ public function testToJsonHonorsAdditionalFlags(): void public function testToJsonThrowsForNonSerializableValue(): void { /** @Given snapshot data containing a non-JSON-serializable value */ - $snapshotData = new SnapshotData(data: ['infinity' => INF]); + $snapshotData = new SnapshotData(payload: ['infinity' => INF]); /** @Then a JsonException is thrown */ $this->expectException(JsonException::class); @@ -74,30 +74,30 @@ public function testToJsonThrowsForNonSerializableValue(): void public function testEqualsReturnsTrueForIdenticalPayloads(): void { /** @Given two snapshot data instances with identical payloads */ - $first = new SnapshotData(data: ['status' => 'placed']); + $first = new SnapshotData(payload: ['status' => 'placed']); /** @And a matching counterpart */ - $second = new SnapshotData(data: ['status' => 'placed']); + $second = new SnapshotData(payload: ['status' => 'placed']); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentPayloads(): void { /** @Given two snapshot data instances with different payloads */ - $first = new SnapshotData(data: ['status' => 'placed']); + $first = new SnapshotData(payload: ['status' => 'placed']); /** @And a distinct counterpart */ - $second = new SnapshotData(data: ['status' => 'shipped']); + $second = new SnapshotData(payload: ['status' => 'shipped']); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } } diff --git a/tests/Models/FileSnapshotter.php b/tests/Mocks/FileSnapshotter.php similarity index 91% rename from tests/Models/FileSnapshotter.php rename to tests/Mocks/FileSnapshotter.php index 20bca88..6dd157c 100644 --- a/tests/Models/FileSnapshotter.php +++ b/tests/Mocks/FileSnapshotter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Models; +namespace Test\TinyBlocks\BuildingBlocks\Mocks; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter; diff --git a/tests/Models/CartWithoutHandler.php b/tests/Models/CartWithoutHandler.php new file mode 100644 index 0000000..8c545f3 --- /dev/null +++ b/tests/Models/CartWithoutHandler.php @@ -0,0 +1,30 @@ + 1]; + return [...$payload, 'quantity' => 1]; } } diff --git a/tests/Models/ProductV2Upcaster.php b/tests/Models/ProductV2Upcaster.php index f34e7b1..efb23bb 100644 --- a/tests/Models/ProductV2Upcaster.php +++ b/tests/Models/ProductV2Upcaster.php @@ -15,8 +15,8 @@ final class ProductV2Upcaster implements Upcaster private const int FROM_REVISION = 2; private const int TO_REVISION = 3; - protected function doUpcast(array $data): array + protected function rewrite(array $payload): array { - return [...$data, 'notes' => '']; + return [...$payload, 'notes' => '']; } } diff --git a/tests/Snapshot/SnapshotConditionTest.php b/tests/Snapshot/SnapshotConditionTest.php index fc52258..c86daa3 100644 --- a/tests/Snapshot/SnapshotConditionTest.php +++ b/tests/Snapshot/SnapshotConditionTest.php @@ -17,51 +17,63 @@ public function testConditionHoldsAtInitialSequence(): void $cart = Cart::blank(identity: new CartId(value: 'cart-1')); /** @When asking the condition whether to snapshot */ - $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); /** @Then the condition holds because zero is divisible by two */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testConditionDoesNotHoldAfterOneEvent(): void { - /** @Given a cart advanced to sequence number one */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + + /** @And one product added advancing the sequence to one */ $cart->addProduct(productId: 'prod-1'); /** @When asking the condition whether to snapshot */ - $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); /** @Then the condition does not hold */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testConditionHoldsAgainAfterTwoEvents(): void { - /** @Given a cart advanced to sequence number two */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-3')); + + /** @And a first product added */ $cart->addProduct(productId: 'prod-1'); + + /** @And a second product advancing the sequence to two */ $cart->addProduct(productId: 'prod-2'); /** @When asking the condition whether to snapshot */ - $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); /** @Then the condition holds again at the next even step */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testConditionDoesNotHoldAfterThreeEvents(): void { - /** @Given a cart advanced to sequence number three */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + + /** @And a first product added */ $cart->addProduct(productId: 'prod-1'); + + /** @And a second product added */ $cart->addProduct(productId: 'prod-2'); + + /** @And a third product advancing the sequence to three */ $cart->addProduct(productId: 'prod-3'); /** @When asking the condition whether to snapshot */ - $result = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = new EveryTwoEvents()->shouldSnapshot(aggregate: $cart); /** @Then the condition does not hold at an odd step */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } } diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php index 4177b9f..c8d7795 100644 --- a/tests/Snapshot/SnapshotTest.php +++ b/tests/Snapshot/SnapshotTest.php @@ -141,10 +141,10 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void ); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void @@ -170,9 +170,9 @@ public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void ); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); } } diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Snapshot/SnapshotterBehaviorTest.php index 9f9520b..4ec2715 100644 --- a/tests/Snapshot/SnapshotterBehaviorTest.php +++ b/tests/Snapshot/SnapshotterBehaviorTest.php @@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; -use Test\TinyBlocks\BuildingBlocks\Models\FileSnapshotter; +use Test\TinyBlocks\BuildingBlocks\Mocks\FileSnapshotter; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class SnapshotterBehaviorTest extends TestCase diff --git a/tests/Upcast/IntermediateEventTest.php b/tests/Upcast/IntermediateEventTest.php index 9be2660..900df90 100644 --- a/tests/Upcast/IntermediateEventTest.php +++ b/tests/Upcast/IntermediateEventTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; +use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; -use TinyBlocks\Mapper\KeyPreservation; final class IntermediateEventTest extends TestCase { @@ -123,10 +123,10 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are equal */ - self::assertTrue($result); + self::assertTrue($areEqual); } public function testEqualsReturnsFalseForDifferentPayloads(): void @@ -140,10 +140,81 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']); /** @When comparing them */ - $result = $first->equals(other: $second); + $areEqual = $first->equals(other: $second); /** @Then they are not equal */ - self::assertFalse($result); + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenOnlyTypeDiffers(): void + { + /** @Given two intermediate events sharing revision and payload */ + $revision = Revision::initial(); + + /** @And differing only by type */ + $first = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: $revision, + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @And a counterpart with a different type */ + $second = new IntermediateEvent( + type: EventType::fromString(value: 'ProductRemoved'), + revision: $revision, + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenOnlyRevisionDiffers(): void + { + /** @Given two intermediate events sharing type and payload */ + $eventType = EventType::fromString(value: 'ProductAdded'); + + /** @And differing only by revision */ + $first = new IntermediateEvent( + type: $eventType, + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @And a counterpart at a later revision */ + $second = new IntermediateEvent( + type: $eventType, + revision: Revision::of(value: 2), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenOtherIsDifferentValueObjectType(): void + { + /** @Given an intermediate event */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @And a value object of a different class */ + $otherValueObject = SequenceNumber::first(); + + /** @When comparing them */ + $areEqual = $event->equals(other: $otherValueObject); + + /** @Then they are not equal */ + self::assertFalse($areEqual); } public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void @@ -157,8 +228,8 @@ public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void /** @When reconstituting from an iterable of typed values */ $restored = IntermediateEvent::fromIterable(iterable: [ - 'type' => EventType::fromString(value: 'ProductAdded'), - 'revision' => Revision::initial(), + 'type' => EventType::fromString(value: 'ProductAdded'), + 'revision' => Revision::initial(), 'serializedEvent' => ['productId' => 'prod-1'] ]); @@ -176,7 +247,7 @@ public function testToArraySerializesTypeAndRevisionToScalars(): void ); /** @When converting to array */ - $array = $event->toArray(KeyPreservation::PRESERVE); + $array = $event->toArray(); /** @Then type and revision are unwrapped to their scalar values */ self::assertSame('ProductAdded', $array['type']); @@ -194,7 +265,7 @@ public function testToJsonSerializesToJsonString(): void ); /** @When converting to JSON */ - $json = $event->toJson(KeyPreservation::PRESERVE); + $json = $event->toJson(); /** @Then the result is a valid JSON string with the expected structure */ self::assertSame( diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Upcast/SingleUpcasterBehaviorTest.php index cb1ccc9..1b09712 100644 --- a/tests/Upcast/SingleUpcasterBehaviorTest.php +++ b/tests/Upcast/SingleUpcasterBehaviorTest.php @@ -54,10 +54,10 @@ public function testUpcastReturnsUnchangedEventForMismatchedType(): void ); /** @When applying the upcaster */ - $result = new ProductV1Upcaster()->upcast(event: $event); + $upcasted = new ProductV1Upcaster()->upcast(event: $event); /** @Then the same instance is returned unchanged */ - self::assertSame($event, $result); + self::assertSame($event, $upcasted); } public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void @@ -70,9 +70,9 @@ public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void ); /** @When applying the upcaster */ - $result = new ProductV1Upcaster()->upcast(event: $event); + $upcasted = new ProductV1Upcaster()->upcast(event: $event); /** @Then the same instance is returned unchanged */ - self::assertSame($event, $result); + self::assertSame($event, $upcasted); } } diff --git a/tests/Upcast/UpcastersTest.php b/tests/Upcast/UpcastersTest.php index 1ec84c4..665c1ee 100644 --- a/tests/Upcast/UpcastersTest.php +++ b/tests/Upcast/UpcastersTest.php @@ -24,10 +24,10 @@ public function testEmptyChainReturnsEventUnchanged(): void ); /** @When chaining through an empty Upcasters collection */ - $result = Upcasters::createFromEmpty()->chain(event: $event); + $chained = Upcasters::createFromEmpty()->chain(event: $event); /** @Then the event is returned unchanged */ - self::assertTrue($result->equals(other: $event)); + self::assertTrue($chained->equals(other: $event)); } public function testSingleMatchingUpcasterTransformsEvent(): void @@ -43,11 +43,11 @@ public function testSingleMatchingUpcasterTransformsEvent(): void $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]); /** @When chaining the event */ - $result = $upcasters->chain(event: $event); + $chained = $upcasters->chain(event: $event); /** @Then the revision advances to 2 and the payload gains the quantity field */ - self::assertSame(2, $result->revision->value); - self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $result->serializedEvent); + self::assertSame(2, $chained->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $chained->serializedEvent); } public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void @@ -63,10 +63,10 @@ public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]); /** @When chaining the event */ - $result = $upcasters->chain(event: $event); + $chained = $upcasters->chain(event: $event); /** @Then the event is returned unchanged */ - self::assertTrue($result->equals(other: $event)); + self::assertTrue($chained->equals(other: $event)); } public function testChainedUpcastersApplySequentially(): void @@ -82,11 +82,11 @@ public function testChainedUpcastersApplySequentially(): void $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]); /** @When chaining the event */ - $result = $upcasters->chain(event: $event); + $chained = $upcasters->chain(event: $event); /** @Then the revision reaches 3 and both fields are added */ - self::assertSame(3, $result->revision->value); - self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent); + self::assertSame(3, $chained->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $chained->serializedEvent); } public function testOnlyMatchingUpcastersInChainApply(): void @@ -102,10 +102,10 @@ public function testOnlyMatchingUpcastersInChainApply(): void $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]); /** @When chaining the event */ - $result = $upcasters->chain(event: $event); + $chained = $upcasters->chain(event: $event); /** @Then only V2 applies: revision advances to 3 and notes is added */ - self::assertSame(3, $result->revision->value); - self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent); + self::assertSame(3, $chained->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $chained->serializedEvent); } }