friends-of-ddd/context-arranger

Framework for creating context for integration tests

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Forks: 0

pkg:composer/friends-of-ddd/context-arranger

0.1.0 2025-11-05 21:49 UTC

This package is auto-updated.

Last update: 2025-11-05 21:00:46 UTC


README

A powerful library for creating complex test contexts in integration tests using command templates, automatic dependency resolution, and event-driven flows.

Table of Contents

Installation

Install the library using Composer as a development dependency:

composer require --dev friends-of-ddd/context-arranger

Introduction

Context Arranger is a framework designed to simplify the setup of complex test contexts in integration tests. It enables you to:

  • Define reusable command templates with configurable parameters
  • Automatically resolve dependencies between commands using events
  • Create prerequisite flows that trigger automatically when needed
  • Refer to IDs that will be created in the future using closures
  • Customize command behavior with pre-dispatch callbacks
  • Manage multiple isolated contexts within the same test

Core Concepts

Command Templates

A Command Template is a blueprint for creating commands with predefined or dynamic parameters. It implements the CommandTemplateInterface:

interface CommandTemplateInterface
{
    /**
     * Creates the actual command with resolved parameters
     */
    public function create(array $resolvedParams): CommandInterface;

    /**
     * Returns all parameters (both resolved and unresolved)
     */
    public function getAllUnresolvedParams(): array;
}

Unresolved Parameters

Unresolved parameters are values that will be determined at runtime. They can be:

  1. Static values: Simple strings, integers, etc.
  2. Closures: Functions that will be executed to resolve the value dynamically
  3. Event-based closures: Functions that extract values from events produced by other commands

Closures and Dynamic Resolution

Closures in command templates can:

  • Generate unique values (e.g., auto-incrementing IDs, unique emails)
  • Extract values from events produced by prerequisite commands
  • Access services from the application container
  • Reference results from specific contexts

Basic Usage

Creating Command Templates

Here's how to create a command template for a CreateClientCommand:

use Closure;
use FriendsOfDdd\ContextArranger\CommandTemplateInterface;

final readonly class CreateClientCommandTemplate implements CommandTemplateInterface
{
    public function __construct(
        private string $name = 'John Doe',
        private null|string|Closure $email = null,
    ) {
    }

    public function create(array $resolvedParams): CreateClientCommand
    {
        return new CreateClientCommand(...$resolvedParams);
    }

    public function getAllUnresolvedParams(): array
    {
        return [
            'name' => $this->name,
            'email' => $this->email ?? static function (): string {
                static $counter = 1;
                $email = "john.doe$counter@company.com";
                $counter++;
                return $email;
            },
        ];
    }
}

Key Points:

  • Properties can be static values or Closure types
  • In getAllUnresolvedParams(), return closures as unresolved parameters
  • Closures with no arguments generate values dynamically (e.g., auto-increment)
  • Static counters inside closures maintain state across multiple calls

Setting Up the Context Arranger

use FriendsOfDdd\ContextArranger\Context;
use FriendsOfDdd\ContextArranger\ContextArranger;
use FriendsOfDdd\ContextArranger\EventBus;
use FriendsOfDdd\ContextArranger\Result\ResultCollection;

$contextArranger = new ContextArranger(
    context: new Context(
        application: $container, // PSR-11 container
        results: new ResultCollection(),
    ),
    commandBus: $commandBus,     // Your command bus
    eventBus: new EventBus(),    // Event bus to track dispatched events
);

Arranging Commands

// Execute a single command
$contextArranger->arrange(
    new CreateClientCommandTemplate(name: 'Alice')
);

// Execute multiple commands
$contextArranger->arrange(
    new CreateClientCommandTemplate(name: 'Alice'),
    new CreateClientCommandTemplate(name: 'Bob'),
);

Advanced Features

Referring to Future IDs in Closures

One of the most powerful features is the ability to reference IDs that will be created by prerequisite commands using event parameter names.

Event-Based Parameter Resolution

When a closure has a parameter with an event type, the library will:

  1. Look for events of that type in the context
  2. Extract the required property from the event
  3. Pass it to the command being created

Example:

final readonly class CreateTicketCommandTemplate implements CommandTemplateInterface
{
    public function __construct(
        private string $title = 'Fix my laptop',
        private null|int|Closure $clientId = null,
        private null|int|Closure $topicId = null,
    ) {
        // Default closure extracts clientId from ClientCreatedEvent
        $this->clientId = $clientId ?? static fn (ClientCreatedEvent $event): int => $event->clientId;
    }

    public function create(array $resolvedParams): CreateTicketCommand
    {
        return new CreateTicketCommand(...$resolvedParams);
    }

    public function getAllUnresolvedParams(): array
    {
        return [
            'title' => $this->title,
            'clientId' => $this->clientId,
            'topicId' => $this->topicId,
        ];
    }
}

