Skip to content
14 changes: 14 additions & 0 deletions .cursor/rules/01-architecture.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ $container = new Container();
$service = $container->build(TestService::class);
```

**Test Mocking:** The Container supports `bind()` for registering mock instances in tests.

```php
// Register mocks for testing
$container = new Container();
$container->bind(SSHService::class, $mockSSH);
$container->bind(EnvService::class, $mockEnv);

// Container uses bound instances instead of auto-wiring
$command = $container->build(ServerAddCommand::class); // Gets mocks
```

This enables isolated testing while maintaining production auto-wiring behavior.

### Command Layer

- Commands handle user interaction (input/output) and orchestrate Services
Expand Down
51 changes: 46 additions & 5 deletions .cursor/rules/02-tests.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,64 @@ alwaysApply: true
**✅ PREFERRED - Manual Instantiation (Unit Tests):**

```php
// Testing services in isolation
$mockFs = mockFilesystem(true, 'content');
$service = new EnvService(new FilesystemService($mockFs), new Dotenv());
```

Benefits: Clear dependency wiring, easy mock injection, no container overhead.

**✅ OPTIONAL - Container (Integration Tests):**
**✅ RECOMMENDED - Container with Bindings (Command/Integration Tests):**

```php
// Testing commands with mockCommandContainer()
$container = mockCommandContainer();
$command = $container->build(ServerAddCommand::class);

// Override specific services when needed
$customSSH = mockSSHServiceWithBehavior(canConnect: false);
$container = mockCommandContainer(ssh: $customSSH);
$command = $container->build(ServerAddCommand::class);

