- 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.
460 lines
18 KiB
PHP
460 lines
18 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\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\OAuth\OAuthServiceInterface;
|
|
use App\Framework\OAuth\ValueObjects\OAuthToken;
|
|
|
|
// In-Memory Campaign Repository for Testing
|
|
class InMemoryCampaignRepository implements PreSaveCampaignRepositoryInterface
|
|
{
|
|
private array $campaigns = [];
|
|
|
|
public function addCampaign(PreSaveCampaign $campaign): void
|
|
{
|
|
$this->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();
|
|
});
|
|
});
|
|
});
|