Special Parameter Names for Flow-Specific and Named Results:

You can reference events from specific flows or named results using special parameter naming:

// Reference event from a specific flow named "Email"
static fn (TicketCreatedEvent $inEmail) => $inEmail->ticketId

// Reference event from "Default" flow
static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId

// Reference the most recent result named "TheResult"
static fn (TicketCreatedEvent $theResult) => $theResult->ticketId

The prefix before the event type name determines the source:

  • $in{FlowName} or ${randomText}In{FlowName} - Gets event from a specific flow (e.g., $inDefault, $ticketInEmail)
  • $the{ResultName} or ${randomText}The{ResultName} - Gets event from a specific named result (e.g., $theTicket1, $eventTheFirstTicket)

Defining Flows as Prerequisites

Prerequisites define flows that trigger automatically when their required events are missing.

use FriendsOfDdd\ContextArranger\Prerequisite\Prerequisite;
use FriendsOfDdd\ContextArranger\Prerequisite\PrerequisiteCollection;

$prerequisites = new PrerequisiteCollection(
    // Define "Default" flow for creating a client
    new Prerequisite(
        commandTemplate: new CreateClientCommandTemplate(),
        triggersEventTypes: [ClientCreatedEvent::class],
        flowName: 'Default',
    ),
    
    // Define "Default" flow for creating a ticket
    new Prerequisite(
        commandTemplate: new CreateTicketCommandTemplate(
            clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
        ),
        triggersEventTypes: [TicketCreatedEvent::class],
        flowName: 'Default',
    ),
    
    // Define "Default" flow for closing a ticket
    new Prerequisite(
        commandTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
        ),
        triggersEventTypes: [TicketClosedEvent::class],
        flowName: 'Default',
    ),
);

$contextArranger = new ContextArranger(
    context: $context,
    commandBus: $commandBus,
    eventBus: $eventBus,
    prerequisites: $prerequisites,
);

Automatic Dependency Chains

When you arrange a command that requires events that don't exist yet, the library automatically:

  1. Analyzes the command's closures to find required event types
  2. Finds prerequisites that produce those events
  3. Executes the prerequisite commands recursively
  4. Continues with the original command once dependencies are satisfied

Example:

// You want to close a ticket, but no ticket exists yet
$contextArranger->arrange(
    new CloseTicketCommandTemplate(
        ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
    )
);

// The library automatically:
// 1. Detects that TicketCreatedEvent is missing
// 2. Finds the prerequisite that produces TicketCreatedEvent
// 3. Executes CreateTicketCommand
// 4. Detects that ClientCreatedEvent is missing (required by CreateTicketCommand)
// 5. Finds the prerequisite that produces ClientCreatedEvent
// 6. Executes CreateClientCommand
// 7. Now executes CreateTicketCommand with the clientId
// 8. Finally executes CloseTicketCommand with the ticketId

Redefining Commands in Flow Chains

You can override specific commands in a prerequisite chain without redefining the entire flow:

use FriendsOfDdd\ContextArranger\CommandTemplateMetadata;

// Override just the CreateTicketCommand in the "Default" flow
$contextArranger->arrange(
    new CommandTemplateMetadata(
        realTemplate: new CreateTicketCommandTemplate(
            title: 'Custom ticket title',
            clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
        ),
        resultName: 'customTicket',
    )
);

// Or override the client creation while keeping the rest of the flow
$contextArranger->arrange(
    new CommandTemplateMetadata(
        realTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
        ),
    )
);
// This will still trigger CreateClientCommand and CreateTicketCommand from prerequisites,
// but you can wrap any of them with CommandTemplateMetadata to customize them

Customizing prerequisite commands inline:

$prerequisites = new PrerequisiteCollection(
    new Prerequisite(
        commandTemplate: new CommandTemplateMetadata(
            realTemplate: new CreateTicketCommandTemplate(
                clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
            ),
            resultName: 'regularTicket', // Custom result name
        ),
        triggersEventTypes: [TicketCreatedEvent::class],
        flowName: 'Default',
    ),
);

Custom Context Names

Understanding Flow Names vs Context Names:

These are two separate concepts that serve different purposes:

  1. Flow Names - Defined in prerequisites, determine which prerequisite chain to use

    • Set in Prerequisite constructor: flowName: 'Default' or flowName: 'Email'
    • Referenced in closure parameter names: $inDefault, $inEmail
    • Controls which dependency chain is triggered when events are missing
  2. Context Names - Defined in CommandTemplateMetadata, namespace for storing results

    • Set in CommandTemplateMetadata constructor: contextName: 'AdminScenario'
    • Keeps results in separate namespaces to avoid duplicates
    • Multiple contexts can use the same flow or different flows

