- Move 12 markdown files from root to docs/ subdirectories - Organize documentation by category: • docs/troubleshooting/ (1 file) - Technical troubleshooting guides • docs/deployment/ (4 files) - Deployment and security documentation • docs/guides/ (3 files) - Feature-specific guides • docs/planning/ (4 files) - Planning and improvement proposals Root directory cleanup: - Reduced from 16 to 4 markdown files in root - Only essential project files remain: • CLAUDE.md (AI instructions) • README.md (Main project readme) • CLEANUP_PLAN.md (Current cleanup plan) • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements) This improves: ✅ Documentation discoverability ✅ Logical organization by purpose ✅ Clean root directory ✅ Better maintainability
315 lines
10 KiB
PHP
315 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Domain\Common\ValueObject\Email;
|
|
use App\Framework\Logging\ChannelLogger;
|
|
use App\Framework\Logging\LogChannel;
|
|
use App\Framework\Logging\Logger;
|
|
use App\Framework\Logging\LogLevel;
|
|
use App\Framework\Logging\ValueObjects\LogContext;
|
|
use App\Framework\Mail\Commands\SendEmailCommand;
|
|
use App\Framework\Mail\Commands\SendEmailCommandHandler;
|
|
use App\Framework\Mail\EmailList;
|
|
use App\Framework\Mail\Exceptions\SmtpException;
|
|
use App\Framework\Mail\Message;
|
|
use App\Framework\Mail\Testing\MockTransport;
|
|
use App\Framework\Mail\TransportResult;
|
|
|
|
describe('SendEmailCommandHandler', function () {
|
|
beforeEach(function () {
|
|
$this->transport = new MockTransport();
|
|
$this->logger = new MailTestLogger();
|
|
$this->handler = new SendEmailCommandHandler($this->transport, $this->logger);
|
|
|
|
$this->message = new Message(
|
|
from: new Email('sender@example.com'),
|
|
subject: 'Test Subject',
|
|
body: 'Test body',
|
|
to: new EmailList(new Email('recipient@example.com'))
|
|
);
|
|
|
|
$this->command = new SendEmailCommand($this->message);
|
|
});
|
|
|
|
it('handles successful email sending', function () {
|
|
$this->handler->handle($this->command);
|
|
|
|
expect($this->transport->getSentMessageCount())->toBe(1);
|
|
expect($this->transport->getLastSentMessage()['message'])->toBe($this->message);
|
|
expect($this->logger->hasInfo('Sending email via queue'))->toBeTrue();
|
|
expect($this->logger->hasInfo('Email sent successfully'))->toBeTrue();
|
|
});
|
|
|
|
it('logs transport failure and throws SmtpException', function () {
|
|
$this->transport->setShouldFail(true, 'SMTP connection failed');
|
|
|
|
expect(fn () => $this->handler->handle($this->command))
|
|
->toThrow(SmtpException::class, 'Failed to send email: SMTP connection failed');
|
|
|
|
expect($this->logger->hasInfo('Sending email via queue'))->toBeTrue();
|
|
expect($this->logger->hasError('Email sending failed'))->toBeTrue();
|
|
});
|
|
|
|
it('logs and wraps unexpected exceptions', function () {
|
|
// Create a transport that throws unexpected exception
|
|
$failingTransport = new FailingTransport('Unexpected error');
|
|
|
|
$handler = new SendEmailCommandHandler($failingTransport, $this->logger);
|
|
|
|
expect(fn () => $handler->handle($this->command))
|
|
->toThrow(SmtpException::class, 'Unexpected error while sending email: Unexpected error');
|
|
|
|
expect($this->logger->hasInfo('Sending email via queue'))->toBeTrue();
|
|
expect($this->logger->hasError('Unexpected exception during email sending'))->toBeTrue();
|
|
});
|
|
|
|
it('re-throws SmtpException for retry logic', function () {
|
|
// Create a transport that throws SmtpException
|
|
$failingTransport = new SmtpFailingTransport();
|
|
|
|
$handler = new SendEmailCommandHandler($failingTransport, $this->logger);
|
|
|
|
expect(fn () => $handler->handle($this->command))
|
|
->toThrow(SmtpException::class, 'Failed to connect to SMTP server localhost:587: Connection refused');
|
|
|
|
expect($this->logger->hasInfo('Sending email via queue'))->toBeTrue();
|
|
expect($this->logger->hasError('SMTP exception during email sending'))->toBeTrue();
|
|
});
|
|
|
|
it('includes transport name in logs', function () {
|
|
$this->handler->handle($this->command);
|
|
|
|
$logs = $this->logger->getLogs();
|
|
expect($logs[0]['context']['transport'])->toBe('Mock Transport');
|
|
expect($logs[1]['context']['transport'])->toBe('Mock Transport');
|
|
});
|
|
|
|
it('includes message details in logs', function () {
|
|
$this->handler->handle($this->command);
|
|
|
|
$logs = $this->logger->getLogs();
|
|
expect($logs[0]['context']['to'])->toBe('recipient@example.com');
|
|
expect($logs[0]['context']['subject'])->toBe('Test Subject');
|
|
expect($logs[1]['context']['to'])->toBe('recipient@example.com');
|
|
expect($logs[1]['context']['subject'])->toBe('Test Subject');
|
|
expect($logs[1]['context'])->toHaveKey('message_id');
|
|
});
|
|
|
|
it('includes error details in failure logs', function () {
|
|
$this->transport->setShouldFail(true, 'Custom error message');
|
|
|
|
expect(fn () => $this->handler->handle($this->command))
|
|
->toThrow(SmtpException::class);
|
|
|
|
$logs = $this->logger->getLogs();
|
|
$errorLog = $logs[1]; // Second log should be the error
|
|
expect($errorLog['context']['error'])->toBe('Custom error message');
|
|
expect($errorLog['context']['to'])->toBe('recipient@example.com');
|
|
expect($errorLog['context']['subject'])->toBe('Test Subject');
|
|
expect($errorLog['context'])->toHaveKey('metadata');
|
|
});
|
|
});
|
|
|
|
// Test stub for mail handler
|
|
class MailTestLogger implements Logger
|
|
{
|
|
private array $logs = [];
|
|
|
|
public function emergency(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'emergency', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function alert(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'alert', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function critical(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'critical', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function error(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'error', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function warning(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'warning', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function notice(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'notice', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function info(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'info', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function debug(string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => 'debug', 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function log(LogLevel $level, string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => $level->value, 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
public function getLogs(): array
|
|
{
|
|
return $this->logs;
|
|
}
|
|
|
|
public function hasInfo(string $message): bool
|
|
{
|
|
return $this->hasLevel('info', $message);
|
|
}
|
|
|
|
public function hasError(string $message): bool
|
|
{
|
|
return $this->hasLevel('error', $message);
|
|
}
|
|
|
|
public function hasWarning(string $message): bool
|
|
{
|
|
return $this->hasLevel('warning', $message);
|
|
}
|
|
|
|
public function hasDebug(string $message): bool
|
|
{
|
|
return $this->hasLevel('debug', $message);
|
|
}
|
|
|
|
private function hasLevel(string $level, string $message): bool
|
|
{
|
|
foreach ($this->logs as $log) {
|
|
if ($log['level'] === $level && $log['message'] === $message) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function logToChannel(LogChannel $channel, LogLevel $level, string $message, ?LogContext $context = null): void
|
|
{
|
|
$this->logs[] = ['level' => $level->value, 'channel' => $channel->value, 'message' => $message, 'context' => $context];
|
|
}
|
|
|
|
private ?ChannelLogger $mockChannelLogger = null;
|
|
|
|
public ChannelLogger $security {
|
|
get {
|
|
return $this->mockChannelLogger ??= $this->createMockChannelLogger();
|
|
}
|
|
}
|
|
|
|
public ChannelLogger $cache {
|
|
get {
|
|
return $this->mockChannelLogger ??= $this->createMockChannelLogger();
|
|
}
|
|
}
|
|
|
|
public ChannelLogger $database {
|
|
get {
|
|
return $this->mockChannelLogger ??= $this->createMockChannelLogger();
|
|
}
|
|
}
|
|
|
|
public ChannelLogger $framework {
|
|
get {
|
|
return $this->mockChannelLogger ??= $this->createMockChannelLogger();
|
|
}
|
|
}
|
|
|
|
public ChannelLogger $error {
|
|
get {
|
|
return $this->mockChannelLogger ??= $this->createMockChannelLogger();
|
|
}
|
|
}
|
|
|
|
private function createMockChannelLogger(): ChannelLogger
|
|
{
|
|
return new class () implements ChannelLogger {
|
|
public function emergency(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function alert(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function critical(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function error(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function warning(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function notice(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function info(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
|
|
public function debug(string $message, ?LogContext $context = null): void
|
|
{
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// Test transport classes
|
|
class FailingTransport implements \App\Framework\Mail\TransportInterface
|
|
{
|
|
public function __construct(private string $errorMessage)
|
|
{
|
|
}
|
|
|
|
public function send(Message $message): TransportResult
|
|
{
|
|
throw new \RuntimeException($this->errorMessage);
|
|
}
|
|
|
|
public function isAvailable(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function getName(): string
|
|
{
|
|
return 'Failing Transport';
|
|
}
|
|
}
|
|
|
|
class SmtpFailingTransport implements \App\Framework\Mail\TransportInterface
|
|
{
|
|
public function send(Message $message): TransportResult
|
|
{
|
|
throw SmtpException::connectionFailed('localhost', 587, 'Connection refused');
|
|
}
|
|
|
|
public function isAvailable(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function getName(): string
|
|
{
|
|
return 'SMTP Failing Transport';
|
|
}
|
|
}
|