id ?? $this->nextId++; $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 { $now = Timestamp::now(); return array_values(array_filter( $this->campaigns, fn($c) => ($c->status === CampaignStatus::SCHEDULED || $c->status === CampaignStatus::ACTIVE) && $c->releaseDate->isBefore($now) )); } public function findReadyForProcessing(): array { return array_values(array_filter( $this->campaigns, fn($c) => $c->status === CampaignStatus::RELEASED )); } 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, ]; } } // In-Memory Registration Repository for Integration Testing class IntegrationRegistrationRepository implements PreSaveRegistrationRepositoryInterface { private array $registrations = []; private int $nextId = 1; public function save(PreSaveRegistration $registration): PreSaveRegistration { $id = $registration->id ?? $this->nextId++; $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 array_values(array_filter( $this->registrations, fn($r) => $r->campaignId === $campaignId && $r->status === RegistrationStatus::PENDING )); } public function findRetryable(int $campaignId, int $maxRetries): array { return array_values(array_filter( $this->registrations, fn($r) => $r->campaignId === $campaignId && $r->status === RegistrationStatus::FAILED && $r->retryCount < $maxRetries )); } public function getStatusCounts(int $campaignId): array { $counts = ['pending' => 0, 'completed' => 0, 'failed' => 0]; foreach ($this->registrations as $reg) { if ($reg->campaignId === $campaignId) { match ($reg->status) { RegistrationStatus::PENDING => $counts['pending']++, RegistrationStatus::COMPLETED => $counts['completed']++, RegistrationStatus::FAILED => $counts['failed']++, }; } } return $counts; } 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; } } // In-Memory OAuth Service for Integration Testing class IntegrationOAuthService implements OAuthServiceInterface { private array $providers = []; private array $tokens = []; public function addProvider(string $userId, string $provider): void { $this->providers[$userId . '_' . $provider] = true; // Create a token for this provider $this->tokens[$userId . '_' . $provider] = new StoredOAuthToken( id: null, // Auto-increment ID in real storage userId: $userId, provider: $provider, token: new OAuthToken( accessToken: AccessToken::create( 'test_access_token_' . $userId, Timestamp::now()->add(Duration::fromHours(1)) // Expires in 1 hour ), refreshToken: new RefreshToken('test_refresh_token_' . $userId), tokenType: TokenType::BEARER, scope: TokenScope::fromString('user-library-modify') ), createdAt: Timestamp::now(), updatedAt: Timestamp::now() ); } public function hasProvider(string $userId, string $provider): bool { return isset($this->providers[$userId . '_' . $provider]); } public function getTokenForUser(string $userId, string $provider): StoredOAuthToken { $key = $userId . '_' . $provider; if (!isset($this->tokens[$key])) { throw new \RuntimeException("No token for {$userId}/{$provider}"); } return $this->tokens[$key]; } public function getProvider(string $name): \App\Framework\OAuth\OAuthProvider { throw new \RuntimeException('Not implemented in integration test'); } public function getAuthorizationUrl(string $provider, array $options = []): string { throw new \RuntimeException('Not implemented in integration test'); } public function handleCallback(string $userId, string $provider, string $code, ?string $state = null): \App\Framework\OAuth\Storage\StoredOAuthToken { throw new \RuntimeException('Not implemented in integration test'); } public function refreshToken(\App\Framework\OAuth\Storage\StoredOAuthToken $storedToken): \App\Framework\OAuth\Storage\StoredOAuthToken { throw new \RuntimeException('Not implemented in integration test'); } public function revokeToken(string $userId, string $provider): bool { throw new \RuntimeException('Not implemented in integration test'); } public function getUserProfile(string $userId, string $provider): array { throw new \RuntimeException('Not implemented in integration test'); } public function getUserProviders(string $userId): array { throw new \RuntimeException('Not implemented in integration test'); } public function refreshExpiringTokens(int $withinSeconds = 300): int { throw new \RuntimeException('Not implemented in integration test'); } public function cleanupExpiredTokens(): int { throw new \RuntimeException('Not implemented in integration test'); } } // Mock Music Provider for Integration Testing (implements SupportsPreSaves) class IntegrationMusicProvider 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[] = [ 'track_id' => $trackId, 'token' => $token->accessToken, 'timestamp' => time() ]; } return true; } } // Mock Logger for Integration Testing class IntegrationLogger 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); } } } describe('PreSave Integration Tests', function () { beforeEach(function () { $this->campaignRepo = new IntegrationCampaignRepository(); $this->registrationRepo = new IntegrationRegistrationRepository(); $this->oauthService = new IntegrationOAuthService(); $this->logger = new IntegrationLogger(); $this->musicProvider = new IntegrationMusicProvider(); $this->campaignService = new PreSaveCampaignService( $this->campaignRepo, $this->registrationRepo, $this->oauthService ); $this->processor = new PreSaveProcessor( $this->campaignRepo, $this->registrationRepo, $this->oauthService, $this->musicProvider, $this->logger ); }); describe('Full Campaign Lifecycle', function () { it('completes full campaign workflow from creation to processing', function () { // 1. Create and publish campaign $campaign = PreSaveCampaign::create( title: 'Integration Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:test123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); expect($campaign->id)->not->toBeNull(); expect($campaign->status)->toBe(CampaignStatus::SCHEDULED); // 2. Register multiple users $this->oauthService->addProvider('user1', StreamingPlatform::SPOTIFY->getOAuthProvider()); $this->oauthService->addProvider('user2', StreamingPlatform::SPOTIFY->getOAuthProvider()); $this->oauthService->addProvider('user3', StreamingPlatform::SPOTIFY->getOAuthProvider()); $reg1 = $this->campaignService->registerUser('user1', $campaign->id, StreamingPlatform::SPOTIFY); $reg2 = $this->campaignService->registerUser('user2', $campaign->id, StreamingPlatform::SPOTIFY); $reg3 = $this->campaignService->registerUser('user3', $campaign->id, StreamingPlatform::SPOTIFY); expect($reg1->status)->toBe(RegistrationStatus::PENDING); expect($reg2->status)->toBe(RegistrationStatus::PENDING); expect($reg3->status)->toBe(RegistrationStatus::PENDING); // 3. Simulate release date passing $releasedCampaign = $campaign->markAsReleased(); $this->campaignRepo->save($releasedCampaign); // 4. Process registrations $result = $this->processor->processCampaignRegistrations($releasedCampaign); expect($result['processed'])->toBe(3); expect($result['successful'])->toBe(3); expect($result['failed'])->toBe(0); // 5. Verify tracks were added to music provider expect($this->musicProvider->addedTracks)->toHaveCount(3); expect($this->musicProvider->addedTracks[0]['track_id'])->toBe('spotify:track:test123'); // 6. Verify logging $successLogs = array_filter( $this->logger->logs, fn($log) => $log['level'] === 'info' && str_contains($log['message'], 'successfully') ); expect($successLogs)->toHaveCount(3); echo "\n✅ Full campaign lifecycle completed successfully!\n"; echo " - Campaign created and published\n"; echo " - 3 users registered\n"; echo " - Campaign released and processed\n"; echo " - All tracks added to user libraries\n"; }); }); describe('User Registration Flow', function () { it('prevents duplicate registrations', function () { $campaign = PreSaveCampaign::create( title: 'Duplicate Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:dup123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user1', StreamingPlatform::SPOTIFY->getOAuthProvider()); // First registration should succeed $reg1 = $this->campaignService->registerUser('user1', $campaign->id, StreamingPlatform::SPOTIFY); expect($reg1)->not->toBeNull(); // Second registration should throw exception expect(fn() => $this->campaignService->registerUser('user1', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); }); it('validates OAuth provider exists before registration', function () { $campaign = PreSaveCampaign::create( title: 'OAuth Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:oauth123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); // User without OAuth provider should fail expect(fn() => $this->campaignService->registerUser('user_no_oauth', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); }); it('allows cancellation of pending registrations', function () { $campaign = PreSaveCampaign::create( title: 'Cancel Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:cancel123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user1', StreamingPlatform::SPOTIFY->getOAuthProvider()); $registration = $this->campaignService->registerUser('user1', $campaign->id, StreamingPlatform::SPOTIFY); expect($registration->status)->toBe(RegistrationStatus::PENDING); $cancelled = $this->campaignService->cancelRegistration('user1', $campaign->id, StreamingPlatform::SPOTIFY); expect($cancelled)->toBeTrue(); // Should be able to register again after cancellation $newRegistration = $this->campaignService->registerUser('user1', $campaign->id, StreamingPlatform::SPOTIFY); expect($newRegistration->status)->toBe(RegistrationStatus::PENDING); }); }); describe('Campaign Processing Flow', function () { it('marks campaigns as released when release date passes', function () { // Create campaign with past release date $pastDate = Timestamp::fromDateTime(new \DateTimeImmutable('-1 day')); $campaign = PreSaveCampaign::create( title: 'Past Release Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: $pastDate, trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:past123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); // Find campaigns ready for release $readyForRelease = $this->campaignRepo->findReadyForRelease(); expect($readyForRelease)->toHaveCount(1); expect($readyForRelease[0]->id)->toBe($campaign->id); // Mark as released $releasedCampaign = $campaign->markAsReleased(); $this->campaignRepo->save($releasedCampaign); // Should now appear in ready for processing $readyForProcessing = $this->campaignRepo->findReadyForProcessing(); expect($readyForProcessing)->toHaveCount(1); expect($readyForProcessing[0]->status)->toBe(CampaignStatus::RELEASED); }); it('handles processing errors gracefully', function () { $campaign = PreSaveCampaign::create( title: 'Error Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('-1 day')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:error123')] ); $campaign = $this->campaignRepo->save($campaign->publish()->markAsReleased()); // Register user but don't provide OAuth token $registration = PreSaveRegistration::create( $campaign->id, 'user_without_token', StreamingPlatform::SPOTIFY ); $this->registrationRepo->save($registration); // Processing should handle error $result = $this->processor->processCampaignRegistrations($campaign); expect($result['processed'])->toBe(1); expect($result['successful'])->toBe(0); expect($result['failed'])->toBe(1); // Verify error was logged $errorLogs = array_filter( $this->logger->logs, fn($log) => $log['level'] === 'error' ); expect($errorLogs)->not->toBeEmpty(); }); it('marks campaign as completed when all registrations processed', function () { $campaign = PreSaveCampaign::create( title: 'Complete Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('-1 day')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:complete123')] ); $campaign = $this->campaignRepo->save($campaign->publish()->markAsReleased()); $this->oauthService->addProvider('user1', StreamingPlatform::SPOTIFY->getOAuthProvider()); $registration = PreSaveRegistration::create($campaign->id, 'user1', StreamingPlatform::SPOTIFY); $this->registrationRepo->save($registration); // Process all registrations $result = $this->processor->processCampaignRegistrations($campaign); expect($result['successful'])->toBe(1); // No more pending registrations $pending = $this->registrationRepo->findPendingByCampaign($campaign->id); expect($pending)->toBeEmpty(); // Campaign should be marked as completed $completedCampaign = $campaign->markAsCompleted(); $this->campaignRepo->save($completedCampaign); $retrievedCampaign = $this->campaignRepo->findById($campaign->id); expect($retrievedCampaign->status)->toBe(CampaignStatus::COMPLETED); }); }); describe('Statistics and Monitoring', function () { it('provides accurate campaign statistics', function () { $campaign = PreSaveCampaign::create( title: 'Stats Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:stats123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $stats = $this->campaignService->getCampaignStats($campaign->id); expect($stats)->toBeArray(); expect($stats)->toHaveKey('campaign'); expect($stats)->toHaveKey('total_registrations'); expect($stats)->toHaveKey('days_until_release'); expect($stats['campaign'])->toBe($campaign); expect($stats['days_until_release'])->toBe(7); }); it('tracks registration status distribution', function () { $campaign = PreSaveCampaign::create( title: 'Distribution Test Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:dist123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); // Create registrations with different statuses $pending = PreSaveRegistration::create($campaign->id, 'user1', StreamingPlatform::SPOTIFY); $this->registrationRepo->save($pending); $completed = PreSaveRegistration::create($campaign->id, 'user2', StreamingPlatform::SPOTIFY); $this->registrationRepo->save($completed->markAsCompleted()); $failed = PreSaveRegistration::create($campaign->id, 'user3', StreamingPlatform::SPOTIFY); $this->registrationRepo->save($failed->markAsFailed('Test error')); $counts = $this->registrationRepo->getStatusCounts($campaign->id); expect($counts['pending'])->toBe(1); expect($counts['completed'])->toBe(1); expect($counts['failed'])->toBe(1); }); }); });