diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 11885b0..a561aa6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,32 +1,16 @@ -# Project - -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, and documentation conventions -are defined in `rules/`. Read the applicable rule files before generating any code or documentation. - -## Commands - -- `make test` — run tests with coverage. -- `make mutation-test` — run mutation testing (Infection). -- `make review` — run lint. -- `make help` — list all available commands. - -## Post-change validation - -After any code change, run `make review`, `make test`, and `make mutation-test`. -If any fails, iterate on the fix while respecting all project rules until all pass. -Never deliver code that breaks lint, tests, or leaves surviving mutants. - -## File formatting - -Every file produced or modified must: - -- Use **LF** line endings. Never CRLF. -- Have no trailing whitespace on any line. -- End with a single trailing newline. -- Have no consecutive blank lines (max one blank line between blocks). +# CLAUDE.md + +This is a PHP library in the tiny-blocks ecosystem. Detailed rules live in `.claude/rules/`. +Each file is scoped via its `paths` frontmatter. Read the relevant file before producing or +editing content under its scope. + +## Rule files + +- `php-library-architecture.md` — folder structure, public API boundary, `Internal/` semantics. +- `php-library-code-style.md` — semantic code rules for `.php` files in `src/` and `tests/`. +- `php-library-commits.md` — Conventional Commits format. Applied only when generating commit messages. +- `php-library-documentation.md` — README and Markdown documentation standards. +- `php-library-github-workflows.md` — CI workflow structure and action pinning. +- `php-library-modeling.md` — nomenclature, value objects, exceptions, enums, complexity. +- `php-library-testing.md` — BDD Given/When/Then, PHPUnit conventions, coverage discipline. +- `php-library-tooling.md` — canonical config files (`composer.json`, `phpcs.xml`, etc). diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md deleted file mode 100644 index a369ba4..0000000 --- a/.claude/rules/github-workflows.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -description: Naming, ordering, inputs, security, and structural rules for all GitHub Actions workflow files. -paths: - - ".github/workflows/**/*.yml" - - ".github/workflows/**/*.yaml" ---- - -# Workflows - -Structural and stylistic rules for GitHub Actions workflow files. Refer to `shell-scripts.md` for Bash conventions used -inside `run:` steps, and to `terraforms.md` for Terraform conventions used in `terraform/`. - -## Pre-output checklist - -Verify every item before producing any workflow YAML. If any item fails, revise before outputting. - -1. File name follows the convention: `ci-.yml` for reusable CI, `cd-.yml` for dispatch CD. -2. `name` field follows the pattern `CI — ` or `CD — `, using sentence case after the dash - (e.g., `CD — Run migration`, not `CD — Run Migration`). -3. Reusable workflows use `workflow_call` trigger. CD workflows use `workflow_dispatch` trigger. -4. Each workflow has a single responsibility. CI tests code. CD deploys it. Never combine both. -5. Every input has a `description` field. Descriptions use American English and end with a period. -6. Input names use `kebab-case`: `service-name`, `dry-run`, `skip-build`. -7. Inputs are ordered: required first, then optional. Each group by **name length ascending**. -8. Choice input options are in **alphabetical order**. -9. `env`, `outputs`, and `with` entries are ordered by **key length ascending**. -10. `permissions` keys are ordered by **key length ascending** (`contents` before `id-token`). -11. Top-level workflow keys follow canonical order: `name`, `on`, `concurrency`, `permissions`, `env`, `jobs`. -12. Job-level properties follow canonical order: `if`, `name`, `needs`, `uses`, `with`, `runs-on`, - `environment`, `timeout-minutes`, `strategy`, `outputs`, `permissions`, `env`, `steps`. -13. All other YAML property names within a block are ordered by **name length ascending**. -14. Jobs follow execution order: `load-config` → `lint` → `test` → `build` → `deploy`. -15. Step names start with a verb and use sentence case: `Setup PHP`, `Run lint`, `Resolve image tag`. -16. Runtime versions are resolved from the service repo's native dependency file (`composer.json`, `go.mod`, - `package.json`). No version is hardcoded in any workflow. -17. Service-specific overrides live in a pipeline config file (e.g., `.pipeline.yml`) in the service repo, - not in the workflows repository. -18. The `load-config` job reads the pipeline config file at runtime with safe fallback to defaults when absent. -19. Top-level `permissions` defaults to read-only (`contents: read`). Jobs escalate only the permissions they - need. -20. AWS authentication uses OIDC federation exclusively. Static access keys are forbidden. -21. Secrets are passed via `secrets: inherit` from callers. No secret is hardcoded. -22. Sensitive values fetched from SSM are masked with `::add-mask::` before assignment. -23. Third-party actions are pinned to the latest available full commit SHA with a version comment: - `uses: aws-actions/configure-aws-credentials@ # v4.0.2`. Always verify the latest - version before generating a workflow. -24. First-party actions (`actions/*`) are pinned to the latest major version tag available: - `actions/checkout@v4`. Always check for the most recent major version before generating a workflow. -25. Production deployments require GitHub Environments protection rules (manual approval). -26. Every job sets `timeout-minutes` to prevent indefinite hangs. CI jobs: 10–15 minutes. CD jobs: 20–30 - minutes. Adjust only with justification in a comment. -27. CI workflows set `concurrency` with `group` scoped to the PR and `cancel-in-progress: true` to avoid - redundant runs. -28. CD workflows set `concurrency` with `group` scoped to the environment and `cancel-in-progress: false` to - prevent interrupted deployments. -29. CD workflows use `if: ${{ !cancelled() }}` to allow to deploy after optional build steps. -30. Inline logic longer than 3 lines is extracted to a script in `scripts/ci/` or `scripts/cd/`. - -## Style - -- All text (workflow names, step names, input descriptions, comments) uses American English with correct - spelling and punctuation. Sentences and descriptions end with a period. - -## Callers - -- Callers trigger on `pull_request` targeting `main` only. No `push` trigger. -- Callers in service repos are static (~10 lines) and pass only `service-name` or `app-name`. -- Callers reference workflows with `@main` during development. Pin to a tag or SHA for production. - -## Image tagging - -- CD deploy builds: `-sha-` + `latest`. - -## Migrations - -- Migrations run **before** service deployment (schema first, code second). -- `cd-migrate.yml` supports `dry-run` mode (`flyway validate`) for pre-flight checks. -- Database credentials are fetched from SSM at runtime, never stored in workflow files. diff --git a/.claude/rules/php-library-architecture.md b/.claude/rules/php-library-architecture.md new file mode 100644 index 0000000..7e4be10 --- /dev/null +++ b/.claude/rules/php-library-architecture.md @@ -0,0 +1,145 @@ +--- +description: Folder structure, public API boundary, and Internal/ semantics for PHP libraries. +paths: + - "src/**/*.php" +--- + +# Architecture + +Covers the physical layout of the library. Folder structure, the boundary between public API and +implementation detail, and where each type of class lives. Semantic rules (value objects, +exceptions, enums, complexity, nomenclature) live in `php-library-modeling.md`. Code style lives +in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before producing or relocating any file. If any item fails, revise before +outputting. + +1. None of the following folder names exist in `src/`: `Models/`, `Entities/`, `ValueObjects/`, + `Enums/`, `Domain/`. They carry no semantic content and conflate technical role with domain + meaning. +2. The `src/` root contains only interfaces, extension points, public enums, thin orchestration + classes, and primary implementations or façades. Substantial logic (algorithms, state machines, + I/O) lives in `src/Internal/`, never at the root. +3. `src/Internal/` is implementation detail and not part of the public API. Breaking changes + inside `src/Internal/` are not semver-breaking. +4. Consumers must not reference, extend, or depend on any type inside `src/Internal/`. The + namespace itself is the boundary. +5. Public exception classes live in `src/Exceptions/`. +6. Internal exception classes live in `src/Internal/Exceptions/`. +7. Public enums live at the `src/` root or inside a public `/` folder. Enums used + only by internals live in `src/Internal/`. +8. Public interfaces live at the `src/` root or inside a public `/` folder. +9. A `/` folder at the `src/` root groups related public types under a shared + concept. Each group has its own namespace and is part of the public API. +10. `/` is optional. Use it only when the library exposes several coherent groups of + types (for example, aggregates and events) rather than a flat set of types around a single + concept. +11. Test fixtures representing domain concepts live in `tests/Models/`. Test doubles for system + boundaries live at the root of `tests/Unit/` or `tests/Integration/`. No dedicated `Mocks/` + or `Doubles/` subdirectory exists. `tests/Drivers//` is permitted when the library + exposes a port exercised against multiple third-party implementations (PSR adapters, + framework integrations). Each `/` subdir holds tests against one specific + implementation. +12. The `tests/Integration/` folder exists only when the library interacts with external + infrastructure (filesystem, database, network). Otherwise, the folder is absent. + +## Folder structure + +Canonical layout for a PHP library in the tiny-blocks ecosystem. + +``` +src/ +├── .php # public contract at root +├── .php # main implementation or extension point at root +├── .php # public enum at root +├── / # public folder grouping related public types under a shared concept +│ ├── .php +│ └── ... +├── Internal/ # implementation details, not part of the public API +│ ├── .php +│ └── Exceptions/ # internal exception classes +└── Exceptions/ # public exception classes + +tests/ +├── Models/ # domain fixtures reused across tests +├── Unit/ # unit tests targeting the public API +│ ├── .php # test doubles at root of Unit/ +│ └── .php +└── Integration/ # only present when the library interacts with infrastructure + └── .php # test doubles at root of Integration/ when needed +``` + +Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. They +carry no semantic content and describe technical role instead of domain meaning. + +## Public API boundary + +The `src/` root is the contract. Everything at the root, plus everything inside public +`/` folders and the public `Exceptions/` folder, is what consumers depend on. Changes +to these types follow semver rules. + +`src/Internal/` is implementation detail. The namespace itself signals the boundary. Consumers +must not depend on any type inside `src/Internal/`. Breaking changes inside `src/Internal/` are +not semver-breaking for the library. + +### What lives at the public boundary + +- Interfaces that define contracts for consumers. +- Extension points designed to be subclassed or composed by consumers. +- Public enums and value objects consumers manipulate directly. +- Thin orchestration classes that wire collaborators together without containing substantial logic. +- Public exception classes consumers may catch. + +### What lives in `src/Internal/` + +- Algorithms, state machines, and complex transformations. +- Adapters for I/O (filesystem, network, database). +- Collaborators that exist purely to break a public class into testable units. +- Implementation details that may change between minor or patch releases. +- Internal exception classes raised by collaborators. + +## Reference examples + +### Small library with flat root + +``` +src/ +├── Timezone.php # public value object +├── Timezones.php # public collection +├── Clock.php # public interface +└── Internal/ + ├── SystemClock.php # default Clock implementation + └── Exceptions/ + └── InvalidTimezone.php +``` + +Everything lives at the root or inside `Internal/`. No `/` folders. Suitable when +the library exposes a small, cohesive set of types around a single concept. + +### Library with public concept groups + +``` +src/ +├── ValueObject.php # public extension point at root +├── Aggregate/ # public namespace grouping aggregate types +│ ├── AggregateRoot.php +│ ├── EventualAggregateRoot.php +│ └── ModelVersion.php +├── Event/ # public namespace grouping event types +│ ├── EventRecord.php +│ ├── EventRecords.php +│ └── SequenceNumber.php +├── Internal/ +│ ├── DefaultModelVersionResolver.php +│ └── Exceptions/ +│ └── InvalidSequenceNumber.php +└── Exceptions/ + └── EventRecordingFailure.php +``` + +`Aggregate/` and `Event/` are public folders at the root, each grouping a coherent set of public +types under one shared concept. Consumers import directly, for example +`TinyBlocks\\Aggregate\AggregateRoot`. Suitable when the library exposes several distinct +concept areas, each with its own set of related types. diff --git a/.claude/rules/php-library-code-style.md b/.claude/rules/php-library-code-style.md index 7ec196e..8485df7 100644 --- a/.claude/rules/php-library-code-style.md +++ b/.claude/rules/php-library-code-style.md @@ -1,5 +1,5 @@ --- -description: Pre-output checklist, naming, typing, complexity, and PHPDoc rules for all PHP files in libraries. +description: Semantic code rules for all PHP files in libraries. paths: - "src/**/*.php" - "tests/**/*.php" @@ -7,136 +7,447 @@ paths: # 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 `php-library-modeling.md` for library modeling rules. +Semantic rules for all PHP files in libraries. Formatting rules covered by `PSR-12` are enforced +by `phpcs.xml`. Two formatting rules outside `PSR-12` (no vertical alignment, no trailing comma in +multi-line lists) are documented at the end of this file under "Formatting overrides". Complexity +rules live in `php-library-modeling.md`. Folder structure, public API boundary, and the semantics +of `Internal/` live in `php-library-architecture.md`. ## Pre-output checklist Verify every item before producing any PHP code. If any item fails, revise before outputting. 1. `declare(strict_types=1)` is present. -2. All classes are `final readonly` by default. Use `class` (without `final` or `readonly`) only when the class is - designed as an extension point for consumers (e.g., `Collection`, `ValueObject`). Use `final class` without - `readonly` only when the parent class is not readonly (e.g., extending a third-party abstract class). -3. All parameters, return types, and properties have explicit types. -4. Constructor property promotion is used. -5. Named arguments are used at call sites for own code, tests, and third-party library methods (e.g., tiny-blocks). - Never use named arguments on native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, - `iterator_to_array`, `sprintf`, `implode`, etc.) or PHPUnit assertions (`assertEquals`, `assertSame`, - `assertTrue`, `expectException`, etc.). -6. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. -7. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of `$acc`. -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 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 - group, order by body size ascending (number of lines between `{` and `}`). Constants and enum cases, which have - 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. 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. 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). +2. All parameters, return types, and properties have explicit types. +3. Constructor property promotion is used. +4. Named arguments are used at call sites for own code, tests, and third-party library methods + (for example, tiny-blocks). Never use named arguments on: + - Native PHP functions (`array_map`, `in_array`, `preg_match`, `is_null`, + `iterator_to_array`, `sprintf`, `implode`, and similar). + - Native PHP enum methods (`from`, `tryFrom`, `cases`). + - PHPUnit assertions and expectations (`assertEquals`, `assertSame`, `assertTrue`, + `expectException`, and similar). + - Interfaces from PHP-FIG PSR standards (PSR-7 `withHeader`, PSR-18 `sendRequest`, etc.). + The PSR contract does not include parameter names. Implementations may rename parameters. + - Calls that include variadic spread (`...$args`). PHP rejects positional argument unpacking + after named arguments. When the caller passes through a `...$variadic`, all arguments are + positional. New own-code APIs should prefer a typed collection parameter over a variadic + so named-argument call sites remain possible. + + Native PHP **class constructors** (`parent::__construct` calls to `\Exception`, + `\RuntimeException`, `\InvalidArgumentException`, `\LogicException`, and similar) are not + in the list above. They accept named arguments, and rule 8 requires using them whenever + the positional call would pass an argument whose value equals the parameter's default. + Example: `parent::__construct(message: sprintf(...), previous: $previous)` instead of + `parent::__construct(sprintf(...), 0, $previous)`. The exclusion above covers native + functions and enum methods, not native class instantiation. +5. Classes follow the rules in "Inheritance and constructors". `final readonly` is the default, + with documented exceptions for extension points and for parents that are not `readonly`. +6. Members are ordered constants first, then constructor, then static methods, then instance + methods. Within each group, order by body size ascending (number of lines between `{` and `}`). + Constants and enum cases, which have no body, are ordered by name length ascending. This + ordering may be overridden only when the alternative carries explicit documentation value: + grouping by domain class with section markers (HTTP status codes by 1xx/2xx/3xx/etc), + mirroring the order of an implemented interface, or similar evident structure. The override + must be obvious at first reading. + + **At call sites** (chained method calls in production code, tests, or documentation + examples), consecutive method invocations on the same receiver are ordered by the **visible + width** of each call expression ascending. The body is not visible at the call site, so the + visible width is the practical proxy for body size. Boolean toggles such as `->secure()` and + `->httpOnly()` come before parameterized `with*` builders for the same reason. When two + calls have equal width, order them alphabetically by method name. + + **Terminal methods that change the receiver type** stay at the end of the chain regardless + of width. A `build()` that returns the built value, a `commit()` that finalizes a unit of + work, a `send()` that flushes a request, are terminal: the chain ends with them. The + ordering rule applies only to consecutive calls on the same receiver type; calls that + transition to a different type are not reorderable. The same applies in reverse to the + factory or accessor that starts the chain (`Cookie::create(...)`, `$repository`) — it stays + at its position. +7. Constructor parameters are ordered by parameter name length ascending (count the name only, + without `$` or type), except when parameters have an implicit semantic order (for example, + `$start/$end`, `$from/$to`, `$startAt/$endAt`), which takes precedence. Parameters with default + values go last, regardless of name length. The same rule applies to named arguments at call + sites. Example order: `$id` (2), `$value` (5), `$status` (6), `$precision` (9). +8. Never pass an argument whose value equals the parameter's default. Omit the argument entirely. + Example with `toArray(KeyPreservation $keyPreservation = KeyPreservation::PRESERVE)`. The call + `$collection->toArray(keyPreservation: KeyPreservation::PRESERVE)` becomes + `$collection->toArray()`. Only pass the argument when the value differs from the default. +9. No `else` or `else if` exists anywhere. Use early returns, polymorphism, or map dispatch instead. +10. No abbreviations appear in identifiers. Use `$index` instead of `$i`, `$account` instead of + `$acc`. +11. No generic identifiers exist. Use domain-specific names instead. Examples are `$data` to + `$payload`, `$value` to `$totalAmount`, `$item` to `$element`, `$info` to `$currencyDetails`, + `$result` to `$conversionOutcome`. +12. No raw arrays exist where a typed collection or value object is available. When data is + `Collectible`, use the `tiny-blocks/collection` fluent API (`Collection`, `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" for the full rule and example. +13. No private methods exist except for private constructors in factory patterns, methods inside + `src/Internal/` (implementation detail by definition, where the namespace is the abstraction + boundary), and `setUp` or `tearDown` overrides in PHPUnit test classes. Outside these cases, + inline trivial logic at the call site or extract it to a collaborator or value object. 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. -17. No justification comments exist (`// NOTE:`, `// REASON:`, etc.). Code speaks for itself. -18. `// TODO: ` is used when implementation is unknown, uncertain, or intentionally deferred. - Never leave silent gaps. -19. All class references use `use` imports at the top of the file. Fully qualified names inline are prohibited. -20. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. -21. Never create public methods, constants, or classes in `src/` solely to serve tests. If production code does not - need it, it does not exist. -22. Always use the most current and clean syntax available in the target PHP version. Prefer match to switch, - first-class callables over `Closure::fromCallable()`, readonly promotion over manual assignment, enum methods - over external switch/if chains, named arguments over positional ambiguity (except where excluded by rule 5), - and `Collection::map` over foreach accumulation. -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 `{` 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, - ); - ``` +16. No inline comments exist in `src/` or `tests/`, except `# TODO: ` when implementation + is unknown, uncertain, or intentionally deferred. Code is the documentation. Block comments + (`/* */`) never appear outside docblocks (`/** */`). The `#` style for inline PHP comments + applies only to code examples inside Markdown files (see `php-library-documentation.md`). +17. No dead or unused code exists. Remove unreferenced classes, methods, constants, and imports. +18. Never create public methods, constants, or classes in `src/` solely to serve tests. If + production code does not need it, it does not exist. +19. Format strings with placeholders (`%s`, `%d`, `%f`, etc.) are assigned to a `$template` + variable before being passed to `sprintf`. The variable assignment and the `sprintf` call live + on separate statements. See "Format strings" for examples. +20. All class references use `use` imports at the top of the file. Fully qualified names inline are + prohibited. +21. Return types and `new` calls use the explicit class name. `self` is prohibited as a type, + as a return type, and in `new self()` instantiation. Constant access via `self::CONST_NAME` + is permitted. `static` is permitted only inside extension-point classes (declared `class` + without `final readonly`) and inside traits, where late static binding lets subclasses or + consuming classes instantiate the correct concrete type. In every other context, use the + class name. +22. Always use the most current and clean syntax available in the target PHP version. Prefer + `match` over `switch`, first-class callables over `Closure::fromCallable()`, readonly promotion + over manual assignment, enum methods over external switch or if chains, named arguments over + positional ambiguity (except where excluded by rule 4), `Collection::map` over foreach + accumulation, and **unparenthesized constructor chaining** (PHP 8.4+): + `new Foo()->bar()` instead of `(new Foo())->bar()`. The parentheses around the `new` + expression are no longer required and add visual noise. +23. All identifiers, comments, and documentation use American English. See "American English" for + the spelling list. -## Casing conventions +## Naming -- Internal code (variables, methods, classes): **`camelCase`**. -- Constants and enum-backed values when representing codes: **`SCREAMING_SNAKE_CASE`**. +- Internal code (variables, methods, classes) uses `camelCase`. +- Constants and enum-backed values when representing codes use `SCREAMING_SNAKE_CASE`. +- Names describe what in domain terms, not how technically. `$monthlyRevenue` instead of + `$calculatedValue`. Generic technical verbs are avoided. See `php-library-modeling.md` for the + full banlist of generic and anemic names. +- Booleans use predicate form. Examples are `isActive`, `hasPermission`, `wasProcessed`. +- Collections are always plural. Examples are `$orders`, `$lines`. +- Methods returning `bool` use prefixes `is`, `has`, `can`, `was`, `should`. -## Naming +## Class self-references + +Type declarations, return types, and `new` calls inside a class use the explicit class name. +The class name is unambiguous, survives refactors that move the method to a different class, +and reads identically inside the class body and at the call site. + +- `self` is prohibited everywhere as a type, as a return type, and in `new self()` + instantiation. Constant access via `self::CONST_NAME` is **permitted**. The prohibition + covers the forms that carry refactoring ambiguity when a method moves to a different class + (the type-or-instantiation forms). Constant access does not have that ambiguity because the + constant is declared in the same class body. +- `static` is permitted only inside extension-point classes (declared `class` without + `final readonly`) and inside traits, where late static binding is required for subclasses or + consuming classes to instantiate the correct concrete type. +- In every other context (the default `final readonly class`, factory methods, return types), + use the class name. + +**Prohibited.** `self` as return type and `new self()` inside a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): self + { + return new self(product: $product); + } +} +``` + +**Correct.** Explicit class name in a final class: + +```php +final readonly class UserAgent +{ + public static function from(string $product): UserAgent + { + return new UserAgent(product: $product); + } +} +``` -- Names describe **what** in domain terms, not **how** technically: `$monthlyRevenue` instead of `$calculatedValue`. -- 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`. +**Correct.** `static` permitted in an extension-point class: + +```php +class Collection +{ + public static function createFrom(iterable $elements): static + { + return new static(elements: $elements); + } +} +``` + +## Inheritance and constructors + +- All classes are `final readonly` by default. +- Use `class` (without `final` or `readonly`) only when the class is designed as an extension point + for consumers, for example `Collection` or `ValueObject`. +- Use `final class` without `readonly` only when the parent class is not readonly, for example + when extending a third-party abstract class. +- Use `final class` without `readonly` is also permitted for `src/Internal/` collaborators that + carry intrinsically mutable state (resource handles, counters, cursors) where the mutation is + central to the class's responsibility (`Stream` closing a resource, `Cursor` advancing a + position). The class must remain confined to `src/Internal/`. +- Use `final class` without `readonly` for classes that consist exclusively of `static` methods + (no instance properties, no instance methods, only static factories or utilities). Pair it + with `private function __construct() {}` to prevent instantiation. `readonly` is meaningless + without instance state, and the private constructor signals that the class is a static + surface, not a value type. +- Inheritance between concrete classes is prohibited. Every concrete class is `final`. +- Polymorphism uses interfaces plus composition, never extension of concrete types. +- The only allowed `extends` is against framework or SPL base classes that the language requires. + Examples are `RuntimeException`, `LogicException`, `PHPUnit\Framework\TestCase`. +- Constructors of `final` classes are `private` when paired with named factory methods, `public` + otherwise. `protected` constructors are prohibited because no subclasses exist to call them. ## Comparisons -1. Null checks: use `is_null($variable)`, never `$variable === null`. -2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings - because `empty('0')` returns `true`. -3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. +1. Null checks use `is_null($variable)`, never `$variable === null`. +2. Empty string checks on typed `string` parameters use `$variable === ''`. Avoid `empty()` on + typed strings because `empty('0')` returns `true`. +3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`) use + `empty($variable)`. ## American English -All identifiers, enum values, comments, and error codes use American English spelling: -`canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not `initialise`), -`behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not `labelled`), -`fulfill` (not `fulfil`), `color` (not `colour`). +All identifiers, enum values, comments, and error codes use American English spelling. Examples +are `canceled` (not `cancelled`), `organization` (not `organisation`), `initialize` (not +`initialise`), `behavior` (not `behaviour`), `modeling` (not `modelling`), `labeled` (not +`labelled`), `fulfill` (not `fulfil`), `color` (not `colour`). ## PHPDoc -- PHPDoc is restricted to interfaces only, documenting obligations, `@throws`, and complexity. -- Never add PHPDoc to concrete classes. +### When required + +- Every method of an interface, **including interfaces declared inside `src/Internal/`**. + Interfaces define contracts. The contract is documentation by definition, regardless of + namespace. The `Internal/` boundary applies to implementations, not to the contracts that + internal collaborators expose to each other. +- Every public method of a concrete class outside `src/Internal/`. Public classes are at the + public API boundary by definition. Consumers call every public method directly, and the + PHPDoc is the contract for each call. Trivial getters and `with*` methods are not exempt. + The only exception is a public method whose contract is already documented on an implemented + interface (the interface carries the docblock). + +### When prohibited + +- Constructors. The constructor signature with property promotion is self-documenting. Parameter + types are already explicit in the signature. +- Private and protected methods. +- Public methods of concrete classes whose contract is already documented on an implemented + interface. The interface carries the docblock. +- Anything inside `src/Internal/`. Internal types are implementation detail and must not carry + PHPDoc. The namespace itself is the boundary. See `php-library-architecture.md` for the + architectural meaning of `Internal/`. **Exception**: interfaces and their methods. An + interface declared inside `src/Internal/` still defines a contract, and the contract is + documented per `### When required` regardless of namespace. The prohibition covers concrete + classes, traits, enums, and anonymous classes inside `Internal/`, never interfaces. +- Anywhere inside `tests/`. Test methods name the scenario via the `testXxxWhenYyyGivenThenZzz` + naming convention, and the `@Given`/`@When`/`@Then`/`@And` annotation blocks defined in + `php-library-testing.md` describe the steps. PHPDoc documentation (summary plus + `@param`/`@return` descriptions) is prohibited on test methods, data providers, fixtures, + setUp/tearDown overrides, and anonymous classes inside tests. The BDD annotations are not + PHPDoc documentation in the sense of this section and remain required per the testing rule. +- Single-line PHPDocs with only a tag (`/** @param ... */`, `/** @return ... */`, + `/** @throws ... */`). PHPDoc always opens with a summary line. Bare-tag docblocks are + prohibited regardless of how few tags they carry. + +The prohibitions above apply to **every form of PHPDoc** in the prohibited scope: +method-level docblocks, property-level docblocks, inline `@var` annotations on local variables, +and PHPDoc blocks placed above anonymous functions or closures inside method bodies. Inside +`src/Internal/` and `tests/`, zero PHPDoc is the rule with no exception. PHPStan errors that +result from the missing annotations route through `ignoreErrors` (see below). + +The PHPDoc prohibitions above take priority over the typed-array case. When PHPStan at +`level: max` flags a missing iterable value type (`missingType.iterableValue`, +`argument.type`, `return.type`): + +- On a **constructor parameter** → suppress via `ignoreErrors` in `phpstan.neon.dist`. Do not + add PHPDoc. +- On anything inside **`src/Internal/`** (concrete classes, traits, enums) → suppress via + `ignoreErrors`. Do not add PHPDoc. Interfaces inside `src/Internal/` are the exception: + they carry PHPDoc per `### When required`, and the PHPStan errors they raise are resolved + through the PHPDoc, never through `ignoreErrors`. +- On anything inside **`tests/`** → suppress via `ignoreErrors`. Do not add PHPDoc. +- On a **public method of a public (non-Internal) class** → add full PHPDoc with summary, + `@param` descriptions, and the typed-array information. The bare-tag form remains + prohibited. This is the normal case where PHPDoc is permitted by "When required" above. + +The summary requirement and the bare-tag prohibition are never waived. Use `ignoreErrors` only +when the context (constructor, `src/Internal/`, `tests/`) makes PHPDoc impossible. Every public +method of a public concrete class carries PHPDoc per "When required", whether the method +has typed-array parameters. + +### Style + +- Summary on the first line, in domain terms. **Mandatory.** PHPDoc without a summary line is + prohibited, even when it carries a single `@param` or `@return`. +- Optional detailed body in `

` paragraphs below the summary. +- Tags use the form `@param Type $name Description.`, `@return Type Description.`, + `@throws ExceptionClass If .`. - 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). +- HTML tags allowed inside descriptions are `