// Pre-populate inventory data
$container = mockCommandContainer(
inventoryData: ['servers' => [['name' => 'web1', 'host' => '192.168.1.1']]]
);
$command = $container->build(ServerListCommand::class);
```

Benefits: Sustainable (no updates needed when BaseCommand grows), consistent pattern, easy service overrides.

**✅ OPTIONAL - Container Auto-wiring (Edge Cases):**

```php
// Testing DI configuration or service integration
$container = new Container();
$container->bind(Filesystem::class, fn() => $mockFs);
$service = $container->build(EnvService::class);
$container->bind(Filesystem::class, $mockFs);
$service = $container->build(CustomService::class);
```

When to use: Testing multiple services together or verifying DI configuration.
When to use: Verifying DI configuration or testing multiple services together.

**Rule:** Unit tests (services/utilities) use manual instantiation. Command/integration tests use `mockCommandContainer()`.

**Maintenance Note:** When adding a new service to `BaseCommand`, update `mockCommandContainer()` in `tests/TestHelpers.php`:

```php
function mockCommandContainer(
?NewService $newService = null, // 1. Add parameter
// ... existing parameters
) {
// ...
$newService = $newService ?? mockNewService(); // 2. Build or use provided

// Bind services to container
$container->bind(NewService::class, $newService); // 3. Bind to container
// ...
}
```

**Rule:** In unit tests, prefer `new ClassName()` with explicit mocks. Save the container for integration tests.
This is the ONLY place you need to update for command testing.

### Test Minimalism

Expand Down
23 changes: 23 additions & 0 deletions app/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,27 @@ final class Container
/** @var array<string, bool> */
private array $resolving = [];

/** @var array<class-string, object> */
private array $bindings = [];

//
// Public
// -------------------------------------------------------------------------------

/**
* Bind a concrete instance to a class name for testing.
*
* @template T of object
* @param class-string<T> $className
* @param T $instance
* @return self
*/
public function bind(string $className, object $instance): self
{
$this->bindings[$className] = $instance;
return $this;
}

/**
* Build a class instance with auto-wired dependencies.
*
Expand All @@ -41,6 +58,12 @@ final class Container
*/
public function build(string $className): object
{
// Return bound instance if available
if (isset($this->bindings[$className])) {
/** @var T */
return $this->bindings[$className];
}

$this->guardAgainstCircularDependency($className);
$this->guardAgainstInvalidClass($className);

Expand Down
44 changes: 9 additions & 35 deletions tests/Integration/Console/Server/ServerAddCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
declare(strict_types=1);

use Bigpixelrocket\DeployerPHP\Console\Server\ServerAddCommand;
use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Bigpixelrocket\DeployerPHP\Services\SSHService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
Expand All @@ -17,18 +15,8 @@

function createServerAddCommandTester(?SSHService $sshService = null): CommandTester
{
$container = new Container();
$env = mockEnvService(true);
$inventory = mockInventoryService(true, ['servers' => []]);
$inventory->loadInventoryFile();

$repository = new ServerRepository();
$repository->loadInventory($inventory);

$ssh = $sshService ?? mockSSHService();
$prompter = mockPrompter();

$command = new ServerAddCommand($container, $env, $inventory, $repository, $ssh, $prompter);
$container = mockCommandContainer(ssh: $sshService);
$command = $container->build(ServerAddCommand::class);
return new CommandTester($command);
}

Expand Down Expand Up @@ -297,18 +285,7 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe
it('persists server data to inventory correctly', function () {
// ARRANGE
$sshService = mockSSHServiceWithBehavior(true);
$container = new Container();
$env = mockEnvService(true);
$inventory = mockInventoryService(true, ['servers' => []]);
$inventory->loadInventoryFile();

$repository = new ServerRepository();
$repository->loadInventory($inventory);

$prompter = mockPrompter();

$command = new ServerAddCommand($container, $env, $inventory, $repository, $sshService, $prompter);
$tester = new CommandTester($command);
$tester = createServerAddCommandTester($sshService);

// ACT - Provide all required options
ob_start();
Expand All @@ -323,15 +300,12 @@ function createServerAddCommandTester(?SSHService $sshService = null): CommandTe
]);
ob_end_clean();

// ASSERT - Verify server persisted in repository
$server = $repository->findByName('persisted-server');

expect($server)->not->toBeNull()
->and($server->name)->toBe('persisted-server')
->and($server->host)->toBe('10.20.30.40')
->and($server->port)->toBe(8022)
->and($server->username)->toBe('admin')
->and($server->privateKeyPath)->toBe('~/.ssh/admin_key');
// ASSERT - Verify server persisted by checking command output
$output = $tester->getDisplay();
expect($output)->toContain('✓')
->and($output)->toContain('Server added successfully')
->and($output)->toContain('persisted-server')
->and($output)->toContain('10.20.30.40');
});

it('displays complete server information before saving', function () {
Expand Down
17 changes: 2 additions & 15 deletions tests/Integration/Console/Server/ServerDeleteCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
declare(strict_types=1);

use Bigpixelrocket\DeployerPHP\Console\Server\ServerDeleteCommand;
use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

Expand All @@ -17,9 +15,6 @@

function createServerDeleteCommandTester(array $existingServers = []): CommandTester
{
$container = new Container();
$env = mockEnvService(true);

// Pre-populate repository with test servers
$inventoryData = empty($existingServers) ? ['servers' => []] : ['servers' => array_map(
fn (ServerDTO $server) => [
Expand All @@ -32,16 +27,8 @@ function createServerDeleteCommandTester(array $existingServers = []): CommandTe
$existingServers
)];

$inventory = mockInventoryService(true, $inventoryData);
$inventory->loadInventoryFile();

$repository = new ServerRepository();
$repository->loadInventory($inventory);

$ssh = mockSSHService();
$prompter = mockPrompter();

$command = new ServerDeleteCommand($container, $env, $inventory, $repository, $ssh, $prompter);
$container = mockCommandContainer(inventoryData: $inventoryData);
$command = $container->build(ServerDeleteCommand::class);
return new CommandTester($command);
}

Expand Down
17 changes: 2 additions & 15 deletions tests/Integration/Console/Server/ServerListCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
declare(strict_types=1);

use Bigpixelrocket\DeployerPHP\Console\Server\ServerListCommand;
use Bigpixelrocket\DeployerPHP\Container;
use Bigpixelrocket\DeployerPHP\DTOs\ServerDTO;
use Bigpixelrocket\DeployerPHP\Repositories\ServerRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

Expand All @@ -17,9 +15,6 @@

function createServerListCommandTester(array $existingServers = []): CommandTester
{
$container = new Container();
$env = mockEnvService(true);

// Pre-populate repository with test servers
$inventoryData = empty($existingServers) ? ['servers' => []] : ['servers' => array_map(
fn (ServerDTO $server) => [
Expand All @@ -32,16 +27,8 @@ function createServerListCommandTester(array $existingServers = []): CommandTest
$existingServers
)];

$inventory = mockInventoryService(true, $inventoryData);
$inventory->loadInventoryFile();

$repository = new ServerRepository();
$repository->loadInventory($inventory);

$ssh = mockSSHService();
$prompter = mockPrompter();

$command = new ServerListCommand($container, $env, $inventory, $repository, $ssh, $prompter);
$container = mockCommandContainer(inventoryData: $inventoryData);
$command = $container->build(ServerListCommand::class);
return new CommandTester($command);
}

Expand Down
61 changes: 40 additions & 21 deletions tests/TestHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
use Bigpixelrocket\DeployerPHP\Services\FilesystemService;
use Bigpixelrocket\DeployerPHP\Services\InventoryService;
use Bigpixelrocket\DeployerPHP\Services\ProcessFactory;
use Bigpixelrocket\DeployerPHP\Services\PrompterService;
use Bigpixelrocket\DeployerPHP\Services\SSHService;
use Bigpixelrocket\DeployerPHP\Services\VersionService;
use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockFilesystem;
use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockPrompter;
use Bigpixelrocket\DeployerPHP\Tests\Fixtures\MockSSHService;
use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestConsoleCommand;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;
Expand Down Expand Up @@ -345,39 +345,58 @@ function mockServerRepository(
// Command & Integration Test Mocks
// -------------------------------------------------------------------------------

if (!function_exists('mockTestConsoleCommand')) {
if (!function_exists('mockCommandContainer')) {
/**
* Create a TestConsoleCommand for testing with mocked dependencies.
* Create a Container with mocked dependencies for command testing.
*
* Returns a fully configured command with all dependencies injected.
* Useful for testing BaseCommand, console traits, and server helpers.
* Returns a Container with sensible mock defaults for all BaseCommand dependencies.
* Override specific services by passing them as arguments.
*
* @example
* // Default configuration
* $command = mockTestConsoleCommand();
* // Build command with default mocks
* $container = mockCommandContainer();
* $command = $container->build(ServerListCommand::class);
*
* @example
* // Override SSH service for connection testing
* $ssh = mockSSHServiceWithBehavior(canConnect: false);
* $container = mockCommandContainer(ssh: $ssh);
* $command = $container->build(ServerAddCommand::class);
*
* @example
* // Custom environment and inventory
* $command = mockTestConsoleCommand(
* envFileExists: true,
* envContent: 'API_KEY=secret',
* inventoryFileExists: true,
* inventoryData: ['servers' => []]
* // Override inventory data for pre-populated servers
* $container = mockCommandContainer(
* inventoryData: ['servers' => ['web1' => ['host' => '192.168.1.1']]]
* );
* $command = $container->build(ServerListCommand::class);
*/
function mockTestConsoleCommand(
function mockCommandContainer(
?SSHService $ssh = null,
?PrompterService $prompter = null,
?EnvService $env = null,
?InventoryService $inventory = null,
?ServerRepository $servers = null,
bool $envFileExists = true,
string $envContent = 'API_KEY=test_value',
bool $inventoryFileExists = true,
array|string $inventoryData = []
): TestConsoleCommand {
): Container {
$container = new Container();
$env = mockEnvService($envFileExists, $envContent);
$inventory = mockInventoryService($inventoryFileExists, $inventoryData);
$servers = mockServerRepository($inventoryFileExists, $inventoryData);
$ssh = mockSSHService();
$prompter = mockPrompter();

return new TestConsoleCommand($container, $env, $inventory, $servers, $ssh, $prompter);
// Build or use provided services
$env ??= mockEnvService($envFileExists, $envContent);
$inventory ??= mockInventoryService($inventoryFileExists, $inventoryData);
$servers ??= mockServerRepository($inventoryFileExists, $inventoryData);
$ssh ??= mockSSHService();
$prompter ??= mockPrompter();

// Bind services to container
$container->bind(EnvService::class, $env);
$container->bind(InventoryService::class, $inventory);
$container->bind(ServerRepository::class, $servers);
$container->bind(SSHService::class, $ssh);
$container->bind(PrompterService::class, $prompter);

return $container;
}
}
Loading