campaigns[$campaign->id ?? count($this->campaigns) + 1] = $campaign; } 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 []; } public function findReadyForProcessing(): array { return []; } 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 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 Testing class InMemoryRegistrationRepository implements PreSaveRegistrationRepositoryInterface { private array $registrations = []; public function addRegistration(PreSaveRegistration $registration): void { $this->registrations[] = $registration; } 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 []; } public function findRetryable(int $campaignId, int $maxRetries): array { return []; } 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; } } // In-Memory OAuth Service for Testing class InMemoryOAuthService implements OAuthServiceInterface { private array $providers = []; public function addProvider(string $userId, string $provider): void { $this->providers[$userId . '_' . $provider] = true; } 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): \App\Framework\OAuth\Storage\StoredOAuthToken { throw new \RuntimeException('Not implemented in test'); } public function getTokenForUser(string $userId, string $provider): \App\Framework\OAuth\Storage\StoredOAuthToken { throw new \RuntimeException('Not implemented in test'); } public function refreshToken(\App\Framework\OAuth\Storage\StoredOAuthToken $storedToken): \App\Framework\OAuth\Storage\StoredOAuthToken { throw new \RuntimeException('Not implemented in test'); } public function revokeToken(string $userId, string $provider): bool { throw new \RuntimeException('Not implemented in test'); } 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->providers[$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('PreSaveCampaignService', function () { beforeEach(function () { $this->campaignRepo = new InMemoryCampaignRepository(); $this->registrationRepo = new InMemoryRegistrationRepository(); $this->oauthService = new InMemoryOAuthService(); $this->service = new PreSaveCampaignService( $this->campaignRepo, $this->registrationRepo, $this->oauthService ); }); describe('registerUser', function () { it('successfully registers user for campaign', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user123', StreamingPlatform::SPOTIFY->getOAuthProvider()); $registration = $this->service->registerUser('user123', $campaign->id, StreamingPlatform::SPOTIFY); expect($registration->userId)->toBe('user123'); expect($registration->campaignId)->toBe($campaign->id); expect($registration->platform)->toBe(StreamingPlatform::SPOTIFY); expect($registration->status)->toBe(RegistrationStatus::PENDING); }); it('throws exception when campaign not found', function () { expect(fn() => $this->service->registerUser('user123', 999, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); it('throws exception when campaign not accepting registrations', function () { $campaign = PreSaveCampaign::create( title: 'Old Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('-7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()->markAsCompleted()); expect(fn() => $this->service->registerUser('user123', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); it('throws exception when platform not supported', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); expect(fn() => $this->service->registerUser('user123', $campaign->id, StreamingPlatform::TIDAL)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); it('throws exception when user already registered', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user123', StreamingPlatform::SPOTIFY->getOAuthProvider()); $existingReg = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY); $this->registrationRepo->addRegistration($existingReg); expect(fn() => $this->service->registerUser('user123', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); it('throws exception when no OAuth token exists', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); expect(fn() => $this->service->registerUser('user123', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); }); describe('cancelRegistration', function () { it('successfully cancels pending registration', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY); $registration = $this->registrationRepo->save($registration); $result = $this->service->cancelRegistration('user123', $campaign->id, StreamingPlatform::SPOTIFY); expect($result)->toBeTrue(); }); it('returns false when registration not found', function () { $result = $this->service->cancelRegistration('user123', 999, StreamingPlatform::SPOTIFY); expect($result)->toBeFalse(); }); it('throws exception when trying to cancel completed registration', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $registration = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY); $registration = $registration->markAsCompleted(); $this->registrationRepo->addRegistration($registration); expect(fn() => $this->service->cancelRegistration('user123', $campaign->id, StreamingPlatform::SPOTIFY)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); }); describe('getCampaignStats', function () { it('returns comprehensive campaign statistics', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $stats = $this->service->getCampaignStats($campaign->id); expect($stats)->toBeArray(); expect(isset($stats['campaign']))->toBeTrue(); expect(isset($stats['total_registrations']))->toBeTrue(); expect(isset($stats['completed']))->toBeTrue(); expect(isset($stats['pending']))->toBeTrue(); expect(isset($stats['failed']))->toBeTrue(); expect(isset($stats['status_breakdown']))->toBeTrue(); expect(isset($stats['days_until_release']))->toBeTrue(); }); it('throws exception when campaign not found', function () { expect(fn() => $this->service->getCampaignStats(999)) ->toThrow(FrameworkException::class); })->throws(FrameworkException::class); }); describe('canUserRegister', function () { it('returns true when user can register', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user123', StreamingPlatform::SPOTIFY->getOAuthProvider()); $canRegister = $this->service->canUserRegister('user123', $campaign->id, StreamingPlatform::SPOTIFY); expect($canRegister)->toBeTrue(); }); it('returns false when campaign not found', function () { $canRegister = $this->service->canUserRegister('user123', 999, StreamingPlatform::SPOTIFY); expect($canRegister)->toBeFalse(); }); it('returns false when campaign not accepting registrations', function () { $campaign = PreSaveCampaign::create( title: 'Old Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('-7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()->markAsCompleted()); $canRegister = $this->service->canUserRegister('user123', $campaign->id, StreamingPlatform::SPOTIFY); expect($canRegister)->toBeFalse(); }); it('returns false when user already registered', function () { $campaign = PreSaveCampaign::create( title: 'New Album', artistName: 'Test Artist', coverImageUrl: 'https://example.com/cover.jpg', releaseDate: Timestamp::fromDateTime(new \DateTimeImmutable('+7 days')), trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'track123')] ); $campaign = $this->campaignRepo->save($campaign->publish()); $this->oauthService->addProvider('user123', StreamingPlatform::SPOTIFY->getOAuthProvider()); $reg = PreSaveRegistration::create($campaign->id, 'user123', StreamingPlatform::SPOTIFY); $this->registrationRepo->addRegistration($reg); $canRegister = $this->service->canUserRegister('user123', $campaign->id, StreamingPlatform::SPOTIFY); expect($canRegister)->toBeFalse(); }); }); });