Context names allow you to:

  • Keep ResultCollection results in separate namespaces within the same test
  • Create multiple instances of the same event without conflicts
  • Run parallel scenarios that may reuse the same flows

Example: Same flow, different contexts

// Create two separate admin scenarios using the same "Default" flow
$contextArranger->arrange(
    new CommandTemplateMetadata(
        realTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
        ),
        contextName: 'AdminScenario1', // First isolated context
    ),
    new CommandTemplateMetadata(
        realTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
        ),
        contextName: 'AdminScenario2', // Second isolated context
    ),
);

// Both commands will:
// - Use the "Default" flow (determined by $inDefault in closures)
// - Store results in separate contexts (AdminScenario1 and AdminScenario2)
// - Create separate instances of ClientCreatedEvent and TicketCreatedEvent

Example: Different flows, different contexts

$prerequisites = new PrerequisiteCollection(
    // "Default" flow
    new Prerequisite(
        new CreateClientCommandTemplate(),
        triggersEventTypes: [ClientCreatedEvent::class],
        flowName: 'Default',
    ),
    new Prerequisite(
        new CreateTicketCommandTemplate(
            clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
        ),
        triggersEventTypes: [TicketCreatedEvent::class],
        flowName: 'Default',
    ),
    
    // "Email" flow - different prerequisite chain
    new Prerequisite(
        new CreateTicketFromEmailCommandTemplate(),
        triggersEventTypes: [ClientCreatedEvent::class, TicketCreatedEvent::class],
        flowName: 'Email',
    ),
);

// Use different flows and contexts
$contextArranger->arrange(
    // Uses "Default" flow, stores in "RegularTicketing" context
    new CommandTemplateMetadata(
        realTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
        ),
        contextName: 'RegularTicketing',
    ),
    
    // Uses "Email" flow, stores in "EmailTicketing" context
    new CommandTemplateMetadata(
        realTemplate: new CloseTicketCommandTemplate(
            ticketId: static fn (TicketCreatedEvent $inEmail) => $inEmail->ticketId,
        ),
        contextName: 'EmailTicketing',
    ),
);

Key Points:

  • Flow name (in closures like $inDefault, $inEmail) selects which prerequisite chain to trigger
  • Context name (in CommandTemplateMetadata) namespaces the results to prevent duplicates
  • Same flow can be used with different contexts to create isolated result sets
  • Different flows can be used with different contexts for completely independent scenarios

Pre-Dispatch Callbacks

Pre-dispatch callbacks allow you to execute custom logic before a command is dispatched. This is useful for:

  • Setting up custom clocks for time-sensitive tests
  • Configuring mocked external services
  • Preparing test data that the command will use

Example: Configuring a mocked external service

use FriendsOfDdd\ContextArranger\CommandTemplateMetadata;

$contextArranger->arrange(
    new CommandTemplateMetadata(
        realTemplate: new CreateTicketFromEmailCommandTemplate(
            emailId: new GmailEmailId('inbox@service.com', '123123123')
        ),
        resultName: 'emailTicket',
        preDispatchCallback: static function (
            CreateTicketFromEmailCommand $command,
            MockedGmailApiClient $client, // Injected from container
        ): void {
            // Configure the mock before the command is executed
            $client->addMockedResponse($command->emailId, new GmailHistory(
                id: '123123123',
                messagesAdded: [
                    new GmailMessage(
                        id: '2234234',
                        threadId: '345345345',
                        internalDate: new DateTimeImmutable(),
                        payload: new GmailMessagePart(
                            partId: '34343453',
                            mimeType: 'text/plain',
                            headers: [],
                            body: 'Fix my stuff',
                        ),
                    ),
                ],
            ));
        },
    )
);

Example: Setting up a custom clock

$contextArranger->arrange(
    new CommandTemplateMetadata(
        realTemplate: new CreateTicketCommandTemplate(
            clientId: static fn (ClientCreatedEvent $event) => $event->clientId,
        ),
        preDispatchCallback: static function (
            CreateTicketCommand $command,
            ClockInterface $clock,
        ): void {
            // Set a specific time for this command
            $clock->setCurrentTime(new DateTimeImmutable('2025-01-15 10:00:00'));
        },
    )
);

Pre-dispatch callback features:

  • Can access the command being dispatched
  • Can inject services from the application container
  • Can access results from the context using event type hints
  • Executed just before $commandBus->dispatch($command)

License

MIT License. See LICENSE file for details.