` for paragraphs, `

  • ` for lists, + `` for inline code, `` and `` for emphasis. + +### Summary patterns + +The summary line is not a creative intent statement. It is a template selected by the method's +name prefix. Apply the matching template. Only methods with no matching prefix require a +free-form one-line summary in domain terms. + +| Method shape | Template | +|-------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| Static factory (`create`, `from`, `fromX`, `with*` when static) | `Creates a {ClassName} from {input}.` or `Builds a {ClassName} with {fields}.` | +| `with*` instance method | `Returns a copy of the {ClassName} with the {field} replaced.` | +| Getter (no prefix, returns a property: `code()`, `body()`, `headers()`) | `Returns the {field}.` | +| Predicate (`is*`, `has*`, `can*`, `was*`, `should*`) | `Tells whether {condition}.` | +| Converter (`toArray`, `toString`, `asX`) | `Returns the {ClassName} as {target shape}.` | +| `apply*`, `merge*`, `add*`, and other side-effect-free operations | One-line summary in domain terms describing the operation. | + +The patterns are mandatory when applicable. They make summary lines mechanical: substitute +`{ClassName}` and `{field}` and the summary is complete. No per-method intent decision is +required. Volume is never a reason to skip the summary. Many methods just mean applying the +template many times. + +### Cross-references + +- `{@see ClassName}` for links to other types in the codebase. +- `@see Author, Title (Publisher, Year), Chapter X.` for bibliographical references. + +### Examples + +**Prohibited.** Single-line bare-tag PHPDoc, no summary: + +```php +/** @param array|null $body */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Prohibited.** PHPDoc on a constructor: + +```php +/** @param array $entries */ +public function __construct(public array $entries) +{ +} +``` + +**Prohibited.** PHPDoc on a **concrete class** inside `src/Internal/` (the prohibition does +not extend to interfaces; see "Correct" below for an Internal/ interface): + +```php +namespace TinyBlocks\Http\Internal\Client; + +final readonly class Url +{ + /** @param array|null $query */ + public static function compose(string $path, ?array $query, string $baseUrl): string + { + } +} +``` + +**Correct.** Interface declared **inside `src/Internal/`** still carries PHPDoc on every +method. The Internal/ prohibition covers concrete classes; interfaces are exempt because they +are the contract: + +```php +namespace TinyBlocks\Http\Internal\Client; + +interface RequestResolver +{ + /** + * Resolves the given URL against the configured base URL. + * + * @param string $url The path or absolute URL to resolve. + * @return string The absolute URL to dispatch. + * @throws MalformedPath If the URL violates RFC 3986. + */ + public function resolve(string $url): string; +} +``` + +**Correct.** Generic array type with summary and `@param` description: + +```php +/** + * Builds a synthesized response from a status code and an optional body. + * + * @param array|null $body The response body as an associative array. + * @return Response The synthesized response instance. + */ +public static function with(Code $code, ?array $body = null): Response +``` + +**Correct.** Interface with rich description, paragraphs, cross-references, and bibliography: + +```php +/** + * Money tied to a specific currency. + * + *

    Operations between different currencies raise CurrencyMismatch. Arithmetic + * preserves the currency.

    + * + *

    Sibling of {@see Quantity}, not a parent. Money carries currency semantics.

    + * + * @see Eric Evans, Domain-Driven Design (Addison-Wesley, 2003), Chapter 5. + */ +interface Money +{ + /** + * Adds the given amount. + * + * @param Money $other The amount to add. + * @return Money A new instance with the summed amount. + * @throws CurrencyMismatch If $other has a different currency. + */ + public function add(Money $other): Money; +} +``` + +**Correct.** Concrete class with a short summary and direct tags: + +```php +/** + * IANA timezone identifier (e.g. America/Sao_Paulo). + */ +final readonly class Timezone +{ + /** + * Creates a Timezone from a valid IANA identifier. + * + * @param string $identifier The IANA timezone identifier. + * @return Timezone The created instance. + * @throws InvalidTimezone If the identifier is not a valid IANA timezone. + */ + public static function from(string $identifier): Timezone + { + # ... + } +} +``` + +## Dependencies + +When the library needs an external dependency, prefer packages from the `tiny-blocks` ecosystem +(https://github.com/tiny-blocks) whenever a suitable option exists. Reach for outside packages +only when the ecosystem has no equivalent that fits the use case. ## Collection usage -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. +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` plus accumulation. +The same applies to `filter()`, `reduce()`, `each()`, and every other `Collectible` operation. +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:** +**Prohibited.** `array_map` plus `iterator_to_array` on a `Collectible`: ```php $names = array_map( @@ -145,10 +456,161 @@ $names = array_map( ); ``` -**Correct — fluent chain with `map()` + `toArray()`:** +**Correct.** Fluent chain with `map()` plus `toArray()`: ```php $names = $collection ->map(transformations: static fn(Element $element): string => $element->name()) ->toArray(keyPreservation: KeyPreservation::DISCARD); ``` + +## Format strings + +When building a message with placeholders, assign the format string to a `$template` variable +first. Pass it to `sprintf` on a separate statement. The format and the data are visually +separated, and the template line stays scannable. + +**Prohibited.** Format string inline with the call: + +```php +if ($value < 0 || $value > 16) { + throw new PrecisionOutOfRange( + message: sprintf('Precision must be between 0 and 16, got %d.', $value) + ); +} +``` + +**Correct.** Format string in a `$template` variable: + +```php +if ($value < 0 || $value > 16) { + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $value)); +} +``` + +## Constructor chaining + +PHP 8.4 allows chained method calls directly on a `new` expression without wrapping it in +parentheses. The parentheses are no longer required and only add visual noise. Apply this +everywhere a `new` is followed by a method call. + +**Prohibited.** Parentheses around the `new` expression: + +```php +$body = (new ServerRequest(method: 'GET', uri: 'https://api.example.com')) + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +**Correct.** No parentheses: + +```php +$body = new ServerRequest(method: 'GET', uri: 'https://api.example.com') + ->withHeader('Accept', 'application/json') + ->getBody(); +``` + +## Formatting overrides + +Three formatting rules are not covered by the canonical `phpcs.xml` (which references `PSR-12` +only). Apply them manually. + +### No vertical alignment in parameter lists + +Use a single space between the type and the variable name in parameter lists (constructors, +function signatures, closures). Never pad with extra spaces to align columns. This rule applies +only to parameter lists, not to other contexts that use `=>` alignment (see "Vertical alignment +of `=>`" below). + +**Prohibited.** Vertical alignment of types: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +**Correct.** Single space between type and variable: + +```php +public function __construct( + public OrderId $id, + public Money $total, + public Customer $customer, + public Precision $precision +) {} +``` + +### Vertical alignment of `=>` in match arms and array literals + +Multi-line `match` expressions and multi-line array literals with `=>` align the `=>` column +across all arms or entries by padding shorter left-hand sides with spaces. Single-line cases +(one-arm match, single-line array) keep the standard PSR-12 single-space form. + +**Prohibited.** Unaligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Correct.** Aligned `=>` in match: + +```php +return match ($this) { + self::MAX_AGE => sprintf($template, $this->value, $value), + default => $this->value +}; +``` + +**Prohibited.** Unaligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +**Correct.** Aligned `=>` in array literal: + +```php +return [ + 'name' => 'Gustavo', + 'role' => 'developer', + 'company' => 'Anthropic' +]; +``` + +### No trailing comma in multi-line lists + +Never place a trailing comma after the last element of any multi-line list. Applies to parameter +lists, argument lists, array literals, match arms, and every other comma-separated multi-line +structure. PHP accepts trailing commas in these positions, but this ecosystem prohibits them for +visual consistency. + +**Prohibited.** Trailing comma after the last argument: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP, +); +``` + +**Correct.** No trailing comma: + +```php +new Precision( + value: 2, + rounding: RoundingMode::HALF_UP +); +``` diff --git a/.claude/rules/php-library-commits.md b/.claude/rules/php-library-commits.md new file mode 100644 index 0000000..feefcf5 --- /dev/null +++ b/.claude/rules/php-library-commits.md @@ -0,0 +1,111 @@ +--- +description: Conventional Commits format. Applied on request when generating commit messages. +--- + +# Commits + +Applied only when generating commit messages, never automatically. All commit messages are +written in English. + +## Format + +`: ` + +The description starts with a capital letter, uses imperative present tense ("Add", "Fix", +"Change", not "Added", "Adds", or "Adding"), and ends with a period. Subject under 300 +characters. If it does not fit, split the change into multiple commits or move detail into the +body. + +Scopes are prohibited. `feat(orders): ...` is wrong. The type stands alone. + +## Allowed types + +Each entry below is a bullet that starts with a capital letter and ends with a period. This is +the canonical example of bullet punctuation enforced everywhere in this document. + +- `ci` for CI configuration changes. +- `fix` for a bug fix. +- `feat` for a user-facing feature. +- `docs` for documentation only. +- `test` for adding or correcting tests. +- `chore` for maintenance with no production code change. +- `build` for build or dependency changes. +- `revert` for reverting a previous commit. +- `refactor` for a code change that neither fixes a bug nor adds a feature. + +`style` is not used. Formatting is enforced by the linter and never appears as a standalone +commit. + +## Subject examples + +Good: + +- `fix: Handle zero-amount transactions.` +- `feat: Add order cancellation endpoint.` +- `refactor: Extract OrderStatus into its own enum.` + +Bad: + +- `Added order cancellation` is past tense, missing type, missing period. +- `feat: Adds order cancellation.` is third-person singular instead of imperative. +- `feat: added order cancellation.` starts lowercase and is past tense. +- `feat: Add cancellation, and fix billing rounding.` bundles two changes. Split. +- `feat(orders): Add cancellation.` uses a scope. Prohibited. + +## Body + +The body is **optional and rarely needed**. Single-purpose commits never have a body. Add a body +ONLY when the reason cannot be inferred from the diff (a non-obvious trade-off, a workaround for +an external bug, a decision worth recording). + +Separate the body from the subject with a blank line. Wrap at 72 characters per line. Explain +why, not what. The diff already shows what. + +## Prose vs. bullets in the body + +**Default to prose.** One or two paragraphs fits almost every commit that has a body at all. + +**Use bullets only when ALL of these are true:** + +1. The commit covers 3 or more independent changes that genuinely belong in the same commit. +2. The list cannot be expressed as continuous prose without becoming disconnected sentences. +3. Each item is independently meaningful (no sub-bullets, no continuation across bullets). + +A two-item bullet list is the wrong shape. Use prose. + +## Bullet formatting (when used) + +Every bullet starts with a capital letter and ends with a period. Imperative verb in present +tense, same as the subject line. Without exception. + +Wrong (do NOT generate): + +- `add the OrderCancelling port` lowercase, missing period. +- `Add the OrderCancelling port` capital but missing period. +- `Adds the OrderCancelling port.` third-person singular instead of imperative. + +## Body example with bullets + +``` +feat: Add order cancellation flow. + +- Add the OrderCancelling inbound port and OrderCancellingHandler. +- Add the CancelOrder command and its validator. +- Cover the cancellation path in the integration test suite. +``` + +## Body example with prose (preferred for most commits) + +``` +fix: Handle zero-amount transactions. + +The payment gateway rejects zero-amount charges with a generic 400 instead +of a documented error code, so the adapter short-circuits before the HTTP +call and raises ZeroAmountNotAllowed directly. +``` + +## Commit splitting + +Prefer one logical change per commit. Refactor commits never modify behavior. When a task +requires multiple types of change, produce multiple commits in order: `refactor` first, then +`feat` or `fix` on top. diff --git a/.claude/rules/php-library-documentation.md b/.claude/rules/php-library-documentation.md index 4791cb9..b7e0da4 100644 --- a/.claude/rules/php-library-documentation.md +++ b/.claude/rules/php-library-documentation.md @@ -1,40 +1,313 @@ --- -description: Standards for README files and all project documentation in PHP libraries. +description: Standards for README and other public-facing Markdown docs in PHP libraries. paths: - "**/*.md" --- # Documentation +Standards for `README.md` and other public-facing Markdown files in the repository. PHPDoc rules +for `.php` files live in `php-library-code-style.md`. American English applies everywhere (see +the American English section in `php-library-code-style.md`). + +The `CONTRIBUTING.md` file is centralized at +`https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md`. Each library's README and +pull request template link to that location. No local `CONTRIBUTING.md` is created per library. + +## Pre-output checklist + +Verify every item before producing any Markdown documentation. If any item fails, revise before +outputting. + +1. README title is `# ` with spaces between words (`# Building Blocks`, not + `# BuildingBlocks`). +2. License badge is the only badge. No build, coverage, Packagist, or version badges. +3. Header is followed by an anchor-linked table of contents. +4. Table of contents uses `*` for top-level (H2) entries, `+` indented by 4 spaces for + second-level (H3) entries, and `-` indented by 8 spaces for third-level (H4) entries. Every + heading from the document appears in the TOC, except FAQ entries: the FAQ is represented by + a single `* [FAQ](#faq)` line regardless of how many questions it contains. +5. Sections appear in the canonical order: Overview, Installation, How to use, FAQ (optional), + License, Contributing. +6. FAQ exists only when there are genuine points of confusion or unusual design decisions. Skip + it entirely when not needed. +7. **Self-contained code examples** are blocks that include any of: a `use` statement, a + `class`/`enum`/`interface`/`trait`/`function` declaration, or more than 3 lines of + executable code. Self-contained blocks open with `?` with zero-padded numbering + (`### 01.`, `### 02.`). +12. FAQ bibliographic citations use the format + `> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` +13. License and Contributing sections each follow the canonical one-line template. +14. Repository includes `SECURITY.md`, `.github/ISSUE_TEMPLATE/bug_report.md`, + `.github/ISSUE_TEMPLATE/feature_request.md`, and `.github/PULL_REQUEST_TEMPLATE.md`, each + matching the canonical template in "Other documentation files". + ## README -1. Include an anchor-linked table of contents. -2. Start with a concise one-line description of what the library does. -3. Include a **license** badge. Do not include any other badges. -4. Provide an **Overview** section explaining the problem the library solves and its design philosophy. -5. **Installation** section: Composer command (`composer require vendor/package`). -6. **How to use** section: complete, runnable code examples covering the primary use cases. Each example - includes a brief heading describing what it demonstrates. -7. If the library exposes multiple entry points, strategies, or container types, document each with its own - subsection and example. -8. **FAQ** section: include entries for common pitfalls, non-obvious behaviors, or design decisions that users - 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 `php-library-code-style.md` American English section for spelling - conventions. +### Structure + +The README follows a fixed section order: + +1. **Overview**. One or more paragraphs explaining the problem the library solves and its design + philosophy. Cross-references to related `tiny-blocks` libraries belong here. +2. **Installation**. Composer command in a code block, with no surrounding prose unless strictly + necessary. +3. **How to use**. Runnable examples covering the primary use cases. Each subsection demonstrates + one capability with a heading and a self-contained code block. +4. **FAQ** (optional). Numbered questions that address real points of confusion or unusual design + decisions. +5. **License**. One-line link to the `LICENSE` file. +6. **Contributing**. One-line link to the centralized `CONTRIBUTING.md` in + `tiny-blocks/tiny-blocks`. + +### Header and license badge + +The first line is `# ` followed by a blank line and the license badge: + +```markdown +# Outbox + +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks//blob/main/LICENSE) +``` + +Replace `` with the library's repository name. The badge is the only badge in the document. + +### Table of contents + +The table of contents is anchor-linked. Top-level (H2) entries use `*`. Second-level (H3) +entries use `+` indented by 4 spaces. Third-level (H4) entries use `-` indented by 8 spaces. +Every heading from the document appears, with one exception: the FAQ is represented by a single +`* [FAQ](#faq)` line. Its questions never appear as TOC sub-entries, regardless of how many +exist. + +```markdown +* [Overview](#overview) +* [Installation](#installation) +* [How to use](#how-to-use) + + [Subtopic A](#subtopic-a) + + [Subtopic B](#subtopic-b) +* [FAQ](#faq) +* [License](#license) +* [Contributing](#contributing) +``` + +Use the third level whenever the document has H4 headings, regardless of whether they form a +two-axis split. The TOC mirrors the document structure exactly. + +```markdown +* [How to use](#how-to-use) + + [Entity](#entity) + - [Single-field identity](#single-field-identity) + - [Compound identity](#compound-identity) + + [Aggregate](#aggregate) +``` + +### Code examples + +Code examples fall into two categories. + +**Self-contained examples** include at least one of: + +- A `use` statement. +- A `class`, `enum`, `interface`, `trait`, or `function` declaration. +- More than 3 lines of executable code. + +They open with `push(records: $order->recordedEvents()); +``` + +**Inline fragment examples** have all of: + +- At most 3 lines of executable code. +- No `use` statements. +- No type declarations. + +Fragments may omit the prologue. + +```php +Code::OK->value; +``` + +The criteria are mechanical: a block that meets any self-contained condition gets the prologue. A block that meets every fragment condition may omit it. There is no middle ground. + +The `#` convention for inline comments applies only to code examples inside Markdown files. PHP +files under `src/` and `tests/` have no inline comments at all, except `# TODO: ` (see +item 16 in `php-library-code-style.md`). + +### FAQ + +FAQ entries are numbered with zero-padded prefixes and end with a question mark: + +```markdown +### 01. Why is DomainEvent close to a marker interface? + +A domain event is a fact about something that happened in the domain. The contract carries only +`revision()` so the library can route schema migrations through upcasters. Everything else +(aggregate identity, sequence number, aggregate type, occurrence timestamp) is envelope metadata +that belongs to `EventRecord`. + +> Vaughn Vernon, *Implementing Domain-Driven Design* (Addison-Wesley, 2013), Chapter 8, +> "Domain Events". +``` + +Bibliographic citations follow the format +`> Author, *Title* (Publisher, Year), Chapter X, "Section Name".` The chapter and section +fragments are optional when the title is precise enough on its own. Multiple citations can be +stacked as separate blockquote lines. + +### License and Contributing + +The License section is a single line: + +```markdown +## License + + is licensed under [MIT](LICENSE). +``` + +The Contributing section is a single line pointing to the centralized guideline: + +```markdown +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. +``` ## Structured data -1. When documenting constructors, factory methods, or configuration options with more than 3 parameters, - use tables with columns: Parameter, Type, Required, Description. -2. Prefer tables to prose for any structured information. +Tables are preferred to prose for any structured information: constructor parameter lists, +builder method catalogs, default value tables, complexity tables, and configuration matrices. +Column layout is chosen per case. No fixed column set is mandated. + +## Other documentation files + +Every library repository includes the following files in addition to the README. Each follows +the canonical template below. + +### SECURITY.md + +```markdown +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks//security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. +``` + +Replace `` with the repository name. + +### .github/ISSUE_TEMPLATE/bug_report.md + +```markdown +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: +``` + +### .github/ISSUE_TEMPLATE/feature_request.md + +```markdown +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. +``` + +### .github/PULL_REQUEST_TEMPLATE.md + +```markdown +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... -## Style +## Checklist -1. Keep language concise and scannable. -2. Never include placeholder content (`TODO`, `TBD`). -3. Code examples must be syntactically correct and self-contained. -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. +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. +``` diff --git a/.claude/rules/php-library-github-workflows.md b/.claude/rules/php-library-github-workflows.md new file mode 100644 index 0000000..396c40a --- /dev/null +++ b/.claude/rules/php-library-github-workflows.md @@ -0,0 +1,287 @@ +--- +description: Structure, ordering, and pinning rules for GitHub Actions workflows in PHP libraries. +paths: + - ".github/workflows/**/*.yml" + - ".github/workflows/**/*.yaml" +--- + +# Workflows + +Conventions for GitHub Actions workflows in PHP libraries. CD does not apply. Libraries publish +to Packagist via tags and never deploy. + +`.github/workflows/ci.yml` is mandatory and follows the canonical structure defined in the +"ci.yml" section below. Additional workflow files (security scanning, automated triage, +scheduled tasks, dependency updates, etc.) may exist and follow the general rules in this file. +Their trigger, job structure, and steps are chosen by their purpose. + +The Composer scripts invoked by `ci.yml` (`composer review`, `composer tests`) are defined in +`php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing or editing any workflow YAML. If any item fails, revise +before outputting. + +### Rules for every workflow + +These rules apply to `ci.yml` and to every additional workflow in `.github/workflows/`. + +1. Keys at the workflow root follow the canonical order `name`, `on`, `concurrency`, + `permissions`, `jobs`. Keys absent in a given workflow are simply omitted. The relative order + of the remaining keys is preserved. +2. Properties inside a job follow the canonical order `name`, `needs`, `runs-on`, + `timeout-minutes`, `outputs`, `env`, `steps`. Same omission rule as above. +3. Inside any block (`env`, `outputs`, `with`, `permissions`), entries are ordered by key length + ascending. +4. The workflow `name`, every job `name`, and every step `name` are mandatory and use sentence + case (`Resolve PHP version`, not `RESOLVE_PHP_VERSION` or `resolve_php_version`). Step names + start with a verb. Job keys describe the job's purpose. Generic keys (`run`, `job`, `do`) are + discouraged in favor of descriptive identifiers (`auto-assign`, `analyze`, `notify`). +5. `concurrency` is set at the workflow root with `cancel-in-progress: true` and a `group` + expression scoped by the workflow's trigger: + - `pull_request`: `-${{ github.event.pull_request.number }}`. + - `issues`, or `issues` combined with `pull_request`: + `-${{ github.event.issue.number || github.event.pull_request.number }}`. + - `push`, `schedule`, or both: `-${{ github.ref }}`. + + `` is the workflow's short name (`ci`, `codeql`, `auto-assign`). +6. `permissions` is declared at the workflow root with the minimum scope every job needs. + Job-level `permissions` blocks are allowed only when a specific job needs a narrower scope + than the root, never broader. +7. Every job sets `timeout-minutes`. Defaults: 5 for trivial steps (single API call, lightweight + script), 15 for jobs with PHP setup or test runs, 30 for analysis-heavy jobs (CodeQL, + security scanning). Adjust based on observed runtime when prior runs exist. +8. Every action is pinned to a fixed major version tag written explicitly. Examples are + `actions/checkout@v6` and `shivammathur/setup-php@v2`. Never use `@latest`, `@main`, a branch + name, or a commit SHA. When the existing pin is an explicit minor or patch, derive the major + version while **preserving the prefix style** of the original tag: `@v2.1.0` → `@v2`, + `@2.1.0` → `@2`. The action's tag convention is reflected in the existing pin. Web lookup is + required only when the existing pin is missing, ambiguous, or pointing to a non-version + reference. Example versions cited in this file may be outdated and are not a license to skip + the lookup when it is required. +9. Inline shell logic longer than 3 lines is extracted to a script in `scripts/ci/`. +10. All text (workflow name, job names, step names, comments) uses American English with correct + spelling and punctuation. Sentences and descriptions end with a period. + +### Rules specific to ci.yml + +These rules apply only to `.github/workflows/ci.yml`. Additional workflows are not bound by them. + +1. File path is `.github/workflows/ci.yml`. The workflow `name` field is exactly `CI`. +2. Trigger is `pull_request` only. No `push`, no branch filter, no `workflow_dispatch`. +3. Jobs run in the fixed sequence `resolve-php-version`, `build`, `auto-review`, `tests`. Each + downstream job lists its upstream jobs in `needs`. +4. PHP version is never hardcoded. The `resolve-php-version` job reads `.require.php` from + `composer.json` at runtime and exposes the minor version (for example, `8.5`) as the job + output `php-version`. Downstream jobs reference + `${{ needs.resolve-php-version.outputs.php-version }}` when setting up PHP. +5. The `auto-review` job runs `composer review`. The `tests` job runs `composer tests`. Both + scripts are defined in `composer.json` per `php-library-tooling.md`. No other command is + invoked in either job. +6. The `build` job uploads `vendor/` and `composer.lock` as a single artifact named + `vendor-artifact`. The `auto-review` and `tests` jobs download that artifact instead of + running `composer install` again. +7. The `tests` job is the only job that may extend with extra setup required by the library, + such as service containers, fixture preparation, or environment variables used during + testing. The other three jobs are identical across every library in the ecosystem. +8. `concurrency.group` is `pr-${{ github.event.pull_request.number }}`. `timeout-minutes` is 5 + for `resolve-php-version` and 15 for `build`, `auto-review`, and `tests`. `permissions` is + `contents: read`. + +## ci.yml + +`ci.yml` is the mandatory workflow that gates every pull request. It contains four jobs in the +exact order below. The first three jobs are identical across every library. Only `tests` may +extend with extra setup required by the library. + +### Resolve PHP version + +Reads `.require.php` from `composer.json` and exposes the minor version (for example, `8.5`) as the +output `php-version`. A single step uses `jq` and a short regex to extract the value. Downstream jobs +consume the output to configure their PHP setup. + +### Build + +Sets up PHP using the resolved version, validates `composer.json`, installs dependencies with +`--no-progress --optimize-autoloader --prefer-dist --no-interaction`, and uploads `vendor/` and +`composer.lock` as the artifact `vendor-artifact`. + +### Auto review + +Depends on `resolve-php-version` and `build`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer review`. The `review` script in `composer.json` aggregates lint, static analysis, and style +checks for the library. + +### Tests + +Depends on `resolve-php-version` and `auto-review`. Downloads `vendor-artifact`, sets up PHP, and runs +`composer tests`. Any setup required by the library's tests (service containers, fixture preparation, +environment variables used during testing) lives in this job only. + +## Reference shape + +The YAML below is the canonical minimal form. Every library starts from this exact shape and extends +only the `tests` job when its tests require extra setup. Action versions cited here may be outdated. +Look up the current major version of every action via web search before adopting this shape verbatim. + +### Minimal workflow + +```yaml +name: CI + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: resolve-php-version + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v7 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + needs: [resolve-php-version, build] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` + +### Extending the tests job + +When the library's tests need external services, env vars, or fixture preparation, the additions live +inside the `tests` job only. The example below shows the same `tests` job extended with a MySQL service +container and the env vars consumed by the test suite. + +```yaml +tests: + name: Tests + needs: [resolve-php-version, auto-review] + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + DB_HOST: 127.0.0.1 + DB_NAME: library_test + DB_PORT: '3306' + DB_USER: library + DB_PASSWORD: library + services: + mysql: + image: mysql:8 + ports: + - 3306:3306 + env: + MYSQL_DATABASE: library_test + MYSQL_ROOT_PASSWORD: library + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} + + - name: Download vendor artifact from build + uses: actions/download-artifact@v8 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests +``` diff --git a/.claude/rules/php-library-modeling.md b/.claude/rules/php-library-modeling.md index bedb733..127413c 100644 --- a/.claude/rules/php-library-modeling.md +++ b/.claude/rules/php-library-modeling.md @@ -1,112 +1,199 @@ --- -description: Library modeling rules — folder structure, public API boundary, naming, value objects, exceptions, enums, extension points, and complexity. +description: Semantic modeling rules for PHP libraries (nomenclature, value objects, exceptions, enums, extension points, complexity). paths: - "src/**/*.php" --- -# Library modeling +# Modeling + +Library modeling rules. How to model the concepts the library exposes. Folder structure and +public API boundary live in `php-library-architecture.md`. Code style lives in +`php-library-code-style.md`. Tooling lives in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any PHP code that defines a model, an exception, or an +algorithm. If any item fails, revise before outputting. + +1. Each model has a single, clear responsibility. Apply DDD, SOLID, DRY, and KISS where they + sharpen the design, not as dogma. +2. Concept names. Every class, property, method, and exception name reflects the concept the + library represents, not a technical role. +3. No always-banned names. Never use `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity` as + class suffix, prefix, or method name. Never use `Exception` as a class suffix. Exception: + names that correspond to externally standardized identifiers (HTTP status text from RFC + documents, PSR interface names being mirrored, etc.) are permitted. The standard reference + is the meaning carrier. +4. No anemic verbs as the primary operation name (`ensure`, `validate`, `check`, `verify`, + `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, `transform`, `parse`) unless + the verb is the library's reason to exist. +5. Architectural role names (`Manager`, `Handler`, `Processor`, `Service`, and their verb forms + `process`, `handle`, `execute`) are allowed only when the class IS that role for consumers + integrating with the library. +6. Value objects are immutable. No setters. Operations return new instances. +7. Value objects compare by value, never by reference. No identity field. +8. Value objects validate invariants in the constructor and throw a dedicated exception on + invalid input. +9. Value objects with multiple creation paths use static factory methods (`from`, `of`, `zero`) + with a private constructor. +10. Every failure throws a dedicated exception class named after the invariant it guards. Never + `throw new DomainException(...)`, `throw new InvalidArgumentException(...)`, or any other + generic native exception directly. +11. Dedicated exception classes extend the appropriate native PHP exception (`DomainException`, + `InvalidArgumentException`, `OverflowException`, etc.). +12. Exceptions are pure. No transport-specific fields (HTTP status in `code`, formatted message + for end-user display). They signal invariant violations only, never control flow. +13. Enums are PHP backed enums. They include methods only when those methods carry vocabulary + meaning. +14. Extension points use `class` instead of `final readonly class`. They expose a private + constructor with static factory methods as the only creation path. Internal state is + injected via the constructor. +15. Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) + or worse needs explicit justification. +16. Prefer lazy or streaming evaluation over materializing intermediate results. Memory usage + is bounded and proportional to the output, not to the sum of intermediate stages. + +## Modeling principles + +Apply the following principles where they sharpen the design. Treat them as guides, not as dogma. + +- Single responsibility. Each model represents one concept, has one reason to change, and + exposes operations that belong to that concept. +- DDD ubiquitous language. Names, types, and operations match the vocabulary the library's + domain uses. Code and conversation share the same terms. +- SOLID. Interfaces define narrow contracts. Composition is preferred to inheritance. + Substitutability holds at every interface boundary. +- DRY. No duplicated logic across two or more places. +- KISS. No abstraction without real duplication or isolation need. -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. +## Nomenclature -## Folder structure +- Every class, property, method, and exception name reflects the concept the library represents. + A math library uses `Precision` and `RoundingMode`. A money library uses `Currency` and + `Amount`. A collection library uses `Collectible` and `Order`. +- Name classes after what they represent, not after what they do technically. Use `Money`, + `Color`, `Pipeline`, not `MoneyCalculator`, `ColorHelper`, `PipelineProcessor`. +- Name methods after the operation in the library's vocabulary. Use `add()`, `convertTo()`, + `splitAt()`, not `compute()`, `process()`, `handle()`. -``` -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) -``` +### Always banned -Never use `Models/`, `Entities/`, `ValueObjects/`, `Enums/`, or `Domain/` as folder names. +These names carry zero semantic content. Never use them anywhere as class suffix, prefix, or +method name. -## Public API boundary +- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. +- `Exception` as a class suffix (e.g., `FooException`). Use the invariant name when extending a + native exception (e.g., `PrecisionOutOfRange`, not `InvalidPrecisionException`). -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. +### Externally standardized names (exception to the banlist) -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. +Names that correspond to externally standardized identifiers are exempt from the banlist. The +standard reference is the meaning carrier. Renaming weakens it. Examples: -## Nomenclature +- HTTP status text from RFC documents (`unprocessableEntity` from RFC 4918, `noContent`). +- PSR interface names being mirrored as test doubles (`ClientException` mirroring + `Psr\Http\Client\ClientExceptionInterface`). +- Unicode category names, locale identifiers, MIME type tokens, and similar registered names. -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()`. +This exception applies only when the external standard is the actual source of the name. It +does not authorize using `Data` or `Entity` as generic suffixes when no external reference is +involved. -### Always banned +### Anemic verbs -These names carry zero semantic content. Never use them anywhere, as class suffixes, prefixes, or method names: +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()`). -- `Data`, `Info`, `Utils`, `Item`, `Record`, `Entity`. -- `Exception` as a class suffix (e.g., `FooException` — use `Foo` when it already extends a native exception). +- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, + `compute`, `transform`, `parse`. -### Anemic verbs (banned by default) +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. -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()`): +### Architectural roles -- `ensure`, `validate`, `check`, `verify`, `assert`, `mark`, `enforce`, `sanitize`, `normalize`, `compute`, - `transform`, `parse`. +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). -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). +- `Manager`, `Handler`, `Processor`, `Service`. +- Verb forms: `process`, `handle`, `execute`. -### Architectural roles (allowed with justification) +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. -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): +## Value objects -- `Manager`, `Handler`, `Processor`, `Service`, and their verb forms `process`, `handle`, `execute`. +- Are immutable. No setters. No mutation after construction. Operations return new instances. +- Compare by value, not by reference. +- Validate invariants in the constructor and throw a dedicated exception on invalid input. +- Have no identity field. +- Use static factory methods (`from`, `of`, `zero`) with a private constructor when multiple + creation paths exist. The factory name communicates the semantic intent. -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. +**Prohibited.** Public constructor with multiple creation paths. Semantics are unclear at the +call site: -## Value objects +```php +final readonly class Money +{ + public function __construct(public int $amount, public Currency $currency) {} +} + +new Money(amount: 1000, currency: Currency::BRL); +new Money(amount: 0, currency: Currency::USD); +``` + +**Correct.** Private constructor with named factory methods. Each factory name communicates +intent: + +```php +final readonly class Money +{ + private function __construct(public int $amount, public Currency $currency) {} -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. + public static function of(int $amount, Currency $currency): Money + { + return new Money(amount: $amount, currency: $currency); + } + + public static function zero(Currency $currency): Money + { + return new Money(amount: 0, currency: $currency); + } +} + +Money::of(amount: 1000, currency: Currency::BRL); +Money::zero(currency: Currency::USD); +``` ## 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: +- 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 directly. If the + invariant is worth throwing for, it is worth a named class. +- 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. +- 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. +- Exceptions signal invariant violations only, not control flow. +- Name the class after the invariant violated, never after the technical type. Use + `PrecisionOutOfRange`, not `InvalidPrecisionException`. Use `CurrencyMismatch`, not + `BadCurrencyException`. Use `ContainerWaitTimeout`, not `TimeoutException`. +- A descriptive `message` argument is allowed and encouraged when it carries debugging context + (the violating value, the boundary 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. Keep messages short, factual, and in American English. + +**Prohibited.** Throwing a native exception directly: ```php if ($value < 0) { @@ -114,50 +201,76 @@ if ($value < 0) { } ``` -**Correct** — dedicated class, no message (class name is sufficient): +**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: +**Correct.** Dedicated class with debugging context in the message: ```php if ($value < 0 || $value > 16) { - throw new PrecisionOutOfRange(sprintf('Precision must be between 0 and 16, got %d.', $value)); + $template = 'Precision must be between 0 and 16, got %d.'; + + throw new PrecisionOutOfRange(message: sprintf($template, $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/`. +- Are PHP backed enums. +- Include methods only when those methods carry vocabulary meaning. Examples are + `Order::ASCENDING_KEY` and `RoundingMode::apply()`. ## 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. +- A class designed to be extended by consumers (e.g., `Collection`, `ValueObject`) uses `class` + instead of `final readonly class`. All other classes use `final readonly class`. See + "Inheritance and constructors" in `php-library-code-style.md`. +- Extension point classes use a private constructor with static factory methods (`createFrom`, + `createFromEmpty`) as the only creation path. +- 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. +- Algorithms run in O(N) or O(N log N) unless the problem inherently requires worse. O(N²) or + worse needs explicit justification at the point of definition. +- Prefer lazy or streaming evaluation over materializing intermediate results. In pipeline-style + libraries, fuse stages so a single pass suffices over the input. +- Memory usage is bounded and proportional to the output, not to the sum of intermediate stages. +- Never re-iterate the same source. When a sequence is consumed once, use lazy creation + primitives (`createLazyFrom`) instead of materializing. + +**Prohibited.** Eager pipeline that materializes between stages: + +```php +$paidTotals = array_map( + static fn(Order $order): float => $order->total(), + array_filter( + $orders->toArray(), + static fn(Order $order): bool => $order->isPaid() + ) +); +``` + +Each stage allocates a full intermediate array. Memory grows with the input size, even when only +the final scalar matters. + +**Correct.** Fused pipeline that runs in a single pass: + +```php +$paidTotals = $orders + ->filter(predicates: static fn(Order $order): bool => $order->isPaid()) + ->map(transformations: static fn(Order $order): float => $order->total()) + ->toArray(keyPreservation: KeyPreservation::DISCARD); +``` + +Operations stack on the same iterator. No intermediate array is built. Memory stays bounded by +the final output. diff --git a/.claude/rules/php-library-testing.md b/.claude/rules/php-library-testing.md index 610b928..86a0c10 100644 --- a/.claude/rules/php-library-testing.md +++ b/.claude/rules/php-library-testing.md @@ -1,17 +1,79 @@ --- -description: BDD Given/When/Then structure, PHPUnit conventions, test organization, and fixture rules for PHP libraries. +description: BDD Given/When/Then structure, PHPUnit conventions, fixture rules, and coverage discipline. paths: - "tests/**/*.php" --- -# Testing conventions +# Testing -Framework: **PHPUnit**. Refer to `php-library-code-style.md` for the code style checklist, which also applies to -test files. +PHPUnit conventions for tests in PHP libraries. Covers BDD structure, fixture rules, and coverage +discipline. Code style applies to test files as well. See `php-library-code-style.md`. Folder +structure for `tests/` lives in `php-library-architecture.md`. Canonical thresholds (MSI 100, +covered MSI 100) live in `php-library-tooling.md`. + +## Pre-output checklist + +Verify every item before producing any test code. If any item fails, revise before outputting. + +1. Each test contains exactly one `@When` block. Two actions require two tests. +2. Use `@And` for complementary preconditions or actions within the same scenario, avoiding + consecutive `@Given` or `@When` tags. +3. Each `@Given` or `@And` block contains exactly one annotation line followed by one expression + or assignment. Never place multiple variable declarations or object constructions under a + single annotation. **Exception for data-provider tests.** When the test method binds its + inputs through a `#[DataProvider]` attribute (or the equivalent `@dataProvider` annotation), + the `@Given` block may declare the input shape in prose form, without an expression below + it. The values are bound by PHPUnit before the test body runs, so the prose annotation + replaces the assignment that would otherwise sit under the `@Given`. + + `@When` blocks follow the same one-expression rule by default: the block represents the + single action under test. **Exception for repeated-invocation tests** (idempotence, caching, + memoization). When the purpose of the test is asserting that the same operation produces the + same outcome across N invocations, the `@When` block may contain N consecutive identical + invocations, each captured in a numbered variable (`$first`, `$second`, ...), and the + annotation reads `@When invoked twice` (or thrice, etc.) to make the composite-action + semantic explicit. Two unrelated actions still require two tests. +4. No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere (e.g., `Money::of(...)->add(...)` instead of + `$money = Money::of(...)` followed by `$money->add(...)`). +5. No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class. +6. Test only the public API. Never assert on private state or `Internal/` classes directly. +7. Test the behavior that **raises** an exception, never the exception itself. Exception classes + represent invariant violations and are value objects, not the subject of behavior tests. A + test constructs the conditions, invokes the public method that is supposed to fail, and + asserts the expected exception class is raised (plus its accessor values when they carry + information relevant to the failure). Constructing an exception directly + (`new HttpRequestInvalid(...)`) and asserting on its accessors is **prohibited**: the + exception's structure is exercised through the call path that produces it. If a method does + not exist whose call path produces the exception, the exception is dead code and should be + removed. +8. Never mock internal collaborators. Use real objects. Test doubles are used only at system + boundaries (filesystem, clock, network) when the library interacts with external resources. +9. Name tests after behavior, not method names. +10. Use domain-specific names in variables and properties. Never `$spy`, `$mock`, `$stub`, + `$fake`, `$dummy` as variable or property names. Use the domain concept the object + represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like + `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what matters. +11. Annotations use domain language. Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. +12. Never use the `/** @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`, + `expectException`, etc.). Pass arguments positionally. +14. Never include conditional logic inside tests. Each `@Then` block expresses one logical + concept. The only allowed `try`/`catch` is when the assertion target is a property of the + caught exception that cannot be expressed via `expectException*` methods (notably + `getPrevious()` for chain inspection). The catch block contains only assertions against the + caught exception, no branching. +15. Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from + coverage. Never suppress mutants via `infection.json.dist` or any other mechanism. See + "Coverage and mutation discipline". ## Structure: Given/When/Then (BDD) -Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments without exception. +Every test uses `/** @Given */`, `/** @And */`, `/** @When */`, `/** @Then */` doc comments +without exception. ### Happy path example @@ -20,26 +82,30 @@ public function testAddMoneyWhenSameCurrencyThenAmountsAreSummed(): void { /** @Given two money instances in the same currency */ $ten = Money::of(amount: 1000, currency: Currency::BRL); + + /** @And another money instance with the same currency */ $five = Money::of(amount: 500, currency: Currency::BRL); /** @When adding them together */ $total = $ten->add(other: $five); /** @Then the result contains the sum of both amounts */ - self::assertEquals(expected: 1500, actual: $total->amount()); + self::assertEquals(1500, $total->amount()); } ``` ### Exception example -When testing that an exception is thrown, place `@Then` (expectException) **before** `@When`. PHPUnit requires this -ordering. +When testing that an exception is thrown, place `@Then` (`expectException`) before `@When`. +PHPUnit requires this ordering. ```php public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void { /** @Given two money instances in different currencies */ $brl = Money::of(amount: 1000, currency: Currency::BRL); + + /** @And another money instance with a different currency */ $usd = Money::of(amount: 500, currency: Currency::USD); /** @Then an exception indicating currency mismatch should be thrown */ @@ -50,67 +116,210 @@ public function testAddMoneyWhenDifferentCurrenciesThenCurrencyMismatch(): void } ``` -Use `@And` for complementary preconditions or actions within the same scenario, avoiding consecutive `@Given` or -`@When` tags. - -## Rules - -1. Include exactly one `@When` per test. Two actions require two tests. -2. Test only the public API. Never assert on private state or `Internal/` classes directly. -3. Never mock internal collaborators. Use real objects. Use test doubles only at system boundaries (filesystem, - clock, network) when the library interacts with external resources. -4. Name tests to describe behavior, not method names. -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. Use domain-specific model classes in `tests/Models/` for test fixtures that represent domain concepts - (e.g., `Amount`, `Invoice`, `Order`). -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. -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. +Use `@And` for complementary preconditions or actions within the same scenario, avoiding +consecutive `@Given` or `@When` tags. + +## Testing exceptions + +Exception classes are value objects describing an invariant violation. They are not the subject +of behavior tests. A test verifies that a public method, under specific conditions, raises a +specific exception. Constructing the exception directly and asserting on its accessors is +prohibited. The exception's structure is exercised through the call path that produces it. + +**Prohibited.** Testing the exception as a value object: + +```php +public function testFromWhenAllFieldsGivenThenExposesEveryAccessor(): void +{ + /** @Given a URL */ + $url = 'https://api.example.com'; + + /** @And an HTTP method */ + $method = Method::GET; + + /** @And a reason */ + $reason = 'Connection refused.'; + + /** @When the exception is constructed */ + $exception = HttpNetworkFailed::from(url: $url, method: $method, reason: $reason); + + /** @Then it exposes the URL */ + self::assertSame($url, $exception->url()); +} +``` + +The test constructs the exception in isolation and asserts on its accessors. No production code +is exercised. The same coverage is achieved (and made meaningful) by the test below, which +drives the path that raises the exception. + +**Correct.** Testing the behavior that raises the exception: + +```php +public function testSendRequestWhenTransportCannotReachServerThenThrowsHttpNetworkFailed(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + /** @Then a network failure exception describing the unreachable target is raised */ + $this->expectException(HttpNetworkFailed::class); + + /** @When the request is sent */ + $http->send(request: $request); +} +``` + +When the accessor values on the raised exception are part of the assertion, `expectException` +alone is not enough (it asserts only the class). Use a `try`/`catch` block as permitted by +rule 14. The catch block contains only assertions against the caught exception, no branching. + +```php +public function testSendRequestWhenTargetUnreachableThenExceptionCarriesUrlAndMethod(): void +{ + /** @Given an HTTP client backed by a transport that always raises a network error */ + $http = Http::usingTransport(transport: new ThrowingClient()); + + /** @And a target request to that transport */ + $request = Request::create(url: 'https://api.example.com', method: Method::GET); + + try { + /** @When the request is sent */ + $http->send(request: $request); + } catch (HttpNetworkFailed $failure) { + /** @Then the exception exposes the target URL and method */ + self::assertSame('https://api.example.com', $failure->url()); + self::assertSame(Method::GET, $failure->method()); + } +} +``` + +If a method does not exist whose call path produces the exception, the exception itself is dead +code. Remove it instead of writing a behavior test against a constructor. + +**The `try`/`catch` form is reserved for assertions that PHPUnit's `expectException*` family +does not cover.** Message, code, and class are covered by PHPUnit (`expectException`, +`expectExceptionMessage`, `expectExceptionMessageMatches`, `expectExceptionCode`): use those +methods, not `try`/`catch`. The only case that warrants `try`/`catch` is inspecting accessors +that PHPUnit cannot reach — notably `getPrevious()` for chain inspection, or domain-specific +accessors on a `TransportFailure` (`url()`, `method()`, `reason()`). + +**Prohibited.** `try`/`catch` to assert message: + +```php +try { + $http->send(request: $request); + self::fail('NoMoreResponses was expected.'); +} catch (NoMoreResponses $exception) { + self::assertStringContainsString('queue exhausted', $exception->getMessage()); +} +``` + +**Correct.** PHPUnit's `expectExceptionMessage`: + +```php +$this->expectException(NoMoreResponses::class); +$this->expectExceptionMessage('queue exhausted'); + +$http->send(request: $request); +``` ## Test setup and fixtures -1. **One annotation = one statement.** Each `@Given` or `@And` block contains exactly one annotation line - followed by one expression or assignment. Never place multiple variable declarations or object - constructions under a single annotation. -2. **No intermediate variables used only once.** If a value is consumed in a single place, inline it at the - call site. Chain method calls when the intermediate state is not referenced elsewhere - (e.g., `Money::of(...)->add(...)` instead of `$money = Money::of(...); $money->add(...);`). -3. **No private or helper methods in test classes.** The only non-test methods allowed are data providers. - If setup logic is complex enough to extract, it belongs in a dedicated fixture class, not in a - private method on the test class. -4. **Domain terms in variables and annotations.** Never use technical testing jargon (`$spy`, `$mock`, - `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object - represents: `$collection`, `$amount`, `$currency`, `$sortedElements`. Class names like - `ClientMock` or `GatewaySpy` are acceptable — the variable holding the instance is what matters. -5. **Annotations use domain language.** Write `/** @Given a collection of amounts */`, not - `/** @Given a mocked collection in test state */`. The annotation describes the domain - scenario, not the technical setup. - -## Test organization +- Each `@Given` or `@And` block contains exactly one annotation followed by one expression or + assignment. Never place multiple declarations under a single annotation. The exception for + data-provider tests applies here as well (see rule 3). +- No intermediate variables used only once. Chain method calls when the intermediate state is + not referenced elsewhere. +- No private or helper methods in test classes. The only non-test methods allowed are data + providers. Setup logic complex enough to extract belongs in a dedicated fixture class, not in + a private method on the test class. +- Domain terms in variables and properties. Never use technical testing jargon (`$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`) as variable or property names. Use the domain concept the object + represents (`$collection`, `$amount`, `$currency`, `$sortedElements`). Class names like + `ClientMock` or `GatewaySpy` are acceptable. The variable holding the instance is what + matters. +- Annotations use domain language. Write `/** @Given a collection of amounts */`, not + `/** @Given a mocked collection in test state */`. The annotation describes the domain + scenario, not the technical setup. + +**Prohibited.** Multiple declarations under a single annotation: + +```php +/** @And two money instances in different currencies */ +$usd = Money::of(amount: 500, currency: Currency::USD); +$eur = Money::of(amount: 300, currency: Currency::EUR); +``` + +**Correct.** One annotation per declaration: +```php +/** @And a money instance in USD */ +$usd = Money::of(amount: 500, currency: Currency::USD); + +/** @And a money instance in EUR */ +$eur = Money::of(amount: 300, currency: Currency::EUR); ``` -tests/ -├── Models/ # Domain-specific fixtures reused across tests -├── Mocks/ # Test doubles for system boundaries -├── Unit/ # Unit tests for public API -│ └── Mocks/ # Alternative location for test doubles -├── Integration/ # Tests requiring real external resources (Docker, filesystem) -└── bootstrap.php # Test bootstrap when needed + +**Also prohibited.** Setup multi-statement grouped under a single annotation because "the +statements build one coherent concept": + +```php +/** @Given transport seeded with two responses */ +$first = Response::with(code: Code::OK); +$second = Response::with(code: Code::CREATED); +$transport = InMemoryTransport::with(responses: [$first, $second]); +``` + +Three statements, one annotation. The fact that the three lines together build a single +setup concept is **not** a license to share one annotation. Each declaration takes its own +`@And` block. The same applies under `@When` when the test prepares the input alongside the +action: the input preparation goes back to `@And` under `@Given`, and `@When` contains only +the action under test. + +**Correct.** Each statement keeps its own annotation: + +```php +/** @Given a first queued response */ +$first = Response::with(code: Code::OK); + +/** @And a second queued response */ +$second = Response::with(code: Code::CREATED); + +/** @And transport with both responses */ +$transport = InMemoryTransport::with(responses: [$first, $second]); ``` -`tests/Integration/` is only present when the library interacts with infrastructure. +## Test doubles + +Conventions for naming and locating test doubles (mocks, spies, stubs, fakes, dummies). + +### Naming + +- Variables and properties never carry the technical role in their name. Never `$spy`, `$mock`, + `$stub`, `$fake`, `$dummy`. Use the domain concept the object represents (`$gateway`, + `$clock`, `$repository`, `$client`). +- Class names may carry the technical role as suffix when the class IS a test double + (`ClientMock`, `GatewaySpy`, `ClockFake`). The suffix signals that the file is a collaborator + built for tests, not a production type. + +### Location + +- Test doubles live at the root of `tests/Unit/`. When integration tests exist, doubles used + there live at the root of `tests/Integration/`. +- No dedicated `Mocks/` or `Doubles/` subdirectory exists. +- Domain fixtures that represent real domain concepts live in `tests/Models/`. See + `php-library-architecture.md` for the canonical `tests/` folder layout. + +## Coverage and mutation discipline -## Coverage and mutation testing +- Never use `@codeCoverageIgnore`, attributes, or configuration that exclude code from coverage. +- Never suppress mutants via `infection.json.dist` or any other mechanism. +- If a line or mutation cannot be covered or killed, the design is wrong. Refactor the + production code to make it testable. Never work around the tool. -1. Line and branch coverage must be **100%**. No annotations (`@codeCoverageIgnore`), attributes, or configuration - that exclude code from coverage are allowed. -2. All mutations reported by Infection must be **killed**. Never ignore or suppress mutants via `infection.json.dist` - or any other mechanism. -3. If a line or mutation cannot be covered or killed, it signals a design problem in the production code. Refactor - the code to make it testable, do not work around the tool. +Canonical thresholds (MSI 100, covered MSI 100) live in `php-library-tooling.md`. They are +enforced by `infection.json.dist`. Achieving MSI 100 implies effective full coverage of `src/` +because every mutation must be killed by an assertion. This file covers only the behavioral +rules that complement those thresholds. diff --git a/.claude/rules/php-library-tooling.md b/.claude/rules/php-library-tooling.md new file mode 100644 index 0000000..3b55111 --- /dev/null +++ b/.claude/rules/php-library-tooling.md @@ -0,0 +1,464 @@ +--- +description: Canonical config files for PHP libraries in the tiny-blocks ecosystem. +paths: + - "composer.json" + - "phpcs.xml" + - "phpstan.neon.dist" + - "phpunit.xml" + - "infection.json.dist" + - ".editorconfig" + - ".gitattributes" + - ".gitignore" + - "Makefile" +--- + +# Tooling + +Canonical configuration files for a PHP library in the tiny-blocks ecosystem. Each file has a +fixed shape. Deviations require justification. Folder structure lives in +`php-library-architecture.md`. Code style lives in `php-library-code-style.md`. + +## Pre-output checklist + +Verify every item before creating, editing, or relocating any of the files below. If any item +fails, revise before outputting. + +1. The library repository contains all the following files at its root: `composer.json`, + `phpcs.xml`, `phpstan.neon.dist`, `phpunit.xml`, `infection.json.dist`, `.editorconfig`, + `.gitattributes`, `.gitignore`, `Makefile`. +2. `composer.json` exposes exactly five scripts: `configure`, `configure-and-update`, `review`, + `test-file`, `tests`. No other public scripts are defined. +3. `composer.json` fixed fields use the canonical values defined in the "composer.json" section + (`license`, `type`, `minimum-stability`, `prefer-stable`, `authors`, `config`, `require.php`). +4. `composer.json` `description` is a single short sentence describing what the library does. + Multi-sentence or multi-paragraph descriptions belong in the README Overview, not in Composer + metadata. +5. `composer.json` includes a `keywords` array. The first keyword is always `"tiny-blocks"`. + Additional keywords are topic tokens derived from the library's purpose (`psr-7`, + `http-client`, `event-sourcing`, etc.). +6. `phpcs.xml` references only the `PSR12` ruleset. No additional sniffs are added. +7. `phpunit.xml` sets all five `failOn*` flags to `true`: `failOnDeprecation`, `failOnNotice`, + `failOnPhpunitDeprecation`, `failOnRisky`, `failOnWarning`. +8. `phpunit.xml` sets `executionOrder="random"` and `beStrictAboutOutputDuringTests="true"`. +9. `infection.json.dist` sets `minMsi: 100` and `minCoveredMsi: 100`. Lowering either value is + prohibited. +10. `.editorconfig` sets `max_line_length = 120`, `indent_size = 4`, `indent_style = space`, and + `end_of_line = lf` for PHP files. YAML uses `indent_size = 2`. Makefile uses `indent_style = tab`. +11. `.gitattributes` sets `* text=auto eol=lf` and lists every dev-only file under `export-ignore`. + The Packagist tarball contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + `.claude/` is listed under `export-ignore` (versioned on GitHub for contributor parity, + excluded from the published package). +12. `.gitignore` follows the canonical content in the ".gitignore" section. `.claude/` is **not** + listed (it is versioned on GitHub). +13. `Makefile` wraps every PHP and Composer command in a Docker container using the canonical + image `gustavofreze/php:8.5-alpine`. No PHP command runs on the host directly. +14. All test artifact paths use `reports/` (plural). The directory is consistent across + `composer tests`, `infection.json.dist`, `phpunit.xml`, and `Makefile`. +15. The `reports/` directory is listed under `export-ignore` in `.gitattributes`. + +## composer.json + +Fixed fields, identical in every library: `license`, `type`, `minimum-stability`, `prefer-stable`, +`require.php`, `authors`, `config.allow-plugins`, `config.sort-packages`, `scripts`, and the five +universal dev dependencies (`ergebnis/composer-normalize`, `infection/infection`, `phpstan/phpstan`, +`phpunit/phpunit`, `squizlabs/php_codesniffer`). + +Per-library fields, vary by library: `name`, `description`, `keywords`, `homepage`, `support`, +`autoload`, `autoload-dev`. The `require-dev` section may add libraries needed by tests (for +example, HTTP client implementations in a PSR-7 library) on top of the five universal tools. + +```json +{ + "name": "tiny-blocks/", + "description": "", + "license": "MIT", + "type": "library", + "keywords": [ + "tiny-blocks", + "", + "" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "homepage": "https://github.com/tiny-blocks/", + "support": { + "issues": "https://github.com/tiny-blocks//issues", + "source": "https://github.com/tiny-blocks/" + }, + "require": { + "php": "^8.5" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51", + "infection/infection": "^0.32", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^13.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "autoload": { + "psr-4": { + "TinyBlocks\\\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "sort-packages": true + }, + "scripts": { + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], + "review": [ + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" + ], + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "tests": [ + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + ] + } +} +``` + +Script usage: + +- `composer configure` runs `composer install --optimize-autoloader` followed by `composer normalize`. + Use this after cloning the repository or pulling new changes. +- `composer configure-and-update` runs `composer update --optimize-autoloader` followed by + `composer normalize`. Use this when intentionally updating dependencies. +- `composer review` runs `phpcs` and `phpstan` in sequence. Used by CI and local validation. +- `composer tests` runs `phpunit` followed by `infection`. Used by CI. +- `composer test-file ` runs a filtered subset of tests without coverage. Local + development only. + +## phpcs.xml + +References only the `PSR12` ruleset. Additional formatting rules (vertical alignment, trailing +comma, etc.) live in `php-library-code-style.md` under "Formatting overrides". + +```xml + + + Code style for the tiny-blocks library. + + src + tests + +``` + +## phpstan.neon.dist + +Static analysis configuration. Runs at the highest level on both `src/` and `tests/`. Invoked +by the `review` Composer script. + +```neon +parameters: + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true +``` + +`ignoreErrors` is permitted to suppress legitimate false positives produced by `level: max` +(third-party type signatures with `mixed`, PHP-FIG interfaces returning untyped arrays, trait +unused-method warnings on shared behavior, etc.). Each entry follows these rules: + +- A short comment above the entry justifies its existence. +- Prefer scoping via `identifier:` plus `path:` over raw `#...#` message patterns. +- `reportUnmatchedIgnoredErrors: true` is mandatory. Obsolete entries fail the build, forcing + cleanup. + +Example with `ignoreErrors`: + +```neon +parameters: + level: max + paths: + - src + - tests + ignoreErrors: + # Trait method intentionally unused by the consuming aggregate; reflection wires it. + - identifier: trait.unused + path: src/Internal/EventualAggregateRootBehavior.php + + # json_encode signature carries `mixed` for backward compatibility at level max. + - identifier: argument.type + path: src/Internal/Serialization/JsonEncoder.php + reportUnmatchedIgnoredErrors: true +``` + +## phpunit.xml + +Strict configuration. All `failOn*` flags are `true`. `executionOrder="random"` forces tests to be +independent of one another. Coverage and JUnit reports go under `reports/`. + +```xml + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + +``` + +Root attributes are sorted alphabetically. + +## infection.json.dist + +Mutation testing configuration. `minMsi` and `minCoveredMsi` are both `100`. Mutants that escape +make the build fail. + +```json +{ + "logs": { + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" + }, + "tmpDir": "reports/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} +``` + +## .editorconfig + +Whitespace and line ending rules applied by editor integrations. + +```ini +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false +``` + +## .gitattributes + +Normalizes line endings to LF and excludes every dev-only file from the Packagist tarball. The +published package contains only `src/`, `composer.json`, `README.md`, and `LICENSE`. + +``` +* text=auto eol=lf + +*.php text diff=php + +# 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 +/reports export-ignore +/.phpunit.cache export-ignore +``` + +## .gitignore + +Keeps the repository working tree clean of artifacts that should never be committed. Entries +are grouped from most fundamental (PHP dependencies) to least critical (OS files). The +`.claude/` directory is **not** listed here. It is versioned on GitHub so other contributors +share the same rules, and it is excluded from the published Packagist tarball through +`export-ignore` in `.gitattributes` (see above). + +``` +# PHP dependencies +/vendor/ +composer.lock + +# Tooling cache +.phpcs-cache +.phpunit.cache/ +.php-cs-fixer.cache +.phpunit.result.cache + +# Coverage and reports +build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ + +# OS +Thumbs.db +.DS_Store +Desktop.ini +``` + +## Makefile + +Thin wrapper over Composer scripts. Every PHP and Composer command runs inside a Docker container +using the canonical image `gustavofreze/php:8.5-alpine`. Targets that match a Composer script +delegate to it directly, avoiding duplication. + +```makefile +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +ifeq ($(ARCH),arm64) + PLATFORM := --platform=linux/amd64 +endif + +DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.PHONY: tests +tests: ## Run unit and mutation tests with coverage + @${DOCKER_RUN} composer tests + +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) + @${DOCKER_RUN} composer test-file ${FILE} + +.PHONY: review +review: ## Run lint and static analysis + @${DOCKER_RUN} composer review + +.PHONY: show-reports +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/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} + @rm -rf reports vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @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)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' +``` diff --git a/.editorconfig b/.editorconfig index 73e3c9a..be5640e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space +max_line_length = 120 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index 744a43b..eedb473 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore /.claude export-ignore @@ -20,3 +20,5 @@ /Makefile export-ignore /CONTRIBUTING.md export-ignore /CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ddd1db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b344d9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7a2c836 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... + +## Checklist + +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 77c2bb8..e34c801 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,10 +2,11 @@ ## Context -PHP library (tiny-blocks). Immutable domain models, zero infrastructure dependencies in core. +PHP library in the tiny-blocks ecosystem. ## Mandatory pre-task step -Before starting any task, read and strictly follow all instruction files located in `.claude/CLAUDE.md` and -`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every rule strictly. Do not -deviate from the patterns, folder structure, or naming conventions defined in them. +Before starting any task, read and strictly follow `.claude/CLAUDE.md` and every rule file in +`.claude/rules/`. These files are the absolute source of truth for code generation. Apply every +rule strictly. Do not deviate from the patterns, folder structure, or naming conventions defined +in them. diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index d0ba49e..8c9683c 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -1,4 +1,4 @@ -name: Auto assign issues and pull requests +name: Auto assign on: issues: @@ -8,12 +8,19 @@ on: types: - opened +concurrency: + group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + jobs: - run: + auto-assign: + name: Auto assign issues and pull requests runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write + timeout-minutes: 5 steps: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.1.0 @@ -22,4 +29,4 @@ jobs: github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' - assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file + assignment_options: 'ISSUE,PULL_REQUEST' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74fb16..d395d35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ permissions: contents: read jobs: - load-config: - name: Load config + resolve-php-version: + name: Resolve PHP version runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -29,7 +29,7 @@ jobs: build: name: Build - needs: load-config + needs: resolve-php-version runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -40,7 +40,7 @@ jobs: uses: shivammathur/setup-php@v2 with: tools: composer:2 - php-version: ${{ needs.load-config.outputs.php-version }} + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -58,7 +58,7 @@ jobs: auto-review: name: Auto review - needs: [load-config, build] + needs: [resolve-php-version, build] runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -69,7 +69,7 @@ jobs: uses: shivammathur/setup-php@v2 with: tools: composer:2 - php-version: ${{ needs.load-config.outputs.php-version }} + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -82,7 +82,7 @@ jobs: tests: name: Tests - needs: [load-config, auto-review] + needs: [resolve-php-version, auto-review] runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -93,7 +93,7 @@ jobs: uses: shivammathur/setup-php@v2 with: tools: composer:2 - php-version: ${{ needs.load-config.outputs.php-version }} + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4c6d7f7..0634bbf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "0 0 * * *" +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + permissions: actions: read contents: read @@ -17,11 +21,11 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: language: [ "actions" ] - steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index bd5baa3..6107765 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,25 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Tooling cache +.phpcs-cache .phpunit.cache/ +.php-cs-fixer.cache .phpunit.result.cache -report/ -coverage/ + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index 07acc3b..4f0e85d 100644 --- a/Makefile +++ b/Makefile @@ -16,28 +16,27 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader - @${DOCKER_RUN} composer normalize + @${DOCKER_RUN} composer configure -.PHONY: test -test: ## Run all tests with coverage +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update + +.PHONY: tests +tests: ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-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 -test-no-coverage: ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage - .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -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 +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html .PHONY: show-outdated show-outdated: ## Show outdated direct dependencies @@ -46,18 +45,18 @@ show-outdated: ## Show outdated direct dependencies .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" diff --git a/README.md b/README.md index 6b7dcf8..3bee14d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,29 @@ * [Installation](#installation) * [How to use](#how-to-use) + [Entity](#entity) + - [Single-field identity](#single-field-identity) + - [Compound identity](#compound-identity) + - [Identity access on entities](#identity-access-on-entities) + [Aggregate](#aggregate) + [Domain events with transactional outbox](#domain-events-with-transactional-outbox) + - [Declaring events](#declaring-events) + - [Emitting events from the aggregate](#emitting-events-from-the-aggregate) + - [Draining events](#draining-events) + - [Restoring aggregate version on reload](#restoring-aggregate-version-on-reload) + - [Constructing event records directly](#constructing-event-records-directly) + [Event sourcing](#event-sourcing) + - [Applying events to state](#applying-events-to-state) + - [Creating a blank aggregate](#creating-a-blank-aggregate) + - [Replaying an event stream](#replaying-an-event-stream) + [Snapshots](#snapshots) + - [Capturing aggregate state](#capturing-aggregate-state) + - [Taking a snapshot](#taking-a-snapshot) + - [Persisting snapshots](#persisting-snapshots) + - [Built-in conditions](#built-in-conditions) + [Upcasting](#upcasting) + - [Defining an upcaster](#defining-an-upcaster) + - [Chaining upcasters](#chaining-upcasters) + - [Default values for new fields](#default-values-for-new-fields) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -21,6 +39,13 @@ The `Building Blocks` library provides the tactical design building blocks of Do `Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox or an event-sourced store. +This library implements the tactical patterns from Evans (Entity, Identity, Aggregate Root, Value Object) and Vernon +(Domain Event) together with pragmatic extensions that production code needs but the original DDD literature does +not address: aggregate versioning for optimistic offline locking (Fowler PEAA), model versioning and rolling +snapshots for event-sourced aggregates (Greg Young), event upcasting for schema evolution (Greg Young), and an +event envelope decoupling domain events from infrastructure metadata (Hohpe/Woolf EIP). Every extension is annotated +in its own PHPDoc with its source. + It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives (`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers. @@ -53,6 +78,10 @@ differently named property override `identityProperty()`. * `SingleIdentity`: identity backed by a single scalar value (UUID, auto-increment integer, slug). ```php + identityValue(); ``` @@ -98,6 +132,10 @@ differently named property override `identityProperty()`. entity that declares its identity property. ```php + sequenceNumber(); + $user->aggregateVersion(); + ``` + + ```php + isAfter(other: $previous); # true + $previous->isBefore(other: $current); # true ``` * `modelVersion()`: typed as `ModelVersion`. Defaults to `ModelVersion::initial()` (value `0`). Override on - aggregates that have a versioned schema. + aggregates that have a versioned schema. `ModelVersion::isAfter()` and `ModelVersion::isBefore()` compare two + schema versions during migration logic. ```php + modelVersion(); ``` -* `aggregateName()`: short class name, used as the aggregate type identifier on each `EventRecord`. + ```php + isAfter(other: $previous); # true + $previous->isBefore(other: $current); # true + ``` + +* `aggregateType()`: short class name, used as the aggregate type identifier on each `EventRecord`. ```php - $user->aggregateName(); + $user->aggregateType(); ``` ### Domain events with transactional outbox @@ -182,6 +265,10 @@ fails by design with a duplicate-event error from the outbox. defaulted to `Revision::initial()` by `DomainEventBehavior`. Override only when bumping the event schema. ```php + recordedEvents() as $record) { @@ -264,22 +373,107 @@ fails by design with a duplicate-event error from the outbox. } ``` +#### Restoring aggregate version on reload + +* `reconstitute()`: static factory that state-based repositories invoke when rehydrating an + `EventualAggregateRoot` from persistence. The default implementation provided by + `EventualAggregateRootBehavior` instantiates the aggregate without invoking its constructor, assigns the + identity to the property declared by `identityProperty()`, hydrates the remaining state by reflection + from the `$state` map (entries with keys absent from the aggregate are silently ignored), and assigns + the aggregate version so subsequent events advance from the correct value. The buffer of recorded events + starts empty, the use-once contract still holds for any new operation. + + ```php + 'pending'] + ); + ``` + + Aggregates may override the factory to enforce a concrete identity type at the entry point. The static + signature cannot narrow the parameter type per LSP, so the override keeps `Identity` in the signature + and guards with `instanceof` inside: + + ```php + , got <%s>.'; + + throw new InvalidArgumentException(message: sprintf($template, OrderId::class, $orderId::class)); + } + + $order = new Order(id: $orderId); + $order->aggregateVersion = $aggregateVersion; + + return $order; + } + } + ``` + #### Constructing event records directly +Every envelope carries `$id`, `$event`, `$revision`, `$eventType`, `$occurredAt`, `$aggregateId`, +`$aggregateType`, and `$aggregateVersion`. The aggregate normally builds the record, so consumers +read these fields off `EventRecord` directly without instantiating one. + * `EventRecord::of()`: factory for the rare cases that require building an envelope outside the aggregate boundary, typically test code that fabricates envelopes as inputs to handlers, or consumer-side code deserializing payloads - from a wire format. The `id`, `occurredOn`, and `snapshotData` parameters fall back to sensible defaults - (`Uuid::uuid4()`, `Instant::now()`, an empty payload) when omitted. + from a wire format. The `id` and `occurredAt` parameters fall back to sensible defaults (`Uuid::uuid4()` and + `Instant::now()`) when omitted. + + | Parameter | Type | Required | Description | + |--------------------|--------------------|----------|--------------------------------------------------------------------| + | `id` | `?UuidInterface` | No | Explicit envelope identifier; defaults to a fresh `Uuid::uuid4()`. | + | `event` | `DomainEvent` | Yes | The event being recorded. | + | `occurredAt` | `?Instant` | No | Explicit occurrence timestamp; defaults to `Instant::now()`. | + | `aggregateId` | `Identity` | Yes | The aggregate identity that produced the event. | + | `aggregateType` | `string` | Yes | The short class name of the aggregate. | + | `aggregateVersion` | `AggregateVersion` | Yes | The aggregate version assigned to this envelope. | ```php + ` method by reflection. ```php + 'placed']); - $data->toArray(); - ``` + declare(strict_types=1); -* Aggregates control what fields enter the snapshot by overriding `getSnapshotState()`. The default captures every - declared property except `recordedEvents` and `sequenceNumber` (which are tracked separately on the envelope). + use Psr\Log\LoggerInterface; + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; + use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRootBehavior; - ```php final class CartWithLogger implements EventSourcingRoot { use EventSourcingRootBehavior; @@ -402,7 +610,7 @@ Snapshots let the event store skip replay of early events when reconstituting a private array $productIds = []; private LoggerInterface $logger; - protected function getSnapshotState(): array + public function snapshotState(): array { return ['id' => $this->id, 'productIds' => $this->productIds]; } @@ -411,14 +619,18 @@ Snapshots let the event store skip replay of early events when reconstituting a #### Taking a snapshot -* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `getSnapshotState()` hook. +* `Snapshot::fromAggregate()`: captures the aggregate's current state via the `snapshotState()` hook. ```php + aggregateState(); - $snapshot->sequenceNumber(); + $snapshot->aggregateVersion(); ``` #### Persisting snapshots @@ -427,6 +639,10 @@ Snapshots let the event store skip replay of early events when reconstituting a storage to a `persist` hook implemented by the consumer. ```php + Alistair Cockburn, *Hexagonal Architecture* (alistair.cockburn.us, 2005). -### 07. What is the difference between `ModelVersion` and `SequenceNumber`? +### 07. What is the difference between `ModelVersion` and `AggregateVersion`? -`SequenceNumber` counts events per aggregate instance. It is the basis for optimistic concurrency control: a save -fails if the sequence number in storage differs from the in-memory sequence the aggregate believed it had. +`AggregateVersion` counts events per aggregate instance. It is the basis for optimistic concurrency control: a +save fails if the aggregate version in storage differs from the in-memory version the aggregate believed it had. `ModelVersion` versions the aggregate type itself. When the aggregate schema changes in a backwards-incompatible way (a property is removed, renamed, or its semantics shift), bumping the model version gives migration code a @@ -606,7 +845,7 @@ The two are different concepts that happen to share an integer representation. T objects to prevent accidental comparisons across them at compile time. > Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), "Optimistic Offline -> Lock", source of `SequenceNumber` semantics. +> Lock", source of `AggregateVersion` semantics. > Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017), source of `ModelVersion` semantics. ### 08. Why is the `EventualAggregateRoot` use-once? @@ -635,9 +874,45 @@ No. These three concerns live elsewhere: A `DomainEvent` that grows methods like these duplicates envelope data already on the `EventRecord` and pulls infrastructure into the domain layer. +### 10. Why does the library include `AggregateVersion` and `ModelVersion` if Evans never mentioned them? + +Evans defined the tactical patterns of DDD, but optimistic concurrency control and aggregate schema evolution +are concerns that emerged later in mainstream production code. `AggregateVersion` carries the optimistic offline +lock formalized by Fowler in PEAA: the value travels with the aggregate, the persistence adapter compares the +in-memory value against the stored one, and a mismatch raises a concurrency exception instead of overwriting +another process's change. `ModelVersion` carries Greg Young's schema versioning for aggregate types, so migration +code has a single source of truth to branch on when older shapes show up in storage. + +> Martin Fowler, *Patterns of Enterprise Application Architecture* (Addison-Wesley, 2002), "Optimistic Offline +> Lock". +> Greg Young, *Versioning in an Event Sourced System* (Leanpub, 2017). + +### 11. Why is `reconstitute()` static on the interface even though PHP's polymorphism for static methods is limited? + +The interface declaration documents the contract: every `EventualAggregateRoot` exposes a static factory with the +shape `(Identity, AggregateVersion, array): static` that repositories can call. PHP does not dispatch static calls +through interfaces at runtime, so the consumer always names the concrete class (`Order::reconstitute(...)`, +`Reservation::reconstitute(...)`). The interface still earns its keep: it forces aggregates to expose the factory, +the trait default provides one for free, and overrides remain bound to the declared signature. The parameter name +is free per LSP, so an override can rename `$identity` to `$orderId` for readability, but the type must remain +`Identity` — narrowing to a concrete identity class would break LSP. Concrete types are enforced inside the +override with `instanceof`. + +> Barbara Liskov and Jeannette Wing, *A Behavioral Notion of Subtyping* (ACM TOPLAS, 1994). + +### 12. Why was `reconstituteAggregateVersion()` removed? + +It was never part of the external contract. The only caller was the trait's own `reconstitute()` factory, which +needed to set the aggregate version on the instance it had just built. Exposing that internal step as a public +instance method invited misuse (repositories calling it on aggregates they had not just reconstituted) without +adding any expressiveness over assigning the property directly. The factory now writes `$aggregate->aggregateVersion` +directly inside the trait, which is legal because the assignment happens in the static method of the same class +after the trait flattens into the aggregate. Eliminating the public method tightens the surface and removes the +documentation burden of explaining when calling it is correct. + ## License -Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE). +Building Blocks is licensed under [MIT](LICENSE). ## Contributing diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4464a1f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks/building-blocks/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. diff --git a/composer.json b/composer.json index ced3a3c..4727d47 100644 --- a/composer.json +++ b/composer.json @@ -30,13 +30,13 @@ "php": "^8.5", "ramsey/uuid": "^4.9", "tiny-blocks/collection": "^2.3", - "tiny-blocks/mapper": "^2.0", - "tiny-blocks/time": "^1.5", - "tiny-blocks/value-object": "^4.0" + "tiny-blocks/mapper": "^2.1", + "tiny-blocks/time": "^2.0", + "tiny-blocks/value-object": "^5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51", - "infection/infection": "^0.32", + "ergebnis/composer-normalize": "^2.52", + "infection/infection": "^0.33", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^13.1", "squizlabs/php_codesniffer": "^4.0" @@ -61,22 +61,22 @@ "sort-packages": true }, "scripts": { - "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", + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], "review": [ - "@phpcs", - "@phpstan" + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" ], - "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", + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ - "@test", - "@mutation-test" - ], - "tests-no-coverage": [ - "@test-no-coverage" + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index ee435dd..3307d81 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,9 +1,9 @@ { "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" }, - "tmpDir": "report/infection/", + "tmpDir": "reports/infection/", "minMsi": 100, "timeout": 30, "source": { diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..a52372c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + Code style for the tiny-blocks library. + + src + tests + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 6e9089c..a583202 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,16 +1,44 @@ parameters: - paths: - - src - level: 9 - tmpDir: report/phpstan - ignoreErrors: - - '#mixed given#' - - '#iterable type#' - - '#of new static#' - - '#generic interface#' - - '#destructuring on mixed#' - - identifier: trait.unused - - '#expects int<1, max>#' - - message: '#Upcasters::chain\(\) should return#' - path: src/Upcast/Upcasters.php - reportUnmatchedIgnoredErrors: false + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true + ignoreErrors: + # Constructor parameter $aggregateState cannot carry PHPDoc per code-style rule; + # the getter return-type mismatch is the downstream symptom of that constraint. + - + identifier: missingType.iterableValue + path: src/Snapshot/Snapshot.php + - + identifier: return.type + path: src/Snapshot/Snapshot.php + + # Constructor parameter $serializedEvent cannot carry PHPDoc per code-style rule. + - + identifier: missingType.iterableValue + path: src/Upcast/IntermediateEvent.php + + # Trait argument-type error reported in context of test fixture upcasters, + # whose rewrite() return is untyped because PHPDoc is prohibited in tests/. + - + identifier: argument.type + path: src/Upcast/SingleUpcasterBehavior.php + + # tests/ — PHPDoc and @var are prohibited inside tests/, so PHPStan errors + # for typed arrays in fixtures and helpers route through ignoreErrors. + - + identifier: missingType.iterableValue + path: tests/* + - + identifier: return.type + path: tests/* + - + identifier: assign.propertyType + path: tests/Models/* + - + identifier: property.nonObject + path: tests/Unit/* + - + identifier: method.nonObject + path: tests/Unit/* diff --git a/phpunit.xml b/phpunit.xml index 40c80a2..9cc6d13 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,15 @@ + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -23,15 +25,15 @@ - - - - + + + + - + diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php index bc81631..bd8d5a0 100644 --- a/src/Aggregate/AggregateRoot.php +++ b/src/Aggregate/AggregateRoot.php @@ -5,16 +5,15 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; use TinyBlocks\BuildingBlocks\Entity\Entity; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; /** * Cluster of associated objects treated as a single unit for data changes, with one Entity as the root. * - *

    External references must target the root; invariants apply to the whole cluster; transactions never + *

    External references must target the root. Invariants apply to the whole cluster. Transactions never * straddle aggregate boundaries. This interface adds two pragmatic fields absent from Evans:

    * *
      - *
    • sequenceNumber for optimistic offline locking.
    • + *
    • aggregateVersion for optimistic offline locking.
    • *
    • modelVersion for aggregate schema evolution.
    • *
    * @@ -24,23 +23,12 @@ * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software * (Addison-Wesley, 2003), Chapter 6 "Aggregates". * @see Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002), - * "Optimistic Offline Lock", source of sequenceNumber. + * "Optimistic Offline Lock", source of aggregateVersion. * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), source of * modelVersion. */ interface AggregateRoot extends Entity { - /** - * Returns the aggregate's current sequence number. - * - *

    The initial value is 0. The first recorded event increments it to 1, - * and each subsequent event advances it by one. Persistence adapters compare the stored value against - * the in-memory one to detect concurrent modifications.

    - * - * @return SequenceNumber The current sequence number. - */ - public function sequenceNumber(): SequenceNumber; - /** * Returns the schema version of this aggregate type. * @@ -59,5 +47,16 @@ public function modelVersion(): ModelVersion; * * @return string The short class name. */ - public function aggregateName(): string; + public function aggregateType(): string; + + /** + * Returns the aggregate's current version. + * + *

    The initial value is 0. The first recorded event increments it to 1, + * and each subsequent event advances it by one. Persistence adapters compare the stored value against + * the in-memory one to detect concurrent modifications.

    + * + * @return AggregateVersion The current aggregate version. + */ + public function aggregateVersion(): AggregateVersion; } diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php index c83dfab..d6d9217 100644 --- a/src/Aggregate/AggregateRootBehavior.php +++ b/src/Aggregate/AggregateRootBehavior.php @@ -11,8 +11,6 @@ use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; use TinyBlocks\BuildingBlocks\Event\EventType; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; -use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData; use TinyBlocks\Time\Instant; trait AggregateRootBehavior @@ -21,44 +19,21 @@ trait AggregateRootBehavior private EventRecords $recordedEvents; - private SequenceNumber $sequenceNumber; - - public function sequenceNumber(): SequenceNumber - { - return $this->sequenceNumber ?? SequenceNumber::initial(); - } + private AggregateVersion $aggregateVersion; public function modelVersion(): ModelVersion { return ModelVersion::initial(); } - public function aggregateName(): string + public function aggregateType(): string { return new ReflectionClass(objectOrClass: static::class)->getShortName(); } - protected function nextSequenceNumber(): void - { - $this->sequenceNumber = $this->sequenceNumber()->next(); - } - - protected function generateSnapshotData(): SnapshotData - { - return new SnapshotData(payload: $this->snapshotState()); - } - - protected function reconstituteSequenceNumber(SequenceNumber $sequenceNumber): void - { - $this->sequenceNumber = $sequenceNumber; - } - - protected function snapshotState(): array + public function aggregateVersion(): AggregateVersion { - $state = get_object_vars($this); - unset($state['recordedEvents'], $state['sequenceNumber']); - - return $state; + return $this->aggregateVersion ?? AggregateVersion::initial(); } public function recordedEvents(): EventRecords @@ -68,18 +43,22 @@ public function recordedEvents(): EventRecords return EventRecords::createFrom(elements: $records); } - protected function buildEventRecord(DomainEvent $event): EventRecord + private function nextAggregateVersion(): void + { + $this->aggregateVersion = $this->aggregateVersion()->next(); + } + + private function buildEventRecord(DomainEvent $event): EventRecord { return new EventRecord( id: Uuid::uuid4(), - type: EventType::fromEvent(event: $event), event: $event, - identity: $this->identity(), revision: $event->revision(), - occurredOn: Instant::now(), - snapshotData: $this->generateSnapshotData(), - aggregateType: $this->aggregateName(), - sequenceNumber: $this->sequenceNumber() + eventType: EventType::fromEvent(event: $event), + occurredAt: Instant::now(), + aggregateId: $this->identity(), + aggregateType: $this->aggregateType(), + aggregateVersion: $this->aggregateVersion() ); } } diff --git a/src/Aggregate/AggregateVersion.php b/src/Aggregate/AggregateVersion.php new file mode 100644 index 0000000..bb1101d --- /dev/null +++ b/src/Aggregate/AggregateVersion.php @@ -0,0 +1,85 @@ +value + 1); + } + + /** + * Tells whether this aggregate version is strictly after the given one. + * + * @param AggregateVersion $other The aggregate version to compare against. + * @return bool True when this value is greater than the other's. + */ + public function isAfter(AggregateVersion $other): bool + { + return $this->value > $other->value; + } + + /** + * Tells whether this aggregate version is strictly before the given one. + * + * @param AggregateVersion $other The aggregate version to compare against. + * @return bool True when this value is less than the other's. + */ + public function isBefore(AggregateVersion $other): bool + { + return $this->value < $other->value; + } +} diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php index 973c769..4c8b43b 100644 --- a/src/Aggregate/EventSourcingRoot.php +++ b/src/Aggregate/EventSourcingRoot.php @@ -8,13 +8,13 @@ use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; +use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; /** * Aggregate root variant whose state is derived entirely from its ordered stream of events. * - *

    The event store is the source of truth; aggregate state is a projection. Instances are created + *

    The event store is the source of truth. Aggregate state is a projection. Instances are created * through {@see blank()} and populated by replaying events via {@see reconstitute()}, optionally starting * from a {@see Snapshot} to skip earlier events.

    * @@ -28,25 +28,6 @@ */ interface EventSourcingRoot extends AggregateRoot { - /** - * Returns the explicit map of event class names to handler callables. - * - *

    When the returned array is empty, the trait falls back to the implicit - * convention when<EventShortName>. When the array is - * non-empty, it is the authoritative source: only events whose class names - * appear as keys can be applied; absence triggers an exception.

    - * - * @return array, callable> - */ - public function eventHandlers(): array; - - /** - * Returns the events recorded during the current unit of work. - * - * @return EventRecords The events awaiting append to the event store. - */ - public function recordedEvents(): EventRecords; - /** * Creates a blank aggregate with the given identity and no recorded events. * @@ -54,7 +35,7 @@ public function recordedEvents(): EventRecords; * * @param Identity $identity The identity to assign to the new aggregate. * @return static A new aggregate in its initial state. - * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist. + * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist. */ public static function blank(Identity $identity): static; @@ -62,14 +43,14 @@ public static function blank(Identity $identity): static; * Reconstitutes an aggregate by replaying an ordered stream of event records. * *

    When a snapshot is provided, the aggregate state is first restored from it and the snapshot's - * sequence number is taken as authoritative. Only events recorded after the snapshot need to be + * aggregate version is taken as authoritative. Only events recorded after the snapshot need to be * replayed.

    * * @param Identity $identity The identity of the aggregate. - * @param iterable $records The event stream to replay, ordered by sequence number. + * @param iterable $records The event stream to replay, ordered by aggregate version. * @param Snapshot|null $snapshot Optional snapshot to restore from before replay. * @return static The reconstituted aggregate. - * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist. + * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist. */ public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static; @@ -77,7 +58,7 @@ public static function reconstitute(Identity $identity, iterable $records, ?Snap * Returns the aggregate state to persist in a snapshot. * *

    The default implementation provided by {@see EventSourcingRootBehavior} returns all object - * properties except recordedEvents (transient buffer) and sequenceNumber + * properties except recordedEvents (transient buffer) and aggregateVersion * (already a first-class field on the snapshot). Override to exclude infrastructure properties * (loggers, caches, etc.) or to include only a curated subset of state.

    * @@ -89,10 +70,29 @@ public function snapshotState(): array; * Restores aggregate state from the given snapshot. * *

    Implementations read {@see Snapshot::aggregateState()} and copy the relevant fields into - * their own properties. The sequence number is applied automatically by - * reconstitute(); implementations should not touch it.

    + * their own properties. The aggregate version is applied automatically by + * reconstitute(). Implementations should not touch it.

    * * @param Snapshot $snapshot The snapshot to restore from. */ public function applySnapshot(Snapshot $snapshot): void; + + /** + * Returns the explicit map of event class names to handler callables. + * + *

    When the returned array is empty, the trait falls back to the implicit + * convention whenEventShortName. When the array is + * non-empty, it is the authoritative source: only events whose class names + * appear as keys can be applied. Absence triggers an exception.

    + * + * @return array, callable> The explicit event-class to handler map. + */ + public function eventHandlers(): array; + + /** + * Returns the events recorded during the current unit of work. + * + * @return EventRecords The events awaiting append to the event store. + */ + public function recordedEvents(): EventRecords; } diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php index c29920a..97f7d7d 100644 --- a/src/Aggregate/EventSourcingRootBehavior.php +++ b/src/Aggregate/EventSourcingRootBehavior.php @@ -10,9 +10,8 @@ use TinyBlocks\BuildingBlocks\Event\DomainEvent; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\EventHandlerMethodNotFound; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\NoEventHandlerRegistered; +use TinyBlocks\BuildingBlocks\Exceptions\EventHandlerMethodNotFound; +use TinyBlocks\BuildingBlocks\Exceptions\NoEventHandlerRegistered; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; trait EventSourcingRootBehavior @@ -21,10 +20,10 @@ trait EventSourcingRootBehavior public static function blank(Identity $identity): static { - $aggregate = new ReflectionClass(static::class)->newInstanceWithoutConstructor(); - new ReflectionProperty($aggregate, $aggregate->identityName()) + $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(class: $aggregate, property: $aggregate->identityName()) ->setValue($aggregate, $identity); - $aggregate->sequenceNumber = SequenceNumber::initial(); + $aggregate->aggregateVersion = AggregateVersion::initial(); $aggregate->recordedEvents = EventRecords::createFromEmpty(); return $aggregate; @@ -39,7 +38,7 @@ public static function reconstitute( if (!is_null($snapshot)) { $aggregate->applySnapshot(snapshot: $snapshot); - $aggregate->sequenceNumber = $snapshot->sequenceNumber(); + $aggregate->aggregateVersion = $snapshot->aggregateVersion(); } foreach ($records as $record) { @@ -49,29 +48,40 @@ public static function reconstitute( return $aggregate; } - public function eventHandlers(): array - { - return []; - } - public function snapshotState(): array { + /** @var array $state */ $state = get_object_vars($this); - unset($state['recordedEvents'], $state['sequenceNumber']); + unset($state['recordedEvents'], $state['aggregateVersion']); return $state; } + public function eventHandlers(): array + { + return []; + } + + /** + * Records a domain event and applies it to the aggregate's state in one step. + * + *

    Invoked by command methods of an event-sourced aggregate. Advances the aggregate version, + * builds the {@see EventRecord}, applies it via the registered handler or the implicit + * whenEventShortName convention, and appends the record to the recorded-events + * collection. Not part of the public surface.

    + * + * @param DomainEvent $event The event to record and apply. + */ protected function when(DomainEvent $event): void { - $this->nextSequenceNumber(); + $this->nextAggregateVersion(); $record = $this->buildEventRecord(event: $event); $this->applyEvent(record: $record); $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) ->add(elements: $record); } - protected function applyEvent(EventRecord $record): void + private function applyEvent(EventRecord $record): void { $handlers = $this->eventHandlers(); $eventClass = $record->event::class; @@ -82,17 +92,17 @@ protected function applyEvent(EventRecord $record): void } $handlers[$eventClass]($record->event); - $this->sequenceNumber = $record->sequenceNumber; + $this->aggregateVersion = $record->aggregateVersion; return; } - $methodName = sprintf('when%s', $record->type->value); + $methodName = sprintf('when%s', $record->eventType->value); if (!method_exists($this, $methodName)) { throw new EventHandlerMethodNotFound(methodName: $methodName, aggregateClass: static::class); } $this->{$methodName}($record->event); - $this->sequenceNumber = $record->sequenceNumber; + $this->aggregateVersion = $record->aggregateVersion; } } diff --git a/src/Aggregate/EventualAggregateRoot.php b/src/Aggregate/EventualAggregateRoot.php index 642473d..e058bec 100644 --- a/src/Aggregate/EventualAggregateRoot.php +++ b/src/Aggregate/EventualAggregateRoot.php @@ -4,12 +4,14 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; +use TinyBlocks\BuildingBlocks\Entity\Identity; use TinyBlocks\BuildingBlocks\Event\EventRecords; +use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty; /** * Aggregate root variant that records domain events for eventual publication via transactional outbox. * - *

    State is persisted as the source of truth; events are emitted as side effects and delivered + *

    State is persisted as the source of truth. Events are emitted as side effects and delivered * at-least-once to external consumers. The repository drains recordedEvents() after * persisting the aggregate state.

    * @@ -30,11 +32,52 @@ */ interface EventualAggregateRoot extends AggregateRoot { + /** + * Reconstitutes the aggregate from persisted state. + * + *

    Default factory for state-based aggregates. Used by repositories that load aggregate state + * from external storage (a relational row, a document, an in-memory cache) and need an instance + * ready to emit further events from the correct aggregate version.

    + * + *

    The default implementation provided by {@see EventualAggregateRootBehavior} hydrates state + * properties by reflection from the $state map. Aggregates may override this factory + * to customize the hydration or to rename the identity parameter to reflect their specific + * identity type. For example:

    + * + *
    +     * // Override with a custom parameter name:
    +     * public static function reconstitute(
    +     *     Identity $orderId,
    +     *     AggregateVersion $aggregateVersion,
    +     *     array $state = []
    +     * ): static
    +     * 
    + * + *

    The parameter name is free, but the type must remain Identity per LSP rules on + * static methods. Concrete identity types (e.g. OrderId) can be enforced inside the + * override via instanceof.

    + * + * @param Identity $identity The aggregate's identity. + * @param AggregateVersion $aggregateVersion The version to restore. Subsequent emitted events + * advance from this value. + * @param array $state Optional map of property name to value. Entries whose key + * does not match a declared property are silently ignored by + * the default implementation. + * @return static The reconstituted aggregate. + * @throws MissingIdentityProperty When the property referenced by identityProperty() + * does not exist on the aggregate class. + */ + public static function reconstitute( + Identity $identity, + AggregateVersion $aggregateVersion, + array $state = [] + ): static; + /** * Returns a copy of all events recorded since the aggregate was created. * - *

    Always returns a fresh copy: external mutation of the returned collection does not leak into the - * aggregate's internal buffer.

    + *

    Always returns a fresh copy: external mutation of the returned collection does not leak into + * the aggregate's internal buffer.

    * * @return EventRecords A snapshot of the recorded events, safe to iterate and mutate. */ diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php index 3b639b5..31f8c15 100644 --- a/src/Aggregate/EventualAggregateRootBehavior.php +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -4,16 +4,51 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; +use ReflectionClass; +use ReflectionProperty; +use TinyBlocks\BuildingBlocks\Entity\Identity; use TinyBlocks\BuildingBlocks\Event\DomainEvent; +use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; trait EventualAggregateRootBehavior { use AggregateRootBehavior; + public static function reconstitute( + Identity $identity, + AggregateVersion $aggregateVersion, + array $state = [] + ): static { + $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor(); + new ReflectionProperty(class: $aggregate, property: $aggregate->identityName()) + ->setValue($aggregate, $identity); + + foreach ($state as $property => $value) { + if (property_exists($aggregate, $property)) { + new ReflectionProperty(class: $aggregate, property: $property) + ->setValue($aggregate, $value); + } + } + + $aggregate->aggregateVersion = $aggregateVersion; + + return $aggregate; + } + + /** + * Records a domain event on the aggregate's internal buffer. + * + *

    Invoked by command methods of the aggregate after state has been mutated. Advances the + * aggregate version and appends a fully-built {@see EventRecord} to the recorded-events collection. + * Not part of the public surface: external callers must go through command methods that establish + * domain invariants.

    + * + * @param DomainEvent $event The event to record. + */ protected function push(DomainEvent $event): void { - $this->nextSequenceNumber(); + $this->nextAggregateVersion(); $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) ->add(elements: $this->buildEventRecord(event: $event)); } diff --git a/src/Aggregate/ModelVersion.php b/src/Aggregate/ModelVersion.php index f2876fd..1b284fc 100644 --- a/src/Aggregate/ModelVersion.php +++ b/src/Aggregate/ModelVersion.php @@ -4,7 +4,7 @@ namespace TinyBlocks\BuildingBlocks\Aggregate; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidModelVersion; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidModelVersion; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -19,13 +19,47 @@ private function __construct(public int $value) } } + /** + * Creates a ModelVersion from the given integer value. + * + * @param int $value The schema version. Must be greater than or equal to 0. + * @return ModelVersion The created instance. + * @throws InvalidModelVersion If the value is less than 0. + */ public static function of(int $value): ModelVersion { return new ModelVersion(value: $value); } + /** + * Creates a ModelVersion with the initial value of 0. + * + * @return ModelVersion The initial model version. + */ public static function initial(): ModelVersion { return new ModelVersion(value: 0); } + + /** + * Tells whether this model version is strictly after the given one. + * + * @param ModelVersion $other The model version to compare against. + * @return bool True when this value is greater than the other's. + */ + public function isAfter(ModelVersion $other): bool + { + return $this->value > $other->value; + } + + /** + * Tells whether this model version is strictly before the given one. + * + * @param ModelVersion $other The model version to compare against. + * @return bool True when this value is less than the other's. + */ + public function isBefore(ModelVersion $other): bool + { + return $this->value < $other->value; + } } diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php index 5896f64..1daf0cd 100644 --- a/src/Entity/Entity.php +++ b/src/Entity/Entity.php @@ -4,7 +4,7 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; +use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty; /** * Object whose identity persists through time and changes of state. @@ -25,7 +25,7 @@ interface Entity * Returns the Identity that uniquely identifies this entity. * * @return Identity The identity instance held by this entity. - * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist. + * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist. */ public function identity(): Identity; @@ -33,7 +33,7 @@ public function identity(): Identity; * Returns the name of the property that holds this entity's Identity. * * @return string The property name, resolved from identityProperty(). - * @throws MissingIdentityProperty When the property referenced by identityProperty() does not exist. + * @throws MissingIdentityProperty If the property referenced by identityProperty() does not exist. */ public function identityName(): string; @@ -48,7 +48,7 @@ public function identityName(): string; public function identityValue(): mixed; /** - * Checks whether this entity and the given one share the same identity. + * Tells whether this entity and the given one share the same identity. * * @param Entity $other The entity whose identity will be compared. * @return bool True when both entities hold equal identities. @@ -56,7 +56,7 @@ public function identityValue(): mixed; public function sameIdentityOf(Entity $other): bool; /** - * Checks whether the given Identity is equal to this entity's identity. + * Tells whether the given Identity is equal to this entity's identity. * * @param Identity $other The identity to compare against. * @return bool True when the given identity equals this entity's identity. diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php index 280f56a..9c255e4 100644 --- a/src/Entity/EntityBehavior.php +++ b/src/Entity/EntityBehavior.php @@ -4,10 +4,18 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; +use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty; trait EntityBehavior { + public function identity(): Identity + { + /** @var Identity $identity */ + $identity = $this->{$this->identityName()}; + + return $identity; + } + public function identityName(): string { $name = $this->identityProperty(); @@ -19,16 +27,6 @@ public function identityName(): string return $name; } - protected function identityProperty(): string - { - return 'id'; - } - - public function identity(): Identity - { - return $this->{$this->identityName()}; - } - public function identityValue(): mixed { return $this->identity()->identityValue(); @@ -43,4 +41,16 @@ public function identityEquals(Identity $other): bool { return $this->identity()->equals(other: $other); } + + /** + * Returns the property name that holds the entity's identity. + * + *

    Defaults to 'id'. Override in entities whose identity property has a different name.

    + * + * @return string The property name backing the identity. + */ + protected function identityProperty(): string + { + return 'id'; + } } diff --git a/src/Entity/Identity.php b/src/Entity/Identity.php index df7bb5d..82ec104 100644 --- a/src/Entity/Identity.php +++ b/src/Entity/Identity.php @@ -10,7 +10,7 @@ * 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. Implementations are expected to be immutable; in PHP 8.5+ this is achieved through + * 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 diff --git a/src/Entity/SingleIdentity.php b/src/Entity/SingleIdentity.php index 3f8160b..9d357ee 100644 --- a/src/Entity/SingleIdentity.php +++ b/src/Entity/SingleIdentity.php @@ -11,7 +11,7 @@ * auto-increment integer, slug, etc.). Not a concept from Evans: the book makes no distinction between * single-value and composite identities.

    * - *

    Implementations should declare exactly one property holding the scalar value; the default trait + *

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

    */ interface SingleIdentity extends Identity diff --git a/src/Event/DomainEvent.php b/src/Event/DomainEvent.php index a9ae71c..ab7efd5 100644 --- a/src/Event/DomainEvent.php +++ b/src/Event/DomainEvent.php @@ -12,10 +12,10 @@ * on a DomainEvent and must not be exposed as accessors on subtypes:

    * *
      - *
    • Aggregate identity — added by the aggregate when building the {@see EventRecord}.
    • - *
    • Aggregate type — derived from the aggregate's class name into the envelope.
    • - *
    • Sequence number — assigned by the aggregate into the envelope.
    • - *
    • Serialization to storage — responsibility of outbox writers and consumer + *
    • Aggregate identity: added by the aggregate when building the {@see EventRecord}.
    • + *
    • Aggregate type: derived from the aggregate's class name into the envelope.
    • + *
    • Sequence number: assigned by the aggregate into the envelope.
    • + *
    • Serialization to storage: responsibility of outbox writers and consumer * deserializers, both infrastructure concerns outside this library.
    • *
    * @@ -35,7 +35,7 @@ interface DomainEvent /** * Returns the schema revision of this event. * - * @return Revision The current schema revision; defaults to {@see Revision::initial}. + * @return Revision The current schema revision. Defaults to {@see Revision::initial}. */ public function revision(): Revision; } diff --git a/src/Event/EventRecord.php b/src/Event/EventRecord.php index 649cfd2..d60d18f 100644 --- a/src/Event/EventRecord.php +++ b/src/Event/EventRecord.php @@ -6,8 +6,8 @@ use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Entity\Identity; -use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData; use TinyBlocks\Time\Instant; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -18,36 +18,44 @@ public function __construct( public UuidInterface $id, - public EventType $type, public DomainEvent $event, - public Identity $identity, public Revision $revision, - public Instant $occurredOn, - public SnapshotData $snapshotData, + public EventType $eventType, + public Instant $occurredAt, + public Identity $aggregateId, public string $aggregateType, - public SequenceNumber $sequenceNumber + public AggregateVersion $aggregateVersion ) { } + /** + * Creates an EventRecord from a domain event and its required envelope fields. + * + * @param DomainEvent $event The event being recorded. + * @param Identity $aggregateId The aggregate identity that produced the event. + * @param string $aggregateType The short class name of the aggregate. + * @param AggregateVersion $aggregateVersion The aggregate version assigned to this envelope. + * @param UuidInterface|null $id Optional explicit identifier. Defaults to a fresh UUIDv4. + * @param Instant|null $occurredAt Optional explicit occurrence timestamp. Defaults to now. + * @return EventRecord The constructed envelope. + */ public static function of( DomainEvent $event, - Identity $identity, + Identity $aggregateId, string $aggregateType, - SequenceNumber $sequenceNumber, + AggregateVersion $aggregateVersion, ?UuidInterface $id = null, - ?Instant $occurredOn = null, - ?SnapshotData $snapshotData = null + ?Instant $occurredAt = null ): EventRecord { return new EventRecord( id: $id ?? Uuid::uuid4(), - type: EventType::fromEvent(event: $event), event: $event, - identity: $identity, revision: $event->revision(), - occurredOn: $occurredOn ?? Instant::now(), - snapshotData: $snapshotData ?? new SnapshotData(payload: []), + eventType: EventType::fromEvent(event: $event), + occurredAt: $occurredAt ?? Instant::now(), + aggregateId: $aggregateId, aggregateType: $aggregateType, - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); } } diff --git a/src/Event/EventType.php b/src/Event/EventType.php index c788cd5..4e2be45 100644 --- a/src/Event/EventType.php +++ b/src/Event/EventType.php @@ -5,7 +5,7 @@ namespace TinyBlocks\BuildingBlocks\Event; use ReflectionClass; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidEventType; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -22,11 +22,25 @@ private function __construct(public string $value) } } + /** + * Creates an EventType from a domain event using its short class name. + * + * @param DomainEvent $event The event whose class name carries the type. + * @return EventType The created instance. + * @throws InvalidEventType If the resolved class name does not match the required pattern. + */ public static function fromEvent(DomainEvent $event): EventType { - return new EventType(value: new ReflectionClass($event)->getShortName()); + return new EventType(value: new ReflectionClass(objectOrClass: $event)->getShortName()); } + /** + * Creates an EventType from a raw type identifier. + * + * @param string $value The PascalCase type identifier. + * @return EventType The created instance. + * @throws InvalidEventType If the value does not match the required pattern. + */ public static function fromString(string $value): EventType { return new EventType(value: $value); diff --git a/src/Event/Revision.php b/src/Event/Revision.php index 40fec6a..ace14fc 100644 --- a/src/Event/Revision.php +++ b/src/Event/Revision.php @@ -4,7 +4,7 @@ namespace TinyBlocks\BuildingBlocks\Event; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidRevision; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -19,21 +19,45 @@ private function __construct(public int $value) } } + /** + * Creates a Revision with the initial value of 1. + * + * @return Revision The initial revision. + */ public static function initial(): Revision { return new Revision(value: 1); } + /** + * Creates a Revision from the given integer value. + * + * @param int $value The revision number. Must be greater than or equal to 1. + * @return Revision The created instance. + * @throws InvalidRevision If the value is less than 1. + */ public static function of(int $value): Revision { return new Revision(value: $value); } + /** + * Tells whether this revision is strictly after the given one. + * + * @param Revision $other The revision to compare against. + * @return bool True when this revision's value is greater than the other's. + */ public function isAfter(Revision $other): bool { return $this->value > $other->value; } + /** + * Tells whether this revision is strictly before the given one. + * + * @param Revision $other The revision to compare against. + * @return bool True when this revision's value is less than the other's. + */ public function isBefore(Revision $other): bool { return $this->value < $other->value; diff --git a/src/Event/SequenceNumber.php b/src/Event/SequenceNumber.php deleted file mode 100644 index 217906b..0000000 --- a/src/Event/SequenceNumber.php +++ /dev/null @@ -1,46 +0,0 @@ -value + 1); - } - - public function isAfter(SequenceNumber $other): bool - { - return $this->value > $other->value; - } -} diff --git a/src/Internal/Exceptions/EventHandlerMethodNotFound.php b/src/Exceptions/EventHandlerMethodNotFound.php similarity index 69% rename from src/Internal/Exceptions/EventHandlerMethodNotFound.php rename to src/Exceptions/EventHandlerMethodNotFound.php index 33ac4f1..6e2fd31 100644 --- a/src/Internal/Exceptions/EventHandlerMethodNotFound.php +++ b/src/Exceptions/EventHandlerMethodNotFound.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use LogicException; @@ -12,6 +12,6 @@ public function __construct(public readonly string $methodName, public readonly { $template = 'Handler method <%s> not found in aggregate <%s>.'; - parent::__construct(sprintf($template, $methodName, $aggregateClass)); + parent::__construct(message: sprintf($template, $methodName, $aggregateClass)); } } diff --git a/src/Exceptions/InvalidAggregateVersion.php b/src/Exceptions/InvalidAggregateVersion.php new file mode 100644 index 0000000..34057d4 --- /dev/null +++ b/src/Exceptions/InvalidAggregateVersion.php @@ -0,0 +1,17 @@ +.'; + + parent::__construct(message: sprintf($template, $value)); + } +} diff --git a/src/Internal/Exceptions/InvalidEventType.php b/src/Exceptions/InvalidEventType.php similarity index 72% rename from src/Internal/Exceptions/InvalidEventType.php rename to src/Exceptions/InvalidEventType.php index a722cfc..dbcad5f 100644 --- a/src/Internal/Exceptions/InvalidEventType.php +++ b/src/Exceptions/InvalidEventType.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use InvalidArgumentException; @@ -12,6 +12,6 @@ public function __construct(public readonly string $value, public readonly strin { $template = 'Event type <%s> does not match the required pattern <%s>.'; - parent::__construct(sprintf($template, $value, $pattern)); + parent::__construct(message: sprintf($template, $value, $pattern)); } } diff --git a/src/Internal/Exceptions/InvalidModelVersion.php b/src/Exceptions/InvalidModelVersion.php similarity index 71% rename from src/Internal/Exceptions/InvalidModelVersion.php rename to src/Exceptions/InvalidModelVersion.php index 1d875ef..702dd81 100644 --- a/src/Internal/Exceptions/InvalidModelVersion.php +++ b/src/Exceptions/InvalidModelVersion.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use InvalidArgumentException; @@ -12,6 +12,6 @@ public function __construct(public readonly int $value) { $template = 'Model version must be greater than or equal to 0, got <%d>.'; - parent::__construct(sprintf($template, $value)); + parent::__construct(message: sprintf($template, $value)); } } diff --git a/src/Internal/Exceptions/InvalidRevision.php b/src/Exceptions/InvalidRevision.php similarity index 71% rename from src/Internal/Exceptions/InvalidRevision.php rename to src/Exceptions/InvalidRevision.php index ce0a082..df408e4 100644 --- a/src/Internal/Exceptions/InvalidRevision.php +++ b/src/Exceptions/InvalidRevision.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use InvalidArgumentException; @@ -12,6 +12,6 @@ public function __construct(public readonly int $value) { $template = 'Revision must be greater than or equal to 1, got <%d>.'; - parent::__construct(sprintf($template, $value)); + parent::__construct(message: sprintf($template, $value)); } } diff --git a/src/Internal/Exceptions/InvalidSnapshotCount.php b/src/Exceptions/InvalidSnapshotCount.php similarity index 70% rename from src/Internal/Exceptions/InvalidSnapshotCount.php rename to src/Exceptions/InvalidSnapshotCount.php index d0996bd..34a8cc7 100644 --- a/src/Internal/Exceptions/InvalidSnapshotCount.php +++ b/src/Exceptions/InvalidSnapshotCount.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use InvalidArgumentException; @@ -12,6 +12,6 @@ public function __construct(public readonly int $count) { $template = 'Snapshot count must be at least 1, got <%d>.'; - parent::__construct(sprintf($template, $count)); + parent::__construct(message: sprintf($template, $count)); } } diff --git a/src/Internal/Exceptions/MissingIdentityProperty.php b/src/Exceptions/MissingIdentityProperty.php similarity index 71% rename from src/Internal/Exceptions/MissingIdentityProperty.php rename to src/Exceptions/MissingIdentityProperty.php index b2aa0a4..b327588 100644 --- a/src/Internal/Exceptions/MissingIdentityProperty.php +++ b/src/Exceptions/MissingIdentityProperty.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use RuntimeException; @@ -12,6 +12,6 @@ public function __construct(public readonly string $className, public readonly s { $template = 'Property <%s> referenced by identityName() does not exist in <%s>.'; - parent::__construct(sprintf($template, $propertyName, $className)); + parent::__construct(message: sprintf($template, $propertyName, $className)); } } diff --git a/src/Internal/Exceptions/NoEventHandlerRegistered.php b/src/Exceptions/NoEventHandlerRegistered.php similarity index 70% rename from src/Internal/Exceptions/NoEventHandlerRegistered.php rename to src/Exceptions/NoEventHandlerRegistered.php index be1bc89..3ddf333 100644 --- a/src/Internal/Exceptions/NoEventHandlerRegistered.php +++ b/src/Exceptions/NoEventHandlerRegistered.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\BuildingBlocks\Internal\Exceptions; +namespace TinyBlocks\BuildingBlocks\Exceptions; use LogicException; @@ -12,6 +12,6 @@ public function __construct(public readonly string $eventClass, public readonly { $template = 'No handler registered for event <%s> in aggregate <%s>.'; - parent::__construct(sprintf($template, $eventClass, $aggregateClass)); + parent::__construct(message: sprintf($template, $eventClass, $aggregateClass)); } } diff --git a/src/Internal/Exceptions/InvalidSequenceNumber.php b/src/Internal/Exceptions/InvalidSequenceNumber.php deleted file mode 100644 index 216595c..0000000 --- a/src/Internal/Exceptions/InvalidSequenceNumber.php +++ /dev/null @@ -1,17 +0,0 @@ -.'; - - parent::__construct(sprintf($template, $value)); - } -} diff --git a/src/Snapshot/Snapshot.php b/src/Snapshot/Snapshot.php index 04bed16..8f0733f 100644 --- a/src/Snapshot/Snapshot.php +++ b/src/Snapshot/Snapshot.php @@ -4,8 +4,8 @@ namespace TinyBlocks\BuildingBlocks\Snapshot; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\Time\Instant; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; @@ -15,63 +15,104 @@ use ValueObjectBehavior; private function __construct( - private string $type, - private Instant $createdAt, - private mixed $aggregateId, - private array $aggregateState, - private SequenceNumber $sequenceNumber + public string $aggregateType, + public Instant $createdAt, + public mixed $aggregateId, + public array $aggregateState, + public AggregateVersion $aggregateVersion ) { } + /** + * Creates a Snapshot from the persisted fields. + * + * @param string $aggregateType The short class name of the aggregate. + * @param Instant $createdAt The instant the snapshot was taken. + * @param mixed $aggregateId The aggregate identity raw value. + * @param array $aggregateState The captured aggregate state keyed by property name. + * @param AggregateVersion $aggregateVersion The aggregate version captured with the snapshot. + * @return Snapshot The restored snapshot instance. + */ public static function restore( - string $type, + string $aggregateType, Instant $createdAt, mixed $aggregateId, array $aggregateState, - SequenceNumber $sequenceNumber + AggregateVersion $aggregateVersion ): Snapshot { return new Snapshot( - type: $type, + aggregateType: $aggregateType, createdAt: $createdAt, aggregateId: $aggregateId, aggregateState: $aggregateState, - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); } + /** + * Creates a Snapshot from the current state of the given aggregate. + * + * @param EventSourcingRoot $aggregate The aggregate to snapshot. + * @return Snapshot The captured snapshot. + */ public static function fromAggregate(EventSourcingRoot $aggregate): Snapshot { return new Snapshot( - type: $aggregate->aggregateName(), + aggregateType: $aggregate->aggregateType(), createdAt: Instant::now(), aggregateId: $aggregate->identityValue(), aggregateState: $aggregate->snapshotState(), - sequenceNumber: $aggregate->sequenceNumber() + aggregateVersion: $aggregate->aggregateVersion() ); } - public function type(): string + /** + * Returns the aggregate type. + * + * @return string The short class name of the snapshotted aggregate. + */ + public function aggregateType(): string { - return $this->type; + return $this->aggregateType; } + /** + * Returns the creation timestamp. + * + * @return Instant The instant the snapshot was taken. + */ public function createdAt(): Instant { return $this->createdAt; } + /** + * Returns the aggregate identity raw value. + * + * @return mixed The identity value captured with the snapshot. + */ public function aggregateId(): mixed { return $this->aggregateId; } + /** + * Returns the aggregate state as an associative array. + * + * @return array The captured state keyed by property name. + */ public function aggregateState(): array { return $this->aggregateState; } - public function sequenceNumber(): SequenceNumber + /** + * Returns the aggregate version. + * + * @return AggregateVersion The aggregate version captured with the snapshot. + */ + public function aggregateVersion(): AggregateVersion { - return $this->sequenceNumber; + return $this->aggregateVersion; } } diff --git a/src/Snapshot/SnapshotCondition.php b/src/Snapshot/SnapshotCondition.php index d1ddbf7..87699f5 100644 --- a/src/Snapshot/SnapshotCondition.php +++ b/src/Snapshot/SnapshotCondition.php @@ -9,15 +9,15 @@ /** * Strategy that decides when a snapshot of an event-sourced aggregate should be taken. * - *

    Typical implementations check the aggregate's sequence number against a threshold (for example, take - * a snapshot every N events) or combine sequence checks with a time-based policy. Keeping the + *

    Typical implementations check the aggregate's version against a threshold (for example, take a + * snapshot every N events) or combine version checks with a time-based policy. Keeping the * decision behind a strategy lets consumers mix and match policies per aggregate type without branching * inside the snapshotter.

    */ interface SnapshotCondition { /** - * Decides whether a snapshot of the given aggregate should be taken now. + * Tells whether a snapshot of the given aggregate should be taken now. * * @param EventSourcingRoot $aggregate The aggregate under evaluation. * @return bool True when a snapshot should be taken. diff --git a/src/Snapshot/SnapshotData.php b/src/Snapshot/SnapshotData.php deleted file mode 100644 index 99cb3dd..0000000 --- a/src/Snapshot/SnapshotData.php +++ /dev/null @@ -1,22 +0,0 @@ -payload; - } -} diff --git a/src/Snapshot/SnapshotEvery.php b/src/Snapshot/SnapshotEvery.php index b8c98d1..d20352a 100644 --- a/src/Snapshot/SnapshotEvery.php +++ b/src/Snapshot/SnapshotEvery.php @@ -5,7 +5,7 @@ namespace TinyBlocks\BuildingBlocks\Snapshot; use TinyBlocks\BuildingBlocks\Aggregate\EventSourcingRoot; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSnapshotCount; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidSnapshotCount; final readonly class SnapshotEvery implements SnapshotCondition { @@ -16,6 +16,13 @@ private function __construct(private int $count) } } + /** + * Creates a SnapshotEvery condition that triggers every N events. + * + * @param int $count The number of events between snapshots. Must be at least 1. + * @return SnapshotEvery The created condition. + * @throws InvalidSnapshotCount If the count is less than 1. + */ public static function events(int $count): SnapshotEvery { return new SnapshotEvery(count: $count); @@ -23,7 +30,7 @@ public static function events(int $count): SnapshotEvery public function shouldSnapshot(EventSourcingRoot $aggregate): bool { - $value = $aggregate->sequenceNumber()->value; + $value = $aggregate->aggregateVersion()->value; return $value > 0 && $value % $this->count === 0; } diff --git a/src/Snapshot/SnapshotNever.php b/src/Snapshot/SnapshotNever.php index 5b5004c..30a42e5 100644 --- a/src/Snapshot/SnapshotNever.php +++ b/src/Snapshot/SnapshotNever.php @@ -12,6 +12,11 @@ private function __construct() { } + /** + * Creates a SnapshotNever condition that never triggers a snapshot. + * + * @return SnapshotNever The created condition. + */ public static function create(): SnapshotNever { return new SnapshotNever(); diff --git a/src/Snapshot/SnapshotterBehavior.php b/src/Snapshot/SnapshotterBehavior.php index 0ca59f6..728865c 100644 --- a/src/Snapshot/SnapshotterBehavior.php +++ b/src/Snapshot/SnapshotterBehavior.php @@ -13,5 +13,14 @@ public function take(EventSourcingRoot $aggregate): void $this->persist(snapshot: Snapshot::fromAggregate(aggregate: $aggregate)); } + /** + * Persists the given snapshot. + * + *

    Implemented by the consumer. Invoked once per call to {@see take()} with the snapshot already + * captured from the aggregate. Storage format and location are entirely up to the implementation. + * This hook simply hands over the captured snapshot.

    + * + * @param Snapshot $snapshot The snapshot to persist. + */ abstract protected function persist(Snapshot $snapshot): void; } diff --git a/src/Upcast/DefaultValues.php b/src/Upcast/DefaultValues.php index f8c32c1..ac09b59 100644 --- a/src/Upcast/DefaultValues.php +++ b/src/Upcast/DefaultValues.php @@ -4,8 +4,13 @@ namespace TinyBlocks\BuildingBlocks\Upcast; -final readonly class DefaultValues +final class DefaultValues { + /** + * Returns the DefaultValues as an associative array keyed by primitive type. + * + * @return array Zero-value for each primitive type. + */ public static function get(): array { return [ diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php index 95a68e5..2035e84 100644 --- a/src/Upcast/IntermediateEvent.php +++ b/src/Upcast/IntermediateEvent.php @@ -35,6 +35,12 @@ public function equals(ValueObject $other): bool && $this->serializedEvent === $other->serializedEvent; } + /** + * Returns a copy of the IntermediateEvent with the revision replaced. + * + * @param Revision $revision The replacement revision. + * @return IntermediateEvent A new instance carrying the given revision. + */ public function withRevision(Revision $revision): IntermediateEvent { return new IntermediateEvent( @@ -44,6 +50,12 @@ public function withRevision(Revision $revision): IntermediateEvent ); } + /** + * Returns a copy of the IntermediateEvent with the serialized payload replaced. + * + * @param array $serializedEvent The replacement payload. + * @return IntermediateEvent A new instance carrying the given payload. + */ public function withSerializedEvent(array $serializedEvent): IntermediateEvent { return new IntermediateEvent( diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php index 6ad0b41..36d6a6d 100644 --- a/src/Upcast/SingleUpcasterBehavior.php +++ b/src/Upcast/SingleUpcasterBehavior.php @@ -23,5 +23,15 @@ public function upcast(IntermediateEvent $event): IntermediateEvent ->withRevision(revision: Revision::of(value: static::TO_REVISION)); } + /** + * Rewrites the serialized payload of an event being upcast. + * + *

    Implemented by the consumer. Invoked by {@see upcast()} only when the event's type and revision + * match this upcaster's declared (type, from-revision) pair. The returned array becomes the new + * serialized payload at the upcaster's to-revision.

    + * + * @param array $payload The serialized event payload. + * @return array The rewritten payload. + */ abstract protected function rewrite(array $payload): array; } diff --git a/src/Upcast/Upcasters.php b/src/Upcast/Upcasters.php index 2dd8d93..185fb68 100644 --- a/src/Upcast/Upcasters.php +++ b/src/Upcast/Upcasters.php @@ -8,12 +8,25 @@ final class Upcasters extends Collection { + /** + * Folds every upcaster in this collection over the given event, returning the resulting event. + * + *

    Each upcaster either advances the event by one (type, revision) step or returns it unchanged. + * Apply order follows the collection's iteration order, so callers must register upcasters in the + * order they should run.

    + * + * @param IntermediateEvent $event The event entering the chain. + * @return IntermediateEvent The event after every upcaster in the chain has been applied. + */ public function chain(IntermediateEvent $event): IntermediateEvent { $upcast = static function (IntermediateEvent $carried, Upcaster $upcaster): IntermediateEvent { return $upcaster->upcast(event: $carried); }; - return $this->reduce(accumulator: $upcast, initial: $event); + /** @var IntermediateEvent $upcasted */ + $upcasted = $this->reduce(accumulator: $upcast, initial: $event); + + return $upcasted; } } diff --git a/tests/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Aggregate/EventualAggregateRootBehaviorTest.php deleted file mode 100644 index 4e93ac2..0000000 --- a/tests/Aggregate/EventualAggregateRootBehaviorTest.php +++ /dev/null @@ -1,175 +0,0 @@ -sequenceNumber(); - - /** @Then the sequence number is 1 */ - self::assertSame(1, $sequenceNumber->value); - } - - public function testSequenceNumberAdvancesOnEverySubsequentEvent(): void - { - /** @Given a placed order */ - $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen'); - - /** @And a shipping event emitted after placement */ - $order->ship(carrier: 'DHL'); - - /** @When retrieving the sequence number */ - $sequenceNumber = $order->sequenceNumber(); - - /** @Then the sequence number reflects every emitted event */ - self::assertSame(2, $sequenceNumber->value); - } - - public function testRecordedEventsCountMatchesEmittedEvents(): void - { - /** @Given a placed order */ - $order = Order::place(orderId: new OrderId(value: 'ord-3'), item: 'lamp'); - - /** @And a shipping event emitted after placement */ - $order->ship(carrier: 'FedEx'); - - /** @When retrieving recorded events */ - $records = $order->recordedEvents(); - - /** @Then the count matches the number of events */ - self::assertSame(2, $records->count()); - } - - public function testFirstRecordedEventCarriesPlacementMetadata(): void - { - /** @Given an identity for the placed order */ - $orderId = new OrderId(value: 'ord-4'); - - /** @And a placed order emitting an OrderPlaced event */ - $order = Order::place(orderId: $orderId, item: 'chair'); - - /** @When inspecting the first recorded record */ - $record = $order->recordedEvents()->first(); - - /** @Then the envelope carries the placement metadata */ - self::assertSame('OrderPlaced', $record->type->value); - self::assertSame(1, $record->revision->value); - self::assertSame(1, $record->sequenceNumber->value); - self::assertSame('Order', $record->aggregateType); - self::assertInstanceOf(OrderPlaced::class, $record->event); - self::assertSame($orderId, $record->identity); - self::assertSame('chair', $record->event->item); - } - - public function testSecondRecordedEventCarriesShippingMetadata(): void - { - /** @Given a placed order */ - $order = Order::place(orderId: new OrderId(value: 'ord-4b'), item: 'chair'); - - /** @And a shipping event emitted after placement */ - $order->ship(carrier: 'UPS'); - - /** @When inspecting the last recorded record */ - $record = $order->recordedEvents()->last(); - - /** @Then the envelope carries the shipping metadata */ - self::assertSame('OrderShipped', $record->type->value); - self::assertSame(2, $record->sequenceNumber->value); - self::assertInstanceOf(OrderShipped::class, $record->event); - self::assertSame('UPS', $record->event->carrier); - } - - public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void - { - /** @Given an order with one recorded event */ - $order = Order::place(orderId: new OrderId(value: 'ord-6'), item: 'mug'); - - /** @And an external mutation applied to the first retrieved copy */ - $order->recordedEvents()->add($order->recordedEvents()->first()); - - /** @When retrieving the recorded events again */ - $secondCopy = $order->recordedEvents(); - - /** @Then the aggregate's own buffer is unaffected by the external mutation */ - self::assertSame(1, $secondCopy->count()); - } - - public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void - { - /** @Given a placed order whose events are still buffered */ - $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle'); - - /** @And the buffer drained without clearing, simulating a save that reads but does not reset */ - $firstBatch = $order->recordedEvents(); - - /** @When a second operation emits a further event on the same instance */ - $order->ship(carrier: 'DHL'); - - /** @Then the buffer accumulates events from both operations */ - self::assertSame(2, $order->recordedEvents()->count()); - self::assertSame(1, $firstBatch->count()); - } - - public function testSnapshotDataCapturesDomainStateOnEveryEvent(): void - { - /** @Given an order that transitioned to 'placed' */ - $order = Order::place(orderId: new OrderId(value: 'ord-9'), item: 'tray'); - - /** @When inspecting the snapshot data carried by the event record */ - $state = $order->recordedEvents()->first()->snapshotData->toArray(); - - /** @Then the domain status field is captured with its current value */ - self::assertSame('placed', $state['status']); - } - - public function testSnapshotDataOmitsTransientRecordedEventsBuffer(): void - { - /** @Given an order that emits a placement event */ - $order = Order::place(orderId: new OrderId(value: 'ord-10'), item: 'tray'); - - /** @When inspecting the persistable state attached to the record */ - $state = $order->recordedEvents()->first()->snapshotData->toArray(); - - /** @Then the recording buffer is not part of the persisted state */ - self::assertArrayNotHasKey('recordedEvents', $state); - } - - public function testSnapshotDataOmitsSequenceNumber(): void - { - /** @Given an order that emits a placement event */ - $order = Order::place(orderId: new OrderId(value: 'ord-11'), item: 'mug'); - - /** @When inspecting the persistable state attached to the record */ - $state = $order->recordedEvents()->first()->snapshotData->toArray(); - - /** @Then the sequence number is not duplicated in the snapshot payload */ - self::assertArrayNotHasKey('sequenceNumber', $state); - } - - public function testSnapshotDataContainsAllDomainFields(): void - { - /** @Given a placed order */ - $order = Order::place(orderId: new OrderId(value: 'ord-12'), item: 'desk'); - - /** @When reading the snapshot payload from the first event record */ - $state = $order->recordedEvents()->first()->snapshotData->toArray(); - - /** @Then all domain fields are present in the payload */ - self::assertArrayHasKey('id', $state); - self::assertArrayHasKey('status', $state); - } -} diff --git a/tests/Aggregate/ModelVersionTest.php b/tests/Aggregate/ModelVersionTest.php deleted file mode 100644 index 7ae91ad..0000000 --- a/tests/Aggregate/ModelVersionTest.php +++ /dev/null @@ -1,95 +0,0 @@ -value); - } - - public function testOfReturnsVersionWithGivenValue(): void - { - /** @Given a valid model version value */ - /** @When requesting a model version of that value */ - $version = ModelVersion::of(value: 2); - - /** @Then the value matches */ - self::assertSame(2, $version->value); - } - - public function testEqualsReturnsTrueForSameValue(): void - { - /** @Given two model versions with the same value */ - $first = ModelVersion::of(value: 3); - - /** @And a matching counterpart */ - $second = ModelVersion::of(value: 3); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are equal */ - self::assertTrue($areEqual); - } - - public function testEqualsReturnsFalseForDifferentValues(): void - { - /** @Given two model versions with different values */ - $first = ModelVersion::of(value: 1); - - /** @And a distinct counterpart */ - $second = ModelVersion::of(value: 2); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are not equal */ - self::assertFalse($areEqual); - } - - public function testOfRejectsNegativeValue(): void - { - /** @Given a negative model version value */ - /** @Then an InvalidModelVersion exception is thrown */ - $this->expectException(InvalidModelVersion::class); - $this->expectExceptionMessage('-1'); - - /** @When constructing with a negative value */ - ModelVersion::of(value: -1); - } - - public function testInvalidModelVersionIsCatchableAsInvalidArgumentException(): void - { - /** @Given consumer code catching the PHP-standard InvalidArgumentException */ - /** @Then InvalidModelVersion is caught by the standard exception type */ - $this->expectException(InvalidArgumentException::class); - - /** @When constructing with a negative value */ - ModelVersion::of(value: -1); - } - - public function testInvalidModelVersionMessageMentionsTheMinimumAllowed(): void - { - /** @Given a consumer inspecting the exception message */ - /** @Then the message mentions the minimum allowed value */ - $this->expectException(InvalidModelVersion::class); - $this->expectExceptionMessage('greater than or equal to 0'); - - /** @When constructing with a negative value */ - ModelVersion::of(value: -1); - } -} diff --git a/tests/Entity/CompoundIdentityBehaviorTest.php b/tests/Entity/CompoundIdentityBehaviorTest.php deleted file mode 100644 index 517bc2e..0000000 --- a/tests/Entity/CompoundIdentityBehaviorTest.php +++ /dev/null @@ -1,68 +0,0 @@ -identityValue(); - - /** @Then both fields are returned in an associative array */ - self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value); - } - - public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void - { - /** @Given two compound identities with identical field values */ - $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); - - /** @And a matching counterpart */ - $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are considered equal */ - self::assertTrue($areEqual); - } - - public function testEqualsReturnsFalseWhenTenantDiffers(): void - { - /** @Given two compound identities differing on the tenant */ - $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); - - /** @And a counterpart with a different tenant */ - $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1'); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are not equal */ - self::assertFalse($areEqual); - } - - public function testEqualsReturnsFalseWhenAppointmentDiffers(): void - { - /** @Given two compound identities differing on the appointment */ - $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); - - /** @And a counterpart with a different appointment */ - $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2'); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are not equal */ - self::assertFalse($areEqual); - } -} diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php deleted file mode 100644 index 75b30de..0000000 --- a/tests/Event/SequenceNumberTest.php +++ /dev/null @@ -1,198 +0,0 @@ -isPrivate()); - } - - public function testInitialYieldsZero(): void - { - /** @Given the initial-sequence factory */ - /** @When requesting the initial sequence number */ - $sequenceNumber = SequenceNumber::initial(); - - /** @Then the value is zero */ - self::assertSame(0, $sequenceNumber->value); - } - - public function testFirstYieldsOne(): void - { - /** @Given the first-sequence factory */ - /** @When requesting the first sequence number */ - $sequenceNumber = SequenceNumber::first(); - - /** @Then the value is one */ - self::assertSame(1, $sequenceNumber->value); - } - - public function testOfReturnsSequenceNumberWithGivenValue(): void - { - /** @Given a valid sequence number value */ - /** @When requesting a sequence number of that value */ - $sequenceNumber = SequenceNumber::of(value: 5); - - /** @Then the value matches */ - self::assertSame(5, $sequenceNumber->value); - } - - public function testNextYieldsTheFollowingValue(): void - { - /** @Given a sequence number of 5 */ - $sequenceNumber = SequenceNumber::of(value: 5); - - /** @When advancing to the next */ - $next = $sequenceNumber->next(); - - /** @Then the value is 6 */ - self::assertSame(6, $next->value); - } - - public function testNextDoesNotMutateTheSource(): void - { - /** @Given a sequence number of 5 */ - $sequenceNumber = SequenceNumber::of(value: 5); - - /** @When advancing */ - $sequenceNumber->next(); - - /** @Then the original is unchanged */ - self::assertSame(5, $sequenceNumber->value); - } - - public function testIsAfterReturnsTrueWhenStrictlyGreater(): void - { - /** @Given a larger sequence number */ - $larger = SequenceNumber::of(value: 10); - - /** @And a smaller counterpart */ - $smaller = SequenceNumber::of(value: 5); - - /** @When checking if the larger is after the smaller */ - $isAfter = $larger->isAfter(other: $smaller); - - /** @Then the result is true */ - self::assertTrue($isAfter); - } - - public function testIsAfterReturnsFalseWhenEqual(): void - { - /** @Given two equal sequence numbers */ - $first = SequenceNumber::of(value: 3); - - /** @And a counterpart with the same value */ - $second = SequenceNumber::of(value: 3); - - /** @When checking if one is strictly after the other */ - $isAfter = $first->isAfter(other: $second); - - /** @Then the result is false */ - self::assertFalse($isAfter); - } - - public function testIsAfterReturnsFalseWhenStrictlySmaller(): void - { - /** @Given a smaller sequence number */ - $smaller = SequenceNumber::of(value: 2); - - /** @And a larger counterpart */ - $larger = SequenceNumber::of(value: 8); - - /** @When checking if the smaller is after the larger */ - $isAfter = $smaller->isAfter(other: $larger); - - /** @Then the result is false */ - self::assertFalse($isAfter); - } - - public function testEqualsReturnsTrueForSameValue(): void - { - /** @Given two sequence numbers with the same value */ - $first = SequenceNumber::of(value: 7); - - /** @And a matching counterpart */ - $second = SequenceNumber::of(value: 7); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are equal */ - self::assertTrue($areEqual); - } - - public function testEqualsReturnsFalseForDifferentValues(): void - { - /** @Given two sequence numbers with different values */ - $first = SequenceNumber::of(value: 1); - - /** @And a distinct counterpart */ - $second = SequenceNumber::of(value: 2); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are not equal */ - self::assertFalse($areEqual); - } - - #[DataProvider('negativeValues')] - 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); - - /** @When constructing with a negative value */ - SequenceNumber::of(value: $negativeValue); - } - - public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException(): void - { - /** @Given consumer code catching the PHP-standard InvalidArgumentException */ - /** @Then InvalidSequenceNumber is caught by the standard exception type */ - $this->expectException(InvalidArgumentException::class); - - /** @When constructing with a negative value */ - SequenceNumber::of(value: -1); - } - - public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): void - { - /** @Given a consumer inspecting the exception message */ - /** @Then the message mentions the minimum allowed value */ - $this->expectException(InvalidSequenceNumber::class); - $this->expectExceptionMessage('greater than or equal to 0'); - - /** @When constructing with a negative value */ - SequenceNumber::of(value: -1); - } - - /** - * @return array - */ - public static function negativeValues(): array - { - return [ - 'minus one' => [-1], - 'minus ten' => [-10] - ]; - } -} diff --git a/tests/Models/AppointmentSlot.php b/tests/Models/AppointmentSlot.php new file mode 100644 index 0000000..528e731 --- /dev/null +++ b/tests/Models/AppointmentSlot.php @@ -0,0 +1,18 @@ + */ private array $productIds = []; public static function withProducts(CartId $cartId, int $count): Cart @@ -39,9 +38,6 @@ public function applySnapshot(Snapshot $snapshot): void $this->productIds = $state['productIds'] ?? []; } - /** - * @return list - */ public function productIds(): array { return $this->productIds; diff --git a/tests/Models/CartWithLogger.php b/tests/Models/CartWithLogger.php index 242ece4..dbcb2fa 100644 --- a/tests/Models/CartWithLogger.php +++ b/tests/Models/CartWithLogger.php @@ -16,12 +16,11 @@ final class CartWithLogger implements EventSourcingRoot private string $logBuffer = ''; - /** @var list */ private array $productIds = []; public function addProduct(string $productId): void { - $this->logBuffer .= "Added: {$productId}"; + $this->logBuffer .= "Added: $productId"; $this->when(event: new ProductAdded(productId: $productId)); } @@ -33,19 +32,11 @@ public function applySnapshot(Snapshot $snapshot): void public function snapshotState(): array { $state = get_object_vars($this); - unset($state['recordedEvents'], $state['sequenceNumber'], $state['logBuffer']); + unset($state['recordedEvents'], $state['aggregateVersion'], $state['logBuffer']); return $state; } - /** - * @return list - */ - public function productIds(): array - { - return $this->productIds; - } - protected function identityProperty(): string { return 'cartId'; diff --git a/tests/Models/ExplicitCart.php b/tests/Models/ExplicitCart.php index 14352e1..ddc9702 100644 --- a/tests/Models/ExplicitCart.php +++ b/tests/Models/ExplicitCart.php @@ -14,7 +14,6 @@ final class ExplicitCart implements EventSourcingRoot private CartId $cartId; - /** @var list */ private array $productIds = []; public function addProduct(string $productId): void @@ -35,7 +34,7 @@ public function applySnapshot(Snapshot $snapshot): void public function eventHandlers(): array { return [ - ProductAdded::class => function (ProductAdded $event): void { + ProductAdded::class => function (ProductAdded $event): void { $this->productIds[] = $event->productId; }, ProductAddedV2::class => function (ProductAddedV2 $event): void { @@ -44,9 +43,6 @@ public function eventHandlers(): array ]; } - /** - * @return list - */ public function productIds(): array { return $this->productIds; diff --git a/tests/Models/Order.php b/tests/Models/Order.php index 45798ee..97ecee4 100644 --- a/tests/Models/Order.php +++ b/tests/Models/Order.php @@ -4,9 +4,11 @@ namespace Test\TinyBlocks\BuildingBlocks\Models; +use InvalidArgumentException; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRoot; use TinyBlocks\BuildingBlocks\Aggregate\EventualAggregateRootBehavior; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; +use TinyBlocks\BuildingBlocks\Entity\Identity; final class Order implements EventualAggregateRoot { @@ -14,14 +16,24 @@ final class Order implements EventualAggregateRoot private string $status = 'draft'; - private function __construct(private OrderId $id) + private function __construct(private readonly OrderId $id) { } - public static function reconstitute(OrderId $orderId, SequenceNumber $sequenceNumber): Order - { - $order = new Order(id: $orderId); - $order->reconstituteSequenceNumber(sequenceNumber: $sequenceNumber); + public static function reconstitute( + Identity $identity, + AggregateVersion $aggregateVersion, + array $state = [] + ): static { + if (!$identity instanceof OrderId) { + $template = 'Expected identity of type <%s>, got <%s>.'; + + throw new InvalidArgumentException(message: sprintf($template, OrderId::class, $identity::class)); + } + + $order = new Order(id: $identity); + $order->aggregateVersion = $aggregateVersion; + return $order; } diff --git a/tests/Models/Reservation.php b/tests/Models/Reservation.php new file mode 100644 index 0000000..85d7933 --- /dev/null +++ b/tests/Models/Reservation.php @@ -0,0 +1,41 @@ +status = $status; + } + + public static function book(ReservationId $id): Reservation + { + $reservation = new Reservation(id: $id, status: 'pending'); + $reservation->push(event: new ReservationBooked()); + + return $reservation; + } + + public function confirm(): void + { + if ($this->status !== 'pending') { + $template = 'Cannot confirm reservation in status <%s>.'; + + throw new RuntimeException(message: sprintf($template, $this->status)); + } + + $this->status = 'confirmed'; + $this->push(event: new ReservationConfirmed()); + } +} diff --git a/tests/Models/ReservationBooked.php b/tests/Models/ReservationBooked.php new file mode 100644 index 0000000..b125d0d --- /dev/null +++ b/tests/Models/ReservationBooked.php @@ -0,0 +1,13 @@ + 'placed', 'amount' => 100]); - - /** @When converting to array */ - $payload = $snapshotData->toArray(); - - /** @Then the original data is returned */ - self::assertSame(['status' => 'placed', 'amount' => 100], $payload); - } - - public function testToArrayReturnsSameReferenceForNestedPayload(): void - { - /** @Given snapshot data with a nested payload */ - $snapshotData = new SnapshotData(payload: ['order' => ['item' => 'book', 'qty' => 2]]); - - /** @When converting to array */ - $payload = $snapshotData->toArray(); - - /** @Then the nested structure is preserved exactly */ - self::assertSame(['order' => ['item' => 'book', 'qty' => 2]], $payload); - } - - public function testEqualsReturnsTrueForIdenticalPayloads(): void - { - /** @Given two snapshot data instances with identical payloads */ - $first = new SnapshotData(payload: ['status' => 'placed']); - - /** @And a matching counterpart */ - $second = new SnapshotData(payload: ['status' => 'placed']); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are equal */ - self::assertTrue($areEqual); - } - - public function testEqualsReturnsFalseForDifferentPayloads(): void - { - /** @Given two snapshot data instances with different payloads */ - $first = new SnapshotData(payload: ['status' => 'placed']); - - /** @And a distinct counterpart */ - $second = new SnapshotData(payload: ['status' => 'shipped']); - - /** @When comparing them */ - $areEqual = $first->equals(other: $second); - - /** @Then they are not equal */ - self::assertFalse($areEqual); - } -} diff --git a/tests/Aggregate/AggregateRootBehaviorTest.php b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php similarity index 52% rename from tests/Aggregate/AggregateRootBehaviorTest.php rename to tests/Unit/Aggregate/AggregateRootBehaviorTest.php index 24d681b..0e75b9b 100644 --- a/tests/Aggregate/AggregateRootBehaviorTest.php +++ b/tests/Unit/Aggregate/AggregateRootBehaviorTest.php @@ -2,27 +2,27 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Aggregate; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Aggregate; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; use Test\TinyBlocks\BuildingBlocks\Models\Order; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; final class AggregateRootBehaviorTest extends TestCase { - public function testSequenceNumberIsZeroForBlankAggregate(): void + public function testAggregateVersionIsZeroForBlankAggregate(): void { /** @Given a freshly instantiated aggregate with no events */ $cart = Cart::blank(identity: new CartId(value: 'cart-1')); - /** @When retrieving the sequence number */ - $sequenceNumber = $cart->sequenceNumber(); + /** @When retrieving the aggregate version */ + $aggregateVersion = $cart->aggregateVersion(); /** @Then it is zero */ - self::assertSame(0, $sequenceNumber->value); + self::assertSame(0, $aggregateVersion->value); } public function testModelVersionReflectsDeclaredValue(): void @@ -49,51 +49,57 @@ public function testModelVersionDefaultsToZeroWhenUndefined(): void self::assertSame(0, $version->value); } - public function testAggregateNameForEventSourcedAggregate(): void + public function testAggregateTypeForEventSourcedAggregate(): void { /** @Given a Cart aggregate */ $cart = Cart::blank(identity: new CartId(value: 'cart-3')); - /** @When retrieving the aggregate name */ - $name = $cart->aggregateName(); + /** @When retrieving the aggregate type */ + $aggregateType = $cart->aggregateType(); /** @Then it matches the short class name */ - self::assertSame('Cart', $name); + self::assertSame('Cart', $aggregateType); } - public function testAggregateNameForOutboxAggregate(): void + public function testAggregateTypeForOutboxAggregate(): void { /** @Given an Order aggregate */ $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'lamp'); - /** @When retrieving the aggregate name */ - $name = $order->aggregateName(); + /** @When retrieving the aggregate type */ + $aggregateType = $order->aggregateType(); /** @Then it matches the short class name */ - self::assertSame('Order', $name); + self::assertSame('Order', $aggregateType); } - public function testReconstitutedSequenceNumberMatchesPersistedValue(): void + public function testReconstitutedAggregateVersionMatchesPersistedValue(): void { - /** @Given an Order reconstituted with a persisted sequence number of 5 */ - $order = Order::reconstitute(orderId: new OrderId(value: 'ord-3'), sequenceNumber: SequenceNumber::of(value: 5)); + /** @Given an Order reconstituted with a persisted aggregate version of 5 */ + $order = Order::reconstitute( + identity: new OrderId(value: 'ord-3'), + aggregateVersion: AggregateVersion::of(value: 5) + ); - /** @When retrieving the sequence number */ - $sequenceNumber = $order->sequenceNumber(); + /** @When retrieving the aggregate version */ + $aggregateVersion = $order->aggregateVersion(); /** @Then it matches the persisted value */ - self::assertSame(5, $sequenceNumber->value); + self::assertSame(5, $aggregateVersion->value); } - public function testPushAfterReconstituteAdvancesSequenceByOne(): void + public function testPushAfterReconstituteAdvancesVersionByOne(): void { - /** @Given an Order reconstituted with a persisted sequence number of 5 */ - $order = Order::reconstitute(orderId: new OrderId(value: 'ord-4'), sequenceNumber: SequenceNumber::of(value: 5)); + /** @Given an Order reconstituted with a persisted aggregate version of 5 */ + $order = Order::reconstitute( + identity: new OrderId(value: 'ord-4'), + aggregateVersion: AggregateVersion::of(value: 5) + ); /** @When pushing a new event */ $order->ship(carrier: 'FedEx'); - /** @Then the sequence number advances by one */ - self::assertSame(6, $order->sequenceNumber()->value); + /** @Then the aggregate version advances by one */ + self::assertSame(6, $order->aggregateVersion()->value); } } diff --git a/tests/Unit/Aggregate/AggregateVersionTest.php b/tests/Unit/Aggregate/AggregateVersionTest.php new file mode 100644 index 0000000..b34a884 --- /dev/null +++ b/tests/Unit/Aggregate/AggregateVersionTest.php @@ -0,0 +1,234 @@ +isPrivate()); + } + + public function testInitialYieldsZero(): void + { + /** @When requesting the initial aggregate version */ + $aggregateVersion = AggregateVersion::initial(); + + /** @Then the value is zero */ + self::assertSame(0, $aggregateVersion->value); + } + + public function testFirstYieldsOne(): void + { + /** @When requesting the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); + + /** @Then the value is one */ + self::assertSame(1, $aggregateVersion->value); + } + + public function testOfReturnsAggregateVersionWithGivenValue(): void + { + /** @When requesting an aggregate version of that value */ + $aggregateVersion = AggregateVersion::of(value: 5); + + /** @Then the value matches */ + self::assertSame(5, $aggregateVersion->value); + } + + public function testNextYieldsTheFollowingValue(): void + { + /** @Given an aggregate version of 5 */ + $aggregateVersion = AggregateVersion::of(value: 5); + + /** @When advancing to the next */ + $next = $aggregateVersion->next(); + + /** @Then the value is 6 */ + self::assertSame(6, $next->value); + } + + public function testNextDoesNotMutateTheSource(): void + { + /** @Given an aggregate version of 5 */ + $aggregateVersion = AggregateVersion::of(value: 5); + + /** @When advancing */ + $aggregateVersion->next(); + + /** @Then the original is unchanged */ + self::assertSame(5, $aggregateVersion->value); + } + + public function testIsAfterReturnsTrueWhenStrictlyGreater(): void + { + /** @Given a larger aggregate version */ + $larger = AggregateVersion::of(value: 10); + + /** @And a smaller counterpart */ + $smaller = AggregateVersion::of(value: 5); + + /** @When checking if the larger is after the smaller */ + $isAfter = $larger->isAfter(other: $smaller); + + /** @Then the result is true */ + self::assertTrue($isAfter); + } + + public function testIsAfterReturnsFalseWhenEqual(): void + { + /** @Given two equal aggregate versions */ + $first = AggregateVersion::of(value: 3); + + /** @And a counterpart with the same value */ + $second = AggregateVersion::of(value: 3); + + /** @When checking if one is strictly after the other */ + $isAfter = $first->isAfter(other: $second); + + /** @Then the result is false */ + self::assertFalse($isAfter); + } + + public function testIsAfterReturnsFalseWhenStrictlySmaller(): void + { + /** @Given a smaller aggregate version */ + $smaller = AggregateVersion::of(value: 2); + + /** @And a larger counterpart */ + $larger = AggregateVersion::of(value: 8); + + /** @When checking if the smaller is after the larger */ + $isAfter = $smaller->isAfter(other: $larger); + + /** @Then the result is false */ + self::assertFalse($isAfter); + } + + public function testIsBeforeReturnsTrueWhenValueIsLess(): void + { + /** @Given a smaller aggregate version */ + $smaller = AggregateVersion::of(value: 4); + + /** @And a larger counterpart */ + $larger = AggregateVersion::of(value: 9); + + /** @When checking if the smaller is before the larger */ + $isBefore = $smaller->isBefore(other: $larger); + + /** @Then the result is true */ + self::assertTrue($isBefore); + } + + public function testIsBeforeReturnsFalseWhenValuesAreEqual(): void + { + /** @Given two equal aggregate versions */ + $first = AggregateVersion::of(value: 6); + + /** @And a counterpart with the same value */ + $second = AggregateVersion::of(value: 6); + + /** @When checking if one is strictly before the other */ + $isBefore = $first->isBefore(other: $second); + + /** @Then the result is false */ + self::assertFalse($isBefore); + } + + public function testIsBeforeReturnsFalseWhenValueIsGreater(): void + { + /** @Given a larger aggregate version */ + $larger = AggregateVersion::of(value: 12); + + /** @And a smaller counterpart */ + $smaller = AggregateVersion::of(value: 3); + + /** @When checking if the larger is before the smaller */ + $isBefore = $larger->isBefore(other: $smaller); + + /** @Then the result is false */ + self::assertFalse($isBefore); + } + + public function testEqualsReturnsTrueForSameValue(): void + { + /** @Given two aggregate versions with the same value */ + $first = AggregateVersion::of(value: 7); + + /** @And a matching counterpart */ + $second = AggregateVersion::of(value: 7); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($areEqual); + } + + public function testEqualsReturnsFalseForDifferentValues(): void + { + /** @Given two aggregate versions with different values */ + $first = AggregateVersion::of(value: 1); + + /** @And a distinct counterpart */ + $second = AggregateVersion::of(value: 2); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + #[DataProvider('negativeValues')] + public function testOfRejectsNegativeValue(int $negativeValue): void + { + /** @Then an InvalidAggregateVersion exception carrying the invalid value is thrown */ + $this->expectException(InvalidAggregateVersion::class); + $this->expectExceptionMessage((string)$negativeValue); + + /** @When constructing with a negative value */ + AggregateVersion::of(value: $negativeValue); + } + + public function testInvalidAggregateVersionIsCatchableAsInvalidArgumentException(): void + { + /** @Then InvalidAggregateVersion is caught by the standard exception type */ + $this->expectException(InvalidArgumentException::class); + + /** @When constructing with a negative value */ + AggregateVersion::of(value: -1); + } + + public function testInvalidAggregateVersionMessageMentionsTheMinimumAllowed(): void + { + /** @Then the message mentions the minimum allowed value */ + $this->expectException(InvalidAggregateVersion::class); + $this->expectExceptionMessage('greater than or equal to 0'); + + /** @When constructing with a negative value */ + AggregateVersion::of(value: -1); + } + + public static function negativeValues(): array + { + return [ + 'minus one' => [-1], + 'minus ten' => [-10] + ]; + } +} diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php similarity index 88% rename from tests/Aggregate/EventSourcingRootBehaviorTest.php rename to tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php index 9e7479e..d8be17a 100644 --- a/tests/Aggregate/EventSourcingRootBehaviorTest.php +++ b/tests/Unit/Aggregate/EventSourcingRootBehaviorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Aggregate; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Aggregate; use LogicException; use PHPUnit\Framework\TestCase; @@ -18,7 +18,7 @@ final class EventSourcingRootBehaviorTest extends TestCase { - public function testBlankAggregateStartsWithInitialSequenceNumber(): void + public function testBlankAggregateStartsWithInitialAggregateVersion(): void { /** @Given a cart identity */ $cartId = new CartId(value: 'cart-1'); @@ -26,8 +26,8 @@ public function testBlankAggregateStartsWithInitialSequenceNumber(): void /** @When creating a blank cart */ $cart = Cart::blank(identity: $cartId); - /** @Then the aggregate starts at sequence number zero */ - self::assertSame(0, $cart->sequenceNumber()->value); + /** @Then the aggregate starts at aggregate version zero */ + self::assertSame(0, $cart->aggregateVersion()->value); } public function testBlankAggregateStartsWithEmptyDomainState(): void @@ -78,7 +78,7 @@ public function testDomainOperationAppliesStateFromEmittedEvent(): void self::assertSame(['prod-1'], $cart->productIds()); } - public function testDomainOperationAdvancesSequenceNumber(): void + public function testDomainOperationAdvancesAggregateVersion(): void { /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-3')); @@ -89,8 +89,8 @@ public function testDomainOperationAdvancesSequenceNumber(): void /** @And adding a second product */ $cart->addProduct(productId: 'prod-2'); - /** @Then the sequence number equals the number of events */ - self::assertSame(2, $cart->sequenceNumber()->value); + /** @Then the aggregate version equals the number of events */ + self::assertSame(2, $cart->aggregateVersion()->value); } public function testDomainOperationAppendsToRecordedEvents(): void @@ -120,12 +120,12 @@ public function testFirstRecordedEventCarriesEnvelopeMetadata(): void $record = $cart->recordedEvents()->first(); /** @Then the envelope carries the expected metadata */ - self::assertSame('ProductAdded', $record->type->value); + self::assertSame('ProductAdded', $record->eventType->value); self::assertSame(1, $record->revision->value); - self::assertSame(1, $record->sequenceNumber->value); + self::assertSame(1, $record->aggregateVersion->value); self::assertSame('Cart', $record->aggregateType); self::assertInstanceOf(ProductAdded::class, $record->event); - self::assertSame($cartId, $record->identity); + self::assertSame($cartId, $record->aggregateId); self::assertSame('prod-abc', $record->event->productId); } @@ -168,7 +168,7 @@ public function testReconstitutePreservesEventOrderForDistinctivelyOrderedStream self::assertSame(['zebra', 'apple', 'mango'], $reconstituted->productIds()); } - public function testReconstituteAdvancesSequenceNumberToLastEvent(): void + public function testReconstituteAdvancesAggregateVersionToLastEvent(): void { /** @Given a cart identity */ $cartId = new CartId(value: 'cart-6c'); @@ -179,8 +179,8 @@ public function testReconstituteAdvancesSequenceNumberToLastEvent(): void /** @When reconstituting from the event stream */ $reconstituted = Cart::reconstitute(identity: $cartId, records: $original->recordedEvents()); - /** @Then the sequence number equals the last event's */ - self::assertSame(2, $reconstituted->sequenceNumber()->value); + /** @Then the aggregate version equals the last event's */ + self::assertSame(2, $reconstituted->aggregateVersion()->value); } public function testReconstituteWithEmptyStreamYieldsBlankState(): void @@ -195,7 +195,7 @@ public function testReconstituteWithEmptyStreamYieldsBlankState(): void self::assertSame([], $reconstituted->productIds()); } - public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): void + public function testReconstituteWithEmptyStreamYieldsInitialAggregateVersion(): void { /** @Given a cart identity and no events */ $cartId = new CartId(value: 'cart-7b'); @@ -203,8 +203,8 @@ public function testReconstituteWithEmptyStreamYieldsInitialSequenceNumber(): vo /** @When reconstituting with no events */ $reconstituted = Cart::reconstitute(identity: $cartId, records: []); - /** @Then the sequence number remains at the initial value */ - self::assertSame(0, $reconstituted->sequenceNumber()->value); + /** @Then the aggregate version remains at the initial value */ + self::assertSame(0, $reconstituted->aggregateVersion()->value); } public function testReconstituteFromSnapshotRestoresDomainState(): void @@ -228,7 +228,7 @@ public function testReconstituteFromSnapshotRestoresDomainState(): void self::assertSame(['prod-snapshot'], $reconstituted->productIds()); } - public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): void + public function testReconstituteFromSnapshotAppliesTheSnapshotAggregateVersion(): void { /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8b'); @@ -245,8 +245,8 @@ public function testReconstituteFromSnapshotAppliesTheSnapshotSequenceNumber(): /** @When reconstituting from the snapshot only */ $reconstituted = Cart::reconstitute(identity: $cartId, records: [], snapshot: $snapshot); - /** @Then the sequence number matches the snapshot's */ - self::assertSame(1, $reconstituted->sequenceNumber()->value); + /** @Then the aggregate version matches the snapshot's */ + self::assertSame(1, $reconstituted->aggregateVersion()->value); } public function testReconstituteCombinesSnapshotWithLaterEvents(): void @@ -268,8 +268,8 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void /** @And the records after the snapshot filtered out */ $laterRecords = $cart->recordedEvents()->filter( - predicates: static fn($record): bool => $record->sequenceNumber->isAfter( - other: $snapshot->sequenceNumber() + predicates: static fn($record): bool => $record->aggregateVersion->isAfter( + other: $snapshot->aggregateVersion() ) ); @@ -280,7 +280,7 @@ public function testReconstituteCombinesSnapshotWithLaterEvents(): void self::assertSame(['prod-1', 'prod-2'], $reconstituted->productIds()); } - public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequence(): void + public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesAggregateVersion(): void { /** @Given a cart identity */ $cartId = new CartId(value: 'cart-8d'); @@ -299,16 +299,16 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen /** @And the records after the snapshot filtered out */ $laterRecords = $cart->recordedEvents()->filter( - predicates: static fn($record): bool => $record->sequenceNumber->isAfter( - other: $snapshot->sequenceNumber() + predicates: static fn($record): bool => $record->aggregateVersion->isAfter( + other: $snapshot->aggregateVersion() ) ); /** @When reconstituting from the snapshot and the later records */ $reconstituted = Cart::reconstitute(identity: $cartId, records: $laterRecords, snapshot: $snapshot); - /** @Then the sequence number reflects the last applied event */ - self::assertSame(2, $reconstituted->sequenceNumber()->value); + /** @Then the aggregate version reflects the last applied event */ + self::assertSame(2, $reconstituted->aggregateVersion()->value); } public function testReconstitutedAggregateHasNoRecordedEvents(): void diff --git a/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php new file mode 100644 index 0000000..a37daf4 --- /dev/null +++ b/tests/Unit/Aggregate/EventualAggregateRootBehaviorTest.php @@ -0,0 +1,224 @@ +aggregateVersion(); + + /** @Then the aggregate version is 1 */ + self::assertSame(1, $aggregateVersion->value); + } + + public function testAggregateVersionAdvancesOnEverySubsequentEvent(): void + { + /** @Given a placed order */ + $order = Order::place(orderId: new OrderId(value: 'ord-2'), item: 'pen'); + + /** @And a shipping event emitted after placement */ + $order->ship(carrier: 'DHL'); + + /** @When retrieving the aggregate version */ + $aggregateVersion = $order->aggregateVersion(); + + /** @Then the aggregate version reflects every emitted event */ + self::assertSame(2, $aggregateVersion->value); + } + + public function testRecordedEventsCountMatchesEmittedEvents(): void + { + /** @Given a placed order */ + $order = Order::place(orderId: new OrderId(value: 'ord-3'), item: 'lamp'); + + /** @And a shipping event emitted after placement */ + $order->ship(carrier: 'FedEx'); + + /** @When retrieving recorded events */ + $records = $order->recordedEvents(); + + /** @Then the count matches the number of events */ + self::assertSame(2, $records->count()); + } + + public function testFirstRecordedEventCarriesPlacementMetadata(): void + { + /** @Given an identity for the placed order */ + $orderId = new OrderId(value: 'ord-4'); + + /** @And a placed order emitting an OrderPlaced event */ + $order = Order::place(orderId: $orderId, item: 'chair'); + + /** @When inspecting the first recorded record */ + $record = $order->recordedEvents()->first(); + + /** @Then the envelope carries the placement metadata */ + self::assertSame('OrderPlaced', $record->eventType->value); + self::assertSame(1, $record->revision->value); + self::assertSame(1, $record->aggregateVersion->value); + self::assertSame('Order', $record->aggregateType); + self::assertInstanceOf(OrderPlaced::class, $record->event); + self::assertSame($orderId, $record->aggregateId); + self::assertSame('chair', $record->event->item); + } + + public function testSecondRecordedEventCarriesShippingMetadata(): void + { + /** @Given a placed order */ + $order = Order::place(orderId: new OrderId(value: 'ord-4b'), item: 'chair'); + + /** @And a shipping event emitted after placement */ + $order->ship(carrier: 'UPS'); + + /** @When inspecting the last recorded record */ + $record = $order->recordedEvents()->last(); + + /** @Then the envelope carries the shipping metadata */ + self::assertSame('OrderShipped', $record->eventType->value); + self::assertSame(2, $record->aggregateVersion->value); + self::assertInstanceOf(OrderShipped::class, $record->event); + self::assertSame('UPS', $record->event->carrier); + } + + public function testRecordedEventsReturnsIndependentCopyOnEachCall(): void + { + /** @Given an order with one recorded event */ + $order = Order::place(orderId: new OrderId(value: 'ord-6'), item: 'mug'); + + /** @And an external mutation applied to the first retrieved copy */ + $order->recordedEvents()->add($order->recordedEvents()->first()); + + /** @When retrieving the recorded events again */ + $secondCopy = $order->recordedEvents(); + + /** @Then the aggregate's own buffer is unaffected by the external mutation */ + self::assertSame(1, $secondCopy->count()); + } + + public function testBufferAccumulatesAcrossOperationsWithoutClearing(): void + { + /** @Given a placed order whose events are still buffered */ + $order = Order::place(orderId: new OrderId(value: 'ord-7'), item: 'bottle'); + + /** @And the buffer drained without clearing, simulating a save that reads but does not reset */ + $firstBatch = $order->recordedEvents(); + + /** @When a second operation emits a further event on the same instance */ + $order->ship(carrier: 'DHL'); + + /** @Then the buffer accumulates events from both operations */ + self::assertSame(2, $order->recordedEvents()->count()); + self::assertSame(1, $firstBatch->count()); + } + + public function testReconstituteRestoresIdentityWhenNoStateIsProvided(): void + { + /** @Given an identity to assign to the reconstituted aggregate */ + $reservationId = new ReservationId(value: 'res-1'); + + /** @When reconstituting via the trait default with no state */ + $reservation = Reservation::reconstitute( + identity: $reservationId, + aggregateVersion: AggregateVersion::of(value: 5) + ); + + /** @Then the identity is restored on the reconstituted aggregate */ + self::assertTrue($reservation->identity()->equals(other: $reservationId)); + } + + public function testReconstituteRestoresAggregateVersionForNextEvent(): void + { + /** @Given a reservation reconstituted at version 5 with pending status */ + $reservation = Reservation::reconstitute( + identity: new ReservationId(value: 'res-1'), + aggregateVersion: AggregateVersion::of(value: 5), + state: ['status' => 'pending'] + ); + + /** @When confirming the reservation */ + $reservation->confirm(); + + /** @Then the next recorded event carries version 6 */ + self::assertSame(6, $reservation->recordedEvents()->first()->aggregateVersion->value); + } + + public function testReconstituteHydratesStateSoCommandsBehaveCorrectly(): void + { + /** @Given a reservation reconstituted in confirmed status */ + $reservation = Reservation::reconstitute( + identity: new ReservationId(value: 'res-1'), + aggregateVersion: AggregateVersion::of(value: 5), + state: ['status' => 'confirmed'] + ); + + /** @Then a RuntimeException is raised because state was correctly restored */ + $this->expectException(RuntimeException::class); + + /** @When attempting to confirm an already confirmed reservation */ + $reservation->confirm(); + } + + public function testReconstituteSilentlyIgnoresUnknownStateKeys(): void + { + /** @Given an identity for the aggregate */ + $reservationId = new ReservationId(value: 'res-1'); + + /** @When reconstituting with a state map carrying a key absent from the aggregate */ + $reservation = Reservation::reconstitute( + identity: $reservationId, + aggregateVersion: AggregateVersion::of(value: 5), + state: ['status' => 'pending', 'unknownProperty' => 'value'] + ); + + /** @Then no exception is raised and the known identity is still restored */ + self::assertTrue($reservation->identity()->equals(other: $reservationId)); + } + + public function testOrderReconstituteRejectsForeignIdentityType(): void + { + /** @Given an identity belonging to a foreign aggregate type */ + $foreignIdentity = new ReservationId(value: 'res-1'); + + /** @Then an exception indicating the wrong identity type should be thrown */ + $this->expectException(InvalidArgumentException::class); + + /** @When reconstituting an Order with a non-OrderId identity */ + Order::reconstitute(identity: $foreignIdentity, aggregateVersion: AggregateVersion::of(value: 1)); + } + + public function testOrderReconstituteRestoresVersionForNextEvent(): void + { + /** @Given an Order reconstituted at version 9 */ + $order = Order::reconstitute( + identity: new OrderId(value: 'ord-rec-1'), + aggregateVersion: AggregateVersion::of(value: 9) + ); + + /** @And a shipping event emitted on the reconstituted instance */ + $order->ship(carrier: 'DHL'); + + /** @When inspecting the recorded event */ + $record = $order->recordedEvents()->first(); + + /** @Then the event carries version 10 */ + self::assertSame(10, $record->aggregateVersion->value); + } +} diff --git a/tests/Unit/Aggregate/ModelVersionTest.php b/tests/Unit/Aggregate/ModelVersionTest.php new file mode 100644 index 0000000..d10e859 --- /dev/null +++ b/tests/Unit/Aggregate/ModelVersionTest.php @@ -0,0 +1,180 @@ +value); + } + + public function testOfReturnsVersionWithGivenValue(): void + { + /** @When requesting a model version of that value */ + $version = ModelVersion::of(value: 2); + + /** @Then the value matches */ + self::assertSame(2, $version->value); + } + + public function testEqualsReturnsTrueForSameValue(): void + { + /** @Given two model versions with the same value */ + $first = ModelVersion::of(value: 3); + + /** @And a matching counterpart */ + $second = ModelVersion::of(value: 3); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are equal */ + self::assertTrue($areEqual); + } + + public function testEqualsReturnsFalseForDifferentValues(): void + { + /** @Given two model versions with different values */ + $first = ModelVersion::of(value: 1); + + /** @And a distinct counterpart */ + $second = ModelVersion::of(value: 2); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testIsAfterReturnsTrueWhenValueIsGreater(): void + { + /** @Given a larger model version */ + $larger = ModelVersion::of(value: 5); + + /** @And a smaller counterpart */ + $smaller = ModelVersion::of(value: 2); + + /** @When checking if the larger is after the smaller */ + $isAfter = $larger->isAfter(other: $smaller); + + /** @Then the result is true */ + self::assertTrue($isAfter); + } + + public function testIsAfterReturnsFalseWhenValuesAreEqual(): void + { + /** @Given two equal model versions */ + $first = ModelVersion::of(value: 4); + + /** @And a counterpart with the same value */ + $second = ModelVersion::of(value: 4); + + /** @When checking if one is strictly after the other */ + $isAfter = $first->isAfter(other: $second); + + /** @Then the result is false */ + self::assertFalse($isAfter); + } + + public function testIsAfterReturnsFalseWhenValueIsLess(): void + { + /** @Given a smaller model version */ + $smaller = ModelVersion::of(value: 1); + + /** @And a larger counterpart */ + $larger = ModelVersion::of(value: 8); + + /** @When checking if the smaller is after the larger */ + $isAfter = $smaller->isAfter(other: $larger); + + /** @Then the result is false */ + self::assertFalse($isAfter); + } + + public function testIsBeforeReturnsTrueWhenValueIsLess(): void + { + /** @Given a smaller model version */ + $smaller = ModelVersion::of(value: 3); + + /** @And a larger counterpart */ + $larger = ModelVersion::of(value: 7); + + /** @When checking if the smaller is before the larger */ + $isBefore = $smaller->isBefore(other: $larger); + + /** @Then the result is true */ + self::assertTrue($isBefore); + } + + public function testIsBeforeReturnsFalseWhenValuesAreEqual(): void + { + /** @Given two equal model versions */ + $first = ModelVersion::of(value: 9); + + /** @And a counterpart with the same value */ + $second = ModelVersion::of(value: 9); + + /** @When checking if one is strictly before the other */ + $isBefore = $first->isBefore(other: $second); + + /** @Then the result is false */ + self::assertFalse($isBefore); + } + + public function testIsBeforeReturnsFalseWhenValueIsGreater(): void + { + /** @Given a larger model version */ + $larger = ModelVersion::of(value: 10); + + /** @And a smaller counterpart */ + $smaller = ModelVersion::of(value: 2); + + /** @When checking if the larger is before the smaller */ + $isBefore = $larger->isBefore(other: $smaller); + + /** @Then the result is false */ + self::assertFalse($isBefore); + } + + public function testOfRejectsNegativeValue(): void + { + /** @Then an InvalidModelVersion exception is thrown */ + $this->expectException(InvalidModelVersion::class); + $this->expectExceptionMessage('-1'); + + /** @When constructing with a negative value */ + ModelVersion::of(value: -1); + } + + public function testInvalidModelVersionIsCatchableAsInvalidArgumentException(): void + { + /** @Then InvalidModelVersion is caught by the standard exception type */ + $this->expectException(InvalidArgumentException::class); + + /** @When constructing with a negative value */ + ModelVersion::of(value: -1); + } + + public function testInvalidModelVersionMessageMentionsTheMinimumAllowed(): void + { + /** @Then the message mentions the minimum allowed value */ + $this->expectException(InvalidModelVersion::class); + $this->expectExceptionMessage('greater than or equal to 0'); + + /** @When constructing with a negative value */ + ModelVersion::of(value: -1); + } +} diff --git a/tests/Unit/Entity/CompoundIdentityBehaviorTest.php b/tests/Unit/Entity/CompoundIdentityBehaviorTest.php new file mode 100644 index 0000000..60b9936 --- /dev/null +++ b/tests/Unit/Entity/CompoundIdentityBehaviorTest.php @@ -0,0 +1,148 @@ +identityValue(); + + /** @Then both fields are returned in an associative array */ + self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $identityValue); + } + + public function testEqualsReturnsTrueForIdenticalCompoundIdentities(): void + { + /** @Given two compound identities with identical field values */ + $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + + /** @And a matching counterpart */ + $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are considered equal */ + self::assertTrue($areEqual); + } + + public function testEqualsReturnsFalseWhenTenantDiffers(): void + { + /** @Given two compound identities differing on the tenant */ + $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + + /** @And a counterpart with a different tenant */ + $second = new AppointmentId(tenantId: 'tenant-2', appointmentId: 'apt-1'); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenAppointmentDiffers(): void + { + /** @Given two compound identities differing on the appointment */ + $first = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); + + /** @And a counterpart with a different appointment */ + $second = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-2'); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testIdentityValueReturnsFieldsPreservingScalarAndObjectTypes(): void + { + /** @Given a revision for the slot */ + $revision = Revision::of(value: 7); + + /** @And a compound identity with mixed scalar and object fields */ + $slot = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: $revision); + + /** @When retrieving the identity value */ + $identityValue = $slot->identityValue(); + + /** @Then each field is present preserving its original scalar and object types */ + self::assertSame( + ['tenantId' => 'tenant-1', 'practitionerId' => 42, 'revision' => $revision], + $identityValue + ); + } + + public function testEqualsReturnsTrueForCompoundIdentitiesWithIdenticalMixedFields(): void + { + /** @Given a compound identity with mixed types */ + $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @And a counterpart with identical field values */ + $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are considered equal */ + self::assertTrue($areEqual); + } + + public function testEqualsReturnsFalseWhenObjectFieldDiffers(): void + { + /** @Given a compound identity */ + $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @And a counterpart differing only on the object-typed field */ + $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 8)); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenIntegerFieldDiffers(): void + { + /** @Given a compound identity */ + $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @And a counterpart differing only on the integer field */ + $second = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 43, revision: Revision::of(value: 7)); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } + + public function testEqualsReturnsFalseWhenStringFieldDiffers(): void + { + /** @Given a compound identity */ + $first = new AppointmentSlot(tenantId: 'tenant-1', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @And a counterpart differing only on the string field */ + $second = new AppointmentSlot(tenantId: 'tenant-2', practitionerId: 42, revision: Revision::of(value: 7)); + + /** @When comparing them */ + $areEqual = $first->equals(other: $second); + + /** @Then they are not equal */ + self::assertFalse($areEqual); + } +} diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Unit/Entity/EntityBehaviorTest.php similarity index 94% rename from tests/Entity/EntityBehaviorTest.php rename to tests/Unit/Entity/EntityBehaviorTest.php index 3988b07..de52d5f 100644 --- a/tests/Entity/EntityBehaviorTest.php +++ b/tests/Unit/Entity/EntityBehaviorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Entity; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Entity; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\AppointmentId; @@ -11,7 +11,7 @@ use Test\TinyBlocks\BuildingBlocks\Models\Order; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderWithMissingIdentityProperty; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; +use TinyBlocks\BuildingBlocks\Exceptions\MissingIdentityProperty; final class EntityBehaviorTest extends TestCase { @@ -60,10 +60,10 @@ public function testIdentityValueReturnsScalarForSingleIdentity(): void $order = Order::place(orderId: new OrderId(value: 'ord-42'), item: 'pen'); /** @When retrieving the identity value */ - $value = $order->identityValue(); + $identityValue = $order->identityValue(); /** @Then the raw scalar is returned */ - self::assertSame('ord-42', $value); + self::assertSame('ord-42', $identityValue); } public function testIdentityValueReturnsAssociativeArrayForCompoundIdentity(): void @@ -72,10 +72,10 @@ public function testIdentityValueReturnsAssociativeArrayForCompoundIdentity(): v $appointmentId = new AppointmentId(tenantId: 'tenant-1', appointmentId: 'apt-1'); /** @When retrieving the identity value */ - $value = $appointmentId->identityValue(); + $identityValue = $appointmentId->identityValue(); /** @Then an associative array with all fields is returned */ - self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $value); + self::assertSame(['tenantId' => 'tenant-1', 'appointmentId' => 'apt-1'], $identityValue); } public function testSameIdentityOfReturnsTrueForAggregatesWithEqualIdentity(): void diff --git a/tests/Entity/SingleIdentityBehaviorTest.php b/tests/Unit/Entity/SingleIdentityBehaviorTest.php similarity index 89% rename from tests/Entity/SingleIdentityBehaviorTest.php rename to tests/Unit/Entity/SingleIdentityBehaviorTest.php index cd3fe2d..a833481 100644 --- a/tests/Entity/SingleIdentityBehaviorTest.php +++ b/tests/Unit/Entity/SingleIdentityBehaviorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Entity; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Entity; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; @@ -15,10 +15,10 @@ public function testIdentityValueReturnsTheSingleScalarField(): void $orderId = new OrderId(value: 'ord-1'); /** @When retrieving the identity value */ - $value = $orderId->identityValue(); + $identityValue = $orderId->identityValue(); /** @Then the scalar value is returned as-is */ - self::assertSame('ord-1', $value); + self::assertSame('ord-1', $identityValue); } public function testEqualsReturnsTrueForIdenticalSingleIdentities(): void diff --git a/tests/Event/DomainEventBehaviorTest.php b/tests/Unit/Event/DomainEventBehaviorTest.php similarity index 94% rename from tests/Event/DomainEventBehaviorTest.php rename to tests/Unit/Event/DomainEventBehaviorTest.php index ff7f40e..bf60f75 100644 --- a/tests/Event/DomainEventBehaviorTest.php +++ b/tests/Unit/Event/DomainEventBehaviorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Event; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Event; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; diff --git a/tests/Event/EventRecordTest.php b/tests/Unit/Event/EventRecordTest.php similarity index 52% rename from tests/Event/EventRecordTest.php rename to tests/Unit/Event/EventRecordTest.php index a0aab13..763685b 100644 --- a/tests/Event/EventRecordTest.php +++ b/tests/Unit/Event/EventRecordTest.php @@ -2,92 +2,111 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Event; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Event; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\Uuid; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; -use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData; use TinyBlocks\Time\Instant; final class EventRecordTest extends TestCase { public function testEventRecordExposesEveryConstructorField(): void { - /** @Given every required field for an EventRecord */ + /** @Given an event identifier */ $id = Uuid::uuid4(); + + /** @And an aggregate identity */ $orderId = new OrderId(value: 'ord-1'); + + /** @And a domain event */ $placedEvent = new OrderPlaced(item: 'book'); + + /** @And the matching event type */ $eventType = EventType::fromString(value: 'OrderPlaced'); + + /** @And the initial revision */ $revision = Revision::initial(); - $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(payload: ['status' => 'placed']); - $sequenceNumber = SequenceNumber::first(); + + /** @And the occurrence timestamp */ + $occurredAt = Instant::now(); + + /** @And the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); /** @When constructing the EventRecord */ $record = new EventRecord( id: $id, - type: $eventType, event: $placedEvent, - identity: $orderId, revision: $revision, - occurredOn: $occurredOn, - snapshotData: $snapshotData, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @Then each public field is accessible with the expected value */ self::assertSame($id, $record->id); - self::assertSame($eventType, $record->type); + self::assertSame($eventType, $record->eventType); self::assertSame($placedEvent, $record->event); - self::assertSame($orderId, $record->identity); + self::assertSame($orderId, $record->aggregateId); self::assertSame($revision, $record->revision); - self::assertSame($occurredOn, $record->occurredOn); - self::assertSame($snapshotData, $record->snapshotData); + self::assertSame($occurredAt, $record->occurredAt); self::assertSame('Order', $record->aggregateType); - self::assertSame($sequenceNumber, $record->sequenceNumber); + self::assertSame($aggregateVersion, $record->aggregateVersion); } public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void { - /** @Given shared values for two EventRecord instances */ + /** @Given an event identifier */ $id = Uuid::uuid4(); + + /** @And an aggregate identity */ $orderId = new OrderId(value: 'ord-1'); + + /** @And a domain event */ $placedEvent = new OrderPlaced(item: 'book'); + + /** @And the matching event type */ $eventType = EventType::fromString(value: 'OrderPlaced'); + + /** @And the initial revision */ $revision = Revision::initial(); - $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(payload: []); - $sequenceNumber = SequenceNumber::first(); - /** @And two records constructed from those identical values */ + /** @And the occurrence timestamp */ + $occurredAt = Instant::now(); + + /** @And the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); + + /** @And a first record built from those values */ $first = new EventRecord( id: $id, - type: $eventType, event: $placedEvent, - identity: $orderId, revision: $revision, - occurredOn: $occurredOn, - snapshotData: $snapshotData, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); + + /** @And a second record built from the same values */ $second = new EventRecord( id: $id, - type: $eventType, event: $placedEvent, - identity: $orderId, revision: $revision, - occurredOn: $occurredOn, - snapshotData: $snapshotData, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @When comparing them */ @@ -99,37 +118,46 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void { - /** @Given shared values except the identifier */ + /** @Given an aggregate identity */ $orderId = new OrderId(value: 'ord-1'); + + /** @And a domain event */ $placedEvent = new OrderPlaced(item: 'book'); + + /** @And the matching event type */ $eventType = EventType::fromString(value: 'OrderPlaced'); + + /** @And the initial revision */ $revision = Revision::initial(); - $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(payload: []); - $sequenceNumber = SequenceNumber::first(); - /** @And two records with different UUIDs */ + /** @And the occurrence timestamp */ + $occurredAt = Instant::now(); + + /** @And the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); + + /** @And a first record with a unique identifier */ $first = new EventRecord( id: Uuid::uuid4(), - type: $eventType, event: $placedEvent, - identity: $orderId, revision: $revision, - occurredOn: $occurredOn, - snapshotData: $snapshotData, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); + + /** @And a second record with a different identifier */ $second = new EventRecord( id: Uuid::uuid4(), - type: $eventType, event: $placedEvent, - identity: $orderId, revision: $revision, - occurredOn: $occurredOn, - snapshotData: $snapshotData, + eventType: $eventType, + occurredAt: $occurredAt, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @When comparing them */ @@ -141,71 +169,61 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void public function testOfFactoryBuildsRecordWithRequiredFields(): void { - /** @Given required fields for a record built via the factory */ + /** @Given an aggregate identity */ $orderId = new OrderId(value: 'ord-of-1'); + + /** @And a domain event */ $placedEvent = new OrderPlaced(item: 'notebook'); - $sequenceNumber = SequenceNumber::first(); + + /** @And the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); /** @When building the record via the factory */ $record = EventRecord::of( event: $placedEvent, - identity: $orderId, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @Then the envelope carries the expected metadata */ - self::assertSame('OrderPlaced', $record->type->value); + self::assertSame('OrderPlaced', $record->eventType->value); self::assertSame(1, $record->revision->value); self::assertSame($placedEvent, $record->event); - self::assertSame($orderId, $record->identity); + self::assertSame($orderId, $record->aggregateId); self::assertSame('Order', $record->aggregateType); - self::assertSame($sequenceNumber, $record->sequenceNumber); + self::assertSame($aggregateVersion, $record->aggregateVersion); } public function testOfFactoryUsesProvidedOptionalFields(): void { - /** @Given a specific id, timestamp, and snapshot data */ + /** @Given an explicit identifier */ $id = Uuid::uuid4(); + + /** @And an aggregate identity */ $orderId = new OrderId(value: 'ord-of-2'); + + /** @And a domain event */ $placedEvent = new OrderPlaced(item: 'pen'); - $occurredOn = Instant::now(); - $snapshotData = new SnapshotData(payload: ['status' => 'placed']); - $sequenceNumber = SequenceNumber::first(); + + /** @And an explicit occurrence timestamp */ + $occurredAt = Instant::now(); + + /** @And the first aggregate version */ + $aggregateVersion = AggregateVersion::first(); /** @When building the record via the factory with all optional fields */ $record = EventRecord::of( event: $placedEvent, - identity: $orderId, + aggregateId: $orderId, aggregateType: 'Order', - sequenceNumber: $sequenceNumber, + aggregateVersion: $aggregateVersion, id: $id, - occurredOn: $occurredOn, - snapshotData: $snapshotData + occurredAt: $occurredAt ); /** @Then the optional fields are applied exactly */ self::assertSame($id, $record->id); - self::assertSame($occurredOn, $record->occurredOn); - self::assertSame($snapshotData, $record->snapshotData); - } - - public function testOfFactoryDefaultsSnapshotDataToEmptyArray(): void - { - /** @Given required fields only */ - $orderId = new OrderId(value: 'ord-of-3'); - $placedEvent = new OrderPlaced(item: 'lamp'); - $sequenceNumber = SequenceNumber::first(); - - /** @When building the record without providing snapshot data */ - $record = EventRecord::of( - event: $placedEvent, - identity: $orderId, - aggregateType: 'Order', - sequenceNumber: $sequenceNumber - ); - - /** @Then the snapshot data payload is empty */ - self::assertSame([], $record->snapshotData->toArray()); + self::assertSame($occurredAt, $record->occurredAt); } } diff --git a/tests/Event/EventRecordsTest.php b/tests/Unit/Event/EventRecordsTest.php similarity index 72% rename from tests/Event/EventRecordsTest.php rename to tests/Unit/Event/EventRecordsTest.php index c67fc2f..d84065c 100644 --- a/tests/Event/EventRecordsTest.php +++ b/tests/Unit/Event/EventRecordsTest.php @@ -2,18 +2,17 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Event; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Event; use PHPUnit\Framework\TestCase; use Ramsey\Uuid\Uuid; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; -use TinyBlocks\BuildingBlocks\Snapshot\SnapshotData; use TinyBlocks\Time\Instant; final class EventRecordsTest extends TestCase @@ -38,14 +37,13 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void /** @And a freshly built event record */ $record = new EventRecord( id: Uuid::uuid4(), - type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), - identity: new OrderId(value: 'ord-1'), revision: Revision::initial(), - occurredOn: Instant::now(), - snapshotData: new SnapshotData(payload: []), + eventType: EventType::fromString(value: 'OrderPlaced'), + occurredAt: Instant::now(), + aggregateId: new OrderId(value: 'ord-1'), aggregateType: 'Order', - sequenceNumber: SequenceNumber::first() + aggregateVersion: AggregateVersion::first() ); /** @When adding the record */ @@ -57,18 +55,19 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void public function testFirstElementRoundTripsTheAddedRecord(): void { - /** @Given a record added to an empty EventRecords collection */ + /** @Given a freshly built event record */ $record = new EventRecord( id: Uuid::uuid4(), - type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), - identity: new OrderId(value: 'ord-1'), revision: Revision::initial(), - occurredOn: Instant::now(), - snapshotData: new SnapshotData(payload: []), + eventType: EventType::fromString(value: 'OrderPlaced'), + occurredAt: Instant::now(), + aggregateId: new OrderId(value: 'ord-1'), aggregateType: 'Order', - sequenceNumber: SequenceNumber::first() + aggregateVersion: AggregateVersion::first() ); + + /** @And a collection carrying that record */ $records = EventRecords::createFromEmpty()->add($record); /** @When retrieving the first element */ diff --git a/tests/Event/EventTypeTest.php b/tests/Unit/Event/EventTypeTest.php similarity index 85% rename from tests/Event/EventTypeTest.php rename to tests/Unit/Event/EventTypeTest.php index 537479c..070fbb7 100644 --- a/tests/Event/EventTypeTest.php +++ b/tests/Unit/Event/EventTypeTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Event; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Event; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -10,7 +10,7 @@ use ReflectionMethod; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; use TinyBlocks\BuildingBlocks\Event\EventType; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidEventType; final class EventTypeTest extends TestCase { @@ -38,7 +38,6 @@ public function testFromEventUsesTheShortClassNameOfTheDomainEvent(): void public function testFromStringAcceptsValidPascalCase(): void { - /** @Given a valid PascalCase string */ /** @When creating an EventType from the string */ $eventType = EventType::fromString(value: 'OrderShipped'); @@ -79,7 +78,6 @@ public function testEqualsReturnsFalseForDifferentValues(): void #[DataProvider('invalidPatterns')] public function testFromStringRejectsValuesNotMatchingPattern(string $invalidValue): void { - /** @Given a value that violates the event-type pattern */ /** @Then an InvalidEventType exception mentioning the pattern is thrown */ $this->expectException(InvalidEventType::class); $this->expectExceptionMessage('does not match the required pattern'); @@ -90,7 +88,6 @@ public function testFromStringRejectsValuesNotMatchingPattern(string $invalidVal public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): void { - /** @Given consumer code catching the PHP-standard InvalidArgumentException */ /** @Then InvalidEventType is caught by the standard exception type */ $this->expectException(InvalidArgumentException::class); @@ -100,7 +97,6 @@ public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): voi public function testInvalidEventTypeCarriesTheOffendingValue(): void { - /** @Given a consumer inspecting the exception message */ /** @Then the offending value is included in the message */ $this->expectException(InvalidEventType::class); $this->expectExceptionMessage('lowercaseStart'); @@ -109,17 +105,14 @@ public function testInvalidEventTypeCarriesTheOffendingValue(): void EventType::fromString(value: 'lowercaseStart'); } - /** - * @return array - */ 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/Unit/Event/RevisionTest.php similarity index 91% rename from tests/Event/RevisionTest.php rename to tests/Unit/Event/RevisionTest.php index 31d3b4c..4413fb0 100644 --- a/tests/Event/RevisionTest.php +++ b/tests/Unit/Event/RevisionTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Event; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Event; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use ReflectionMethod; use TinyBlocks\BuildingBlocks\Event\Revision; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidRevision; final class RevisionTest extends TestCase { @@ -25,7 +25,6 @@ public function testConstructorIsPrivate(): void public function testInitialReturnsRevisionOfOne(): void { - /** @Given the initial revision factory */ /** @When requesting the initial revision */ $revision = Revision::initial(); @@ -35,7 +34,6 @@ public function testInitialReturnsRevisionOfOne(): void public function testOfReturnsRevisionWithGivenValue(): void { - /** @Given a valid revision value */ /** @When requesting a revision of that value */ $revision = Revision::of(value: 42); @@ -45,7 +43,6 @@ public function testOfReturnsRevisionWithGivenValue(): void public function testOfStoresTheMinimumValidValue(): void { - /** @Given the minimum valid revision value */ /** @When constructing the revision via factory */ $revision = Revision::of(value: 1); @@ -176,7 +173,6 @@ public function testIsBeforeReturnsFalseWhenValueIsGreater(): void #[DataProvider('invalidValues')] 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); @@ -187,7 +183,6 @@ public function testOfRejectsNonPositiveValue(int $invalidValue): void public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void { - /** @Given consumer code catching the PHP-standard InvalidArgumentException */ /** @Then InvalidRevision is caught by the standard exception type */ $this->expectException(InvalidArgumentException::class); @@ -197,7 +192,6 @@ public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void { - /** @Given a consumer inspecting the exception message */ /** @Then the message mentions the minimum allowed value */ $this->expectException(InvalidRevision::class); $this->expectExceptionMessage('greater than or equal to 1'); @@ -206,13 +200,10 @@ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void Revision::of(value: 0); } - /** - * @return array - */ public static function invalidValues(): array { return [ - 'zero' => [0], + 'zero' => [0], 'negative one' => [-1], 'negative ten' => [-10] ]; diff --git a/tests/Mocks/FileSnapshotter.php b/tests/Unit/FileSnapshotter.php similarity index 91% rename from tests/Mocks/FileSnapshotter.php rename to tests/Unit/FileSnapshotter.php index 6dd157c..d01fae3 100644 --- a/tests/Mocks/FileSnapshotter.php +++ b/tests/Unit/FileSnapshotter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Mocks; +namespace Test\TinyBlocks\BuildingBlocks\Unit; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\BuildingBlocks\Snapshot\Snapshotter; diff --git a/tests/Snapshot/SnapshotConditionTest.php b/tests/Unit/Snapshot/SnapshotConditionTest.php similarity index 97% rename from tests/Snapshot/SnapshotConditionTest.php rename to tests/Unit/Snapshot/SnapshotConditionTest.php index d8caa58..a8dfd79 100644 --- a/tests/Snapshot/SnapshotConditionTest.php +++ b/tests/Unit/Snapshot/SnapshotConditionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Snapshot; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; diff --git a/tests/Snapshot/SnapshotEveryTest.php b/tests/Unit/Snapshot/SnapshotEveryTest.php similarity index 74% rename from tests/Snapshot/SnapshotEveryTest.php rename to tests/Unit/Snapshot/SnapshotEveryTest.php index 3d7b1d3..6db41b9 100644 --- a/tests/Snapshot/SnapshotEveryTest.php +++ b/tests/Unit/Snapshot/SnapshotEveryTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Snapshot; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSnapshotCount; +use TinyBlocks\BuildingBlocks\Exceptions\InvalidSnapshotCount; use TinyBlocks\BuildingBlocks\Snapshot\SnapshotEvery; final class SnapshotEveryTest extends TestCase @@ -18,10 +18,10 @@ public function testReturnsFalseForBlankAggregateWithCountHundred(): void $cart = Cart::blank(identity: new CartId(value: 'cart-snap-1')); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is false because sequence zero is excluded */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testReturnsTrueAtSequenceHundred(): void @@ -30,10 +30,10 @@ public function testReturnsTrueAtSequenceHundred(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-2'), count: 100); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testReturnsTrueAtSequenceTwoHundred(): void @@ -42,10 +42,10 @@ public function testReturnsTrueAtSequenceTwoHundred(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-3'), count: 200); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testReturnsTrueAtSequenceThreeHundred(): void @@ -54,10 +54,10 @@ public function testReturnsTrueAtSequenceThreeHundred(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-4'), count: 300); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testReturnsFalseAtSequenceOne(): void @@ -66,10 +66,10 @@ public function testReturnsFalseAtSequenceOne(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-5'), count: 1); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testReturnsFalseAtSequenceNinetyNine(): void @@ -78,10 +78,10 @@ public function testReturnsFalseAtSequenceNinetyNine(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-6'), count: 99); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testReturnsFalseAtSequenceHundredOne(): void @@ -90,10 +90,10 @@ public function testReturnsFalseAtSequenceHundredOne(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-7'), count: 101); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testReturnsFalseAtSequenceOneNinetyNine(): void @@ -102,10 +102,10 @@ public function testReturnsFalseAtSequenceOneNinetyNine(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-8'), count: 199); /** @When asking a count-100 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 100)->shouldSnapshot(aggregate: $cart); /** @Then the result is false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testReturnsTrueForCountOneAtSequenceOne(): void @@ -114,10 +114,10 @@ public function testReturnsTrueForCountOneAtSequenceOne(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-9'), count: 1); /** @When asking a count-1 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testReturnsTrueForCountOneAtSequenceTwo(): void @@ -126,10 +126,10 @@ public function testReturnsTrueForCountOneAtSequenceTwo(): void $cart = Cart::withProducts(cartId: new CartId(value: 'cart-snap-10'), count: 2); /** @When asking a count-1 condition whether to snapshot */ - $result = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotEvery::events(count: 1)->shouldSnapshot(aggregate: $cart); /** @Then the result is true */ - self::assertTrue($result); + self::assertTrue($shouldSnapshot); } public function testThrowsWhenCountIsZero(): void diff --git a/tests/Snapshot/SnapshotNeverTest.php b/tests/Unit/Snapshot/SnapshotNeverTest.php similarity index 72% rename from tests/Snapshot/SnapshotNeverTest.php rename to tests/Unit/Snapshot/SnapshotNeverTest.php index b2e2f6d..5fa4f19 100644 --- a/tests/Snapshot/SnapshotNeverTest.php +++ b/tests/Unit/Snapshot/SnapshotNeverTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Snapshot; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; @@ -17,22 +17,22 @@ public function testReturnsFalseForBlankAggregate(): void $cart = Cart::blank(identity: new CartId(value: 'cart-never-1')); /** @When asking the SnapshotNever condition whether to snapshot */ - $result = SnapshotNever::create()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotNever::create()->shouldSnapshot(aggregate: $cart); /** @Then the result is always false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } - public function testReturnsFalseForAggregateAtHighSequenceNumber(): void + public function testReturnsFalseForAggregateAtHighAggregateVersion(): void { - /** @Given a cart at sequence 1000 */ + /** @Given a cart at aggregate version 1000 */ $cart = Cart::withProducts(cartId: new CartId(value: 'cart-never-2'), count: 1000); /** @When asking the SnapshotNever condition whether to snapshot */ - $result = SnapshotNever::create()->shouldSnapshot(aggregate: $cart); + $shouldSnapshot = SnapshotNever::create()->shouldSnapshot(aggregate: $cart); /** @Then the result is always false */ - self::assertFalse($result); + self::assertFalse($shouldSnapshot); } public function testTwoInstancesAreEqualUnderLooseComparison(): void diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Unit/Snapshot/SnapshotTest.php similarity index 85% rename from tests/Snapshot/SnapshotTest.php rename to tests/Unit/Snapshot/SnapshotTest.php index b21852f..9f78b87 100644 --- a/tests/Snapshot/SnapshotTest.php +++ b/tests/Unit/Snapshot/SnapshotTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Snapshot; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; use Test\TinyBlocks\BuildingBlocks\Models\CartWithLogger; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; use TinyBlocks\Time\Instant; @@ -25,8 +25,8 @@ public function testFromAggregateCapturesAggregateType(): void /** @When taking a snapshot */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); - /** @Then the type matches the aggregate's short class name */ - self::assertSame('Cart', $snapshot->type()); + /** @Then the aggregate type matches the aggregate's short class name */ + self::assertSame('Cart', $snapshot->aggregateType()); } public function testFromAggregateCapturesAggregateId(): void @@ -41,7 +41,7 @@ public function testFromAggregateCapturesAggregateId(): void self::assertSame('cart-id-42', $snapshot->aggregateId()); } - public function testFromAggregateCapturesSequenceNumber(): void + public function testFromAggregateCapturesAggregateVersion(): void { /** @Given a cart with two products added */ $cart = Cart::withProducts(cartId: new CartId(value: 'cart-2'), count: 2); @@ -49,8 +49,8 @@ public function testFromAggregateCapturesSequenceNumber(): void /** @When taking a snapshot */ $snapshot = Snapshot::fromAggregate(aggregate: $cart); - /** @Then the sequence number is captured */ - self::assertSame(2, $snapshot->sequenceNumber()->value); + /** @Then the aggregate version is captured */ + self::assertSame(2, $snapshot->aggregateVersion()->value); } public function testFromAggregateCapturesCreatedAt(): void @@ -95,7 +95,7 @@ public function testFromAggregateStateOmitsRecordedEventsBuffer(): void self::assertArrayNotHasKey('recordedEvents', $state); } - public function testFromAggregateStateOmitsSequenceNumber(): void + public function testFromAggregateStateOmitsAggregateVersion(): void { /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-6')); @@ -106,8 +106,8 @@ public function testFromAggregateStateOmitsSequenceNumber(): void /** @When taking a snapshot */ $state = Snapshot::fromAggregate(aggregate: $cart)->aggregateState(); - /** @Then the sequence number is not duplicated into the state */ - self::assertArrayNotHasKey('sequenceNumber', $state); + /** @Then the aggregate version is not duplicated into the state */ + self::assertArrayNotHasKey('aggregateVersion', $state); } public function testRoundTripThroughSnapshotRestoresDomainState(): void @@ -169,28 +169,28 @@ public function testFromAggregateWithOverriddenSnapshotStateExcludesInfrastructu public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void { - /** @Given a sequence number at its first value */ - $sequenceNumber = SequenceNumber::first(); + /** @Given an aggregate version at its first value */ + $aggregateVersion = AggregateVersion::first(); /** @And a known creation timestamp */ $createdAt = Instant::now(); /** @And the first snapshot built from those fields */ $first = Snapshot::restore( - type: 'Cart', + aggregateType: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: ['productIds' => []], - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @And the second snapshot built from the same fields */ $second = Snapshot::restore( - type: 'Cart', + aggregateType: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: ['productIds' => []], - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @When comparing them */ @@ -202,28 +202,28 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void { - /** @Given a sequence number at its first value */ - $sequenceNumber = SequenceNumber::first(); + /** @Given an aggregate version at its first value */ + $aggregateVersion = AggregateVersion::first(); /** @And a known creation timestamp */ $createdAt = Instant::now(); /** @And the first snapshot with type Cart */ $first = Snapshot::restore( - type: 'Cart', + aggregateType: 'Cart', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: [], - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @And the second snapshot with type Order */ $second = Snapshot::restore( - type: 'Order', + aggregateType: 'Order', createdAt: $createdAt, aggregateId: 'cart-1', aggregateState: [], - sequenceNumber: $sequenceNumber + aggregateVersion: $aggregateVersion ); /** @When comparing them */ diff --git a/tests/Snapshot/SnapshotterBehaviorTest.php b/tests/Unit/Snapshot/SnapshotterBehaviorTest.php similarity index 66% rename from tests/Snapshot/SnapshotterBehaviorTest.php rename to tests/Unit/Snapshot/SnapshotterBehaviorTest.php index fb13d67..f35eb9a 100644 --- a/tests/Snapshot/SnapshotterBehaviorTest.php +++ b/tests/Unit/Snapshot/SnapshotterBehaviorTest.php @@ -2,21 +2,25 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Snapshot; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Snapshot; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; -use Test\TinyBlocks\BuildingBlocks\Mocks\FileSnapshotter; +use Test\TinyBlocks\BuildingBlocks\Unit\FileSnapshotter; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class SnapshotterBehaviorTest extends TestCase { public function testTakePersistsASnapshotForTheAggregate(): void { - /** @Given a cart with state and a fresh snapshotter */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-1')); + + /** @And a product added */ $cart->addProduct(productId: 'prod-1'); + + /** @And a fresh snapshotter */ $snapshotter = new FileSnapshotter(); /** @When taking a snapshot */ @@ -28,42 +32,52 @@ public function testTakePersistsASnapshotForTheAggregate(): void public function testPersistedSnapshotReflectsTheAggregateType(): void { - /** @Given a cart and a fresh snapshotter */ + /** @Given a blank cart */ $cart = Cart::blank(identity: new CartId(value: 'cart-2')); + + /** @And a fresh snapshotter */ $snapshotter = new FileSnapshotter(); /** @When taking a snapshot */ $snapshotter->take(aggregate: $cart); /** @Then the persisted snapshot carries the aggregate's type */ - self::assertSame('Cart', $snapshotter->lastSnapshot()->type()); + self::assertSame('Cart', $snapshotter->lastSnapshot()?->aggregateType()); } - public function testPersistedSnapshotReflectsTheAggregateSequenceNumber(): void + public function testPersistedSnapshotReflectsTheAggregateVersion(): void { - /** @Given a cart advanced to sequence number 2 */ + /** @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 added */ $cart->addProduct(productId: 'prod-2'); + + /** @And a fresh snapshotter */ $snapshotter = new FileSnapshotter(); /** @When taking a snapshot */ $snapshotter->take(aggregate: $cart); - /** @Then the persisted snapshot carries the aggregate's sequence number */ - self::assertSame(2, $snapshotter->lastSnapshot()->sequenceNumber()->value); + /** @Then the persisted snapshot carries the aggregate's version */ + self::assertSame(2, $snapshotter->lastSnapshot()?->aggregateVersion()->value); } public function testPersistedSnapshotReflectsTheAggregateIdentity(): void { - /** @Given a cart with a known identity */ + /** @Given a blank cart with a known identity */ $cart = Cart::blank(identity: new CartId(value: 'cart-4')); + + /** @And a fresh snapshotter */ $snapshotter = new FileSnapshotter(); /** @When taking a snapshot */ $snapshotter->take(aggregate: $cart); /** @Then the persisted snapshot carries the aggregate id */ - self::assertSame('cart-4', $snapshotter->lastSnapshot()->aggregateId()); + self::assertSame('cart-4', $snapshotter->lastSnapshot()?->aggregateId()); } } diff --git a/tests/Upcast/DefaultValuesTest.php b/tests/Unit/Upcast/DefaultValuesTest.php similarity index 80% rename from tests/Upcast/DefaultValuesTest.php rename to tests/Unit/Upcast/DefaultValuesTest.php index e2af751..330982d 100644 --- a/tests/Upcast/DefaultValuesTest.php +++ b/tests/Unit/Upcast/DefaultValuesTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Upcast; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast; use PHPUnit\Framework\TestCase; use TinyBlocks\BuildingBlocks\Upcast\DefaultValues; @@ -11,8 +11,7 @@ final class DefaultValuesTest extends TestCase { public function testDefaultsMapYieldsTheZeroValueForEachPrimitiveType(): void { - /** @Given the primitive defaults map */ - /** @When retrieving it */ + /** @When retrieving the primitive defaults map */ $defaults = DefaultValues::get(); /** @Then each primitive type maps to its zero-value */ @@ -25,8 +24,7 @@ public function testDefaultsMapYieldsTheZeroValueForEachPrimitiveType(): void public function testDefaultsMapContainsOnlyPrimitiveTypeKeys(): void { - /** @Given the primitive defaults map */ - /** @When retrieving it */ + /** @When retrieving the primitive defaults map */ $defaults = DefaultValues::get(); /** @Then only primitive type keys are present */ diff --git a/tests/Upcast/IntermediateEventTest.php b/tests/Unit/Upcast/IntermediateEventTest.php similarity index 92% rename from tests/Upcast/IntermediateEventTest.php rename to tests/Unit/Upcast/IntermediateEventTest.php index 900df90..eb67bf6 100644 --- a/tests/Upcast/IntermediateEventTest.php +++ b/tests/Unit/Upcast/IntermediateEventTest.php @@ -2,21 +2,25 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Upcast; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast; use PHPUnit\Framework\TestCase; +use TinyBlocks\BuildingBlocks\Aggregate\AggregateVersion; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; -use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; final class IntermediateEventTest extends TestCase { public function testIntermediateEventExposesEveryConstructorField(): void { - /** @Given every required field for an IntermediateEvent */ + /** @Given an event type */ $eventType = EventType::fromString(value: 'ProductAdded'); + + /** @And the initial revision */ $revision = Revision::initial(); + + /** @And a serialized event payload */ $serializedEvent = ['productId' => 'prod-1']; /** @When constructing the intermediate event */ @@ -113,13 +117,19 @@ public function testWithSerializedEventPreservesTheTypeAndRevision(): void public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void { - /** @Given shared fields for two intermediate events */ + /** @Given an event type */ $eventType = EventType::fromString(value: 'ProductAdded'); + + /** @And the initial revision */ $revision = Revision::initial(); + + /** @And a serialized event payload */ $payload = ['productId' => 'prod-1']; - /** @And two intermediate events with identical values */ + /** @And a first intermediate event built from those values */ $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload); + + /** @And a second intermediate event built from the same values */ $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: $payload); /** @When comparing them */ @@ -131,12 +141,16 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void public function testEqualsReturnsFalseForDifferentPayloads(): void { - /** @Given two intermediate events with different payloads */ + /** @Given an event type */ $eventType = EventType::fromString(value: 'ProductAdded'); + + /** @And the initial revision */ $revision = Revision::initial(); - /** @And the two events constructed accordingly */ + /** @And a first event carrying payload a */ $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']); + + /** @And a second event carrying payload b */ $second = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'b']); /** @When comparing them */ @@ -208,7 +222,7 @@ public function testEqualsReturnsFalseWhenOtherIsDifferentValueObjectType(): voi ); /** @And a value object of a different class */ - $otherValueObject = SequenceNumber::first(); + $otherValueObject = AggregateVersion::first(); /** @When comparing them */ $areEqual = $event->equals(other: $otherValueObject); diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php similarity index 98% rename from tests/Upcast/SingleUpcasterBehaviorTest.php rename to tests/Unit/Upcast/SingleUpcasterBehaviorTest.php index 1b09712..ec7e298 100644 --- a/tests/Upcast/SingleUpcasterBehaviorTest.php +++ b/tests/Unit/Upcast/SingleUpcasterBehaviorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Upcast; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\ProductV1Upcaster; diff --git a/tests/Upcast/UpcastersTest.php b/tests/Unit/Upcast/UpcastersTest.php similarity index 98% rename from tests/Upcast/UpcastersTest.php rename to tests/Unit/Upcast/UpcastersTest.php index 665c1ee..fa0c6ed 100644 --- a/tests/Upcast/UpcastersTest.php +++ b/tests/Unit/Upcast/UpcastersTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\BuildingBlocks\Upcast; +namespace Test\TinyBlocks\BuildingBlocks\Unit\Upcast; use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\ProductV1Upcaster;