Files
michaelschiemer/tests/Unit/Domain/PreSave/Services/PreSaveProcessorTest.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

597 lines
22 KiB
PHP

<?php
declare(strict_types=1);
use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepositoryInterface;
use App\Domain\PreSave\PreSaveRegistration;
use App\Domain\PreSave\PreSaveRegistrationRepositoryInterface;
use App\Domain\PreSave\Services\PreSaveProcessor;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Domain\PreSave\ValueObjects\RegistrationStatus;
use App\Domain\PreSave\ValueObjects\StreamingPlatform;
use App\Domain\PreSave\ValueObjects\TrackUrl;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Logging\Logger;
use App\Framework\OAuth\OAuthServiceInterface;
use App\Framework\OAuth\Providers\SupportsPreSaves;
use App\Framework\OAuth\ValueObjects\AccessToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\OAuth\ValueObjects\RefreshToken;
use App\Framework\OAuth\ValueObjects\TokenType;
use App\Framework\OAuth\Storage\StoredOAuthToken;
// Helper function to create test OAuthToken
function createTestToken(string $accessToken = 'test_access', int $expiresIn = 3600): OAuthToken
{
return new OAuthToken(
accessToken: AccessToken::fromProviderResponse($accessToken, $expiresIn),
refreshToken: RefreshToken::create('test_refresh'),
tokenType: TokenType::BEARER,
scope: null
);
}
// Mock Music Provider for Testing (implements SupportsPreSaves)
class ProcessorMockMusicProvider implements SupportsPreSaves
{
public array $addedTracks = [];
public bool $shouldFail = false;
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
{
if ($this->shouldFail) {
throw new \Exception('Music API error');
}
foreach ($trackIds as $trackId) {
$this->addedTracks[] = $trackId;
}
return true;
}
}
// Mock Logger for Testing
class ProcessorMockLogger implements Logger
{
public array $logs = [];
public function debug(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'debug', 'message' => $message, 'context' => $context];
}
public function info(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'info', 'message' => $message, 'context' => $context];
}
public function notice(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'notice', 'message' => $message, 'context' => $context];
}
public function warning(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'warning', 'message' => $message, 'context' => $context];
}
public function error(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'error', 'message' => $message, 'context' => $context];
}
public function critical(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'critical', 'message' => $message, 'context' => $context];
}
public function alert(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'alert', 'message' => $message, 'context' => $context];
}
public function emergency(string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => 'emergency', 'message' => $message, 'context' => $context];
}
public function log(\App\Framework\Logging\LogLevel $level, string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['level' => $level->value, 'message' => $message, 'context' => $context];
}
public function logToChannel(\App\Framework\Logging\LogChannel $channel, \App\Framework\Logging\LogLevel $level, string $message, ?\App\Framework\Logging\ValueObjects\LogContext $context = null): void
{
$this->logs[] = ['channel' => $channel->value, 'level' => $level->value, 'message' => $message, 'context' => $context];
}
public \App\Framework\Logging\ChannelLogger $security {
get {
static $securityLogger = null;
return $securityLogger ??= new \App\Framework\Logging\ChannelLogger($this, \App\Framework\Logging\LogChannel::SECURITY);
}
}
public \App\Framework\Logging\ChannelLogger $cache {
get {
static $cacheLogger = null;
return $cacheLogger ??= new \App\Framework\Logging\ChannelLogger($this, \App\Framework\Logging\LogChannel::CACHE);
}
}
public \App\Framework\Logging\ChannelLogger $database {
get {
static $dbLogger = null;
return $dbLogger ??= new \App\Framework\Logging\ChannelLogger($this, \App\Framework\Logging\LogChannel::DATABASE);
}
}
public \App\Framework\Logging\ChannelLogger $framework {
get {
static $frameworkLogger = null;
return $frameworkLogger ??= new \App\Framework\Logging\ChannelLogger($this, \App\Framework\Logging\LogChannel::FRAMEWORK);
}
}
public \App\Framework\Logging\ChannelLogger $error {
get {
static $errorLogger = null;
return $errorLogger ??= new \App\Framework\Logging\ChannelLogger($this, \App\Framework\Logging\LogChannel::ERROR);
}
}
}
// Extended Mock Campaign Repository for Processor
class ProcessorMockCampaignRepository implements PreSaveCampaignRepositoryInterface
{
private array $campaigns = [];
public array $readyForRelease = [];
public array $readyForProcessing = [];
public function save(PreSaveCampaign $campaign): PreSaveCampaign
{
$id = $campaign->id ?? count($this->campaigns) + 1;
$saved = new PreSaveCampaign(
id: $id,
status: $campaign->status,
title: $campaign->title,
artistName: $campaign->artistName,
coverImageUrl: $campaign->coverImageUrl,
releaseDate: $campaign->releaseDate,
trackUrls: $campaign->trackUrls,
description: $campaign->description,
startDate: $campaign->startDate,
createdAt: $campaign->createdAt,
updatedAt: $campaign->updatedAt
);
$this->campaigns[$id] = $saved;
return $saved;
}
public function findById(int $id): ?PreSaveCampaign
{
return $this->campaigns[$id] ?? null;
}
public function findAll(array $filters = []): array
{
return array_values($this->campaigns);
}
public function findReadyForRelease(): array
{
return $this->readyForRelease;
}
public function findReadyForProcessing(): array
{
return $this->readyForProcessing;
}
public function delete(int $id): bool
{
unset($this->campaigns[$id]);
return true;
}
public function getStatistics(int $campaignId): array
{
return [
'total_registrations' => 10,
'completed' => 5,
'pending' => 3,
'failed' => 2,
];
}
}
// Extended Mock Registration Repository for Processor
class ProcessorMockRegistrationRepository implements PreSaveRegistrationRepositoryInterface
{
private array $registrations = [];
public array $pendingByCampaign = [];
public array $retryable = [];
public function save(PreSaveRegistration $registration): PreSaveRegistration
{
$id = $registration->id ?? count($this->registrations) + 1;
$saved = new PreSaveRegistration(
id: $id,
campaignId: $registration->campaignId,
userId: $registration->userId,
platform: $registration->platform,
status: $registration->status,
registeredAt: $registration->registeredAt,
processedAt: $registration->processedAt,
errorMessage: $registration->errorMessage,
retryCount: $registration->retryCount
);
$this->registrations[$id] = $saved;
return $saved;
}
public function findForUserAndCampaign(string $userId, int $campaignId, StreamingPlatform $platform): ?PreSaveRegistration
{
foreach ($this->registrations as $reg) {
if ($reg->userId === $userId && $reg->campaignId === $campaignId && $reg->platform === $platform) {
return $reg;
}
}
return null;
}
public function findByUserId(string $userId): array
{
return array_values(array_filter($this->registrations, fn($r) => $r->userId === $userId));
}
public function findPendingByCampaign(int $campaignId): array
{
return $this->pendingByCampaign[$campaignId] ?? [];
}
public function findRetryable(int $campaignId, int $maxRetries): array
{
return $this->retryable[$campaignId] ?? [];
}
public function getStatusCounts(int $campaignId): array
{
return [
'pending' => 3,
'completed' => 5,
'failed' => 2,
];
}
public function delete(int $id): bool
{
unset($this->registrations[$id]);
return true;
}
public function hasRegistered(string $userId, int $campaignId, StreamingPlatform $platform): bool
{
return $this->findForUserAndCampaign($userId, $campaignId, $platform) !== null;
}
}
// Mock OAuth Service for Processor
class ProcessorMockOAuthService implements OAuthServiceInterface
{
private array $tokens = [];
public function addToken(string $userId, StreamingPlatform $platform, OAuthToken $token): void
{
$this->tokens[$userId . '_' . $platform->value] = $token;
}
public function getProvider(string $name): \App\Framework\OAuth\OAuthProvider
{
throw new \RuntimeException('Not implemented in test');
}
public function getAuthorizationUrl(string $provider, array $options = []): string
{
throw new \RuntimeException('Not implemented in test');
}
public function handleCallback(string $userId, string $provider, string $code, ?string $state = null): StoredOAuthToken
{
throw new \RuntimeException('Not implemented in test');
}
public function getTokenForUser(string $userId, string $provider): StoredOAuthToken
{
$key = $userId . '_' . $provider;
$token = $this->tokens[$key] ?? throw new \RuntimeException("No token for {$userId}/{$provider}");
return new StoredOAuthToken(
id: null,
userId: $userId,
provider: $provider,
token: $token,
createdAt: Timestamp::now(),
updatedAt: Timestamp::now()
);
}
public function refreshToken(StoredOAuthToken $storedToken): StoredOAuthToken
{
throw new \RuntimeException('Not implemented in test');
}
public function revokeToken(string $userId, string $provider): bool
{
return true;
}
public function getUserProfile(string $userId, string $provider): array
{
throw new \RuntimeException('Not implemented in test');
}
public function hasProvider(string $userId, string $provider): bool
{
return isset($this->tokens[$userId . '_' . $provider]);
}
public function getUserProviders(string $userId): array
{
throw new \RuntimeException('Not implemented in test');
}
public function refreshExpiringTokens(int $withinSeconds = 300): int
{
throw new \RuntimeException('Not implemented in test');
}
public function cleanupExpiredTokens(): int
{
throw new \RuntimeException('Not implemented in test');
}
}
describe('PreSaveProcessor', function () {
beforeEach(function () {
$this->campaignRepo = new ProcessorMockCampaignRepository();
$this->registrationRepo = new ProcessorMockRegistrationRepository();
$this->oauthService = new ProcessorMockOAuthService();
$this->musicProvider = new ProcessorMockMusicProvider();
$this->logger = new ProcessorMockLogger();
$this->processor = new PreSaveProcessor(
$this->campaignRepo,
$this->registrationRepo,
$this->oauthService,
$this->musicProvider,
$this->logger
);
});
describe('processReleasedCampaigns', function () {
it('marks campaigns as released when release date reached', function () {
$campaign = PreSaveCampaign::create(
title: 'Released Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('-1 day')),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish();
$campaign = $this->campaignRepo->save($campaign);
$this->campaignRepo->readyForRelease = [$campaign];
$result = $this->processor->processReleasedCampaigns();
expect(isset($result['campaigns_marked_for_release']))->toBeTrue();
expect($result['campaigns_marked_for_release'])->toBe(1);
});
it('returns zero when no campaigns ready for release', function () {
$result = $this->processor->processReleasedCampaigns();
expect($result['campaigns_marked_for_release'])->toBe(0);
});
});
describe('processPendingRegistrations', function () {
it('processes pending registrations for released campaigns', function () {
$campaign = PreSaveCampaign::create(
title: 'Processing Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY);
$registration = $this->registrationRepo->save($registration);
$token = createTestToken('access_token_user123');
$this->oauthService->addToken('user123', StreamingPlatform::SPOTIFY, $token);
$this->campaignRepo->readyForProcessing = [$campaign];
$this->registrationRepo->pendingByCampaign[$campaign->id] = [$registration];
$result = $this->processor->processPendingRegistrations();
expect(isset($result['campaigns_processed']))->toBeTrue();
expect($result['campaigns_processed'])->toBe(1);
expect(isset($result['total_processed']))->toBeTrue();
expect(isset($result['total_successful']))->toBeTrue();
});
it('marks campaign as completed when no pending registrations remain', function () {
$campaign = PreSaveCampaign::create(
title: 'Complete Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$this->campaignRepo->readyForProcessing = [$campaign];
$this->registrationRepo->pendingByCampaign[$campaign->id] = [];
$result = $this->processor->processPendingRegistrations();
$updatedCampaign = $this->campaignRepo->findById($campaign->id);
expect($updatedCampaign->status)->toBe(CampaignStatus::COMPLETED);
});
});
describe('processCampaignRegistrations', function () {
it('processes all pending registrations for a campaign', function () {
$campaign = PreSaveCampaign::create(
title: 'Batch Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$reg1 = PreSaveRegistration::create($campaign->id, 'user1', StreamingPlatform::SPOTIFY);
$reg1 = $this->registrationRepo->save($reg1);
$reg2 = PreSaveRegistration::create($campaign->id, 'user2', StreamingPlatform::SPOTIFY);
$reg2 = $this->registrationRepo->save($reg2);
$token = createTestToken('access_token_users');
$this->oauthService->addToken('user1', StreamingPlatform::SPOTIFY, $token);
$this->oauthService->addToken('user2', StreamingPlatform::SPOTIFY, $token);
$this->registrationRepo->pendingByCampaign[$campaign->id] = [$reg1, $reg2];
$result = $this->processor->processCampaignRegistrations($campaign);
expect($result['processed'])->toBe(2);
expect($result['successful'])->toBe(2);
expect($result['failed'])->toBe(0);
});
it('handles registration processing failures gracefully', function () {
$campaign = PreSaveCampaign::create(
title: 'Failure Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY);
$registration = $this->registrationRepo->save($registration);
$token = createTestToken('access_token_user123');
$this->oauthService->addToken('user123', StreamingPlatform::SPOTIFY, $token);
$this->musicProvider->shouldFail = true;
$this->registrationRepo->pendingByCampaign[$campaign->id] = [$registration];
$result = $this->processor->processCampaignRegistrations($campaign);
expect($result['processed'])->toBe(1);
expect($result['successful'])->toBe(0);
expect($result['failed'])->toBe(1);
$hasError = false;
foreach ($this->logger->logs as $log) {
if ($log['level'] === 'error' && str_contains($log['message'], 'Failed to process')) {
$hasError = true;
}
}
expect($hasError)->toBeTrue();
});
});
describe('retryFailedRegistrations', function () {
it('retries failed registrations within retry limit', function () {
$campaign = PreSaveCampaign::create(
title: 'Retry Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY);
$registration = $registration->markAsFailed('Temporary error');
$registration = $this->registrationRepo->save($registration);
$token = createTestToken('access_token_user123');
$this->oauthService->addToken('user123', StreamingPlatform::SPOTIFY, $token);
$this->registrationRepo->retryable[$campaign->id] = [$registration];
$result = $this->processor->retryFailedRegistrations($campaign->id, 3);
expect($result['processed'])->toBe(1);
expect($result['successful'])->toBe(1);
});
it('does not retry registrations exceeding max retry count', function () {
$campaign = PreSaveCampaign::create(
title: 'Max Retry Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$this->registrationRepo->retryable[$campaign->id] = [];
$result = $this->processor->retryFailedRegistrations($campaign->id, 3);
expect($result['processed'])->toBe(0);
});
});
describe('Music platform integration', function () {
it('successfully adds tracks to user library', function () {
$campaign = PreSaveCampaign::create(
title: 'Music Album',
artistName: 'Test Artist',
coverImageUrl: 'https://example.com/cover.jpg',
releaseDate: Timestamp::now(),
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:test123')]
);
$campaign = $campaign->publish()->markAsReleased();
$campaign = $this->campaignRepo->save($campaign);
$registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY);
$registration = $this->registrationRepo->save($registration);
$token = createTestToken('access_token_user123');
$this->oauthService->addToken('user123', StreamingPlatform::SPOTIFY, $token);
$this->registrationRepo->pendingByCampaign[$campaign->id] = [$registration];
$this->processor->processCampaignRegistrations($campaign);
expect($this->musicProvider->addedTracks)->toHaveCount(1);
expect($this->musicProvider->addedTracks[0])->toBe('spotify:track:test123');
$hasInfo = false;
foreach ($this->logger->logs as $log) {
if ($log['level'] === 'info' && str_contains($log['message'], 'successfully')) {
$hasInfo = true;
}
}
expect($hasInfo)->toBeTrue();
});
});
});