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(); }); }); });