- 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.
670 lines
26 KiB
PHP
670 lines
26 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\PreSaveCampaignService;
|
|
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\Exception\FrameworkException;
|
|
use App\Framework\Logging\Logger;
|
|
use App\Framework\OAuth\OAuthServiceInterface;
|
|
use App\Framework\OAuth\Providers\SupportsPreSaves;
|
|
use App\Framework\OAuth\Storage\StoredOAuthToken;
|
|
use App\Framework\OAuth\ValueObjects\OAuthToken;
|
|
|
|
// In-Memory Campaign Repository for Integration Testing
|
|
class IntegrationCampaignRepository implements PreSaveCampaignRepositoryInterface
|
|
{
|
|
private array $campaigns = [];
|
|
private int $nextId = 1;
|
|
|
|
public function save(PreSaveCampaign $campaign): PreSaveCampaign
|
|
{
|
|
$id = $campaign->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->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(
|
|
userId: $userId,
|
|
provider: $provider,
|
|
token: new OAuthToken(
|
|
accessToken: 'test_access_token_' . $userId,
|
|
tokenType: 'Bearer',
|
|
expiresIn: 3600,
|
|
refreshToken: 'test_refresh_token_' . $userId,
|
|
scope: ['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);
|
|
});
|
|
});
|
|
});
|