- 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.
513 lines
16 KiB
PHP
513 lines
16 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\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;
|
|
|
|
// Mock Repository Implementations for Testing
|
|
|
|
class InMemoryPreSaveCampaignRepository implements PreSaveCampaignRepositoryInterface
|
|
{
|
|
/** @var array<int, PreSaveCampaign> */
|
|
private array $campaigns = [];
|
|
private int $nextId = 1;
|
|
|
|
public function save(PreSaveCampaign $campaign): PreSaveCampaign
|
|
{
|
|
// If campaign has no ID (new), use a wrapper to create with ID
|
|
if ($campaign->id === null) {
|
|
$campaign = $this->assignId($campaign);
|
|
}
|
|
|
|
$this->campaigns[$campaign->id] = $campaign;
|
|
return $campaign;
|
|
}
|
|
|
|
private function assignId(PreSaveCampaign $campaign): PreSaveCampaign
|
|
{
|
|
$id = $this->nextId++;
|
|
|
|
return new PreSaveCampaign(
|
|
id: $id,
|
|
title: $campaign->title,
|
|
artistName: $campaign->artistName,
|
|
coverImageUrl: $campaign->coverImageUrl,
|
|
releaseDate: $campaign->releaseDate,
|
|
trackUrls: $campaign->trackUrls,
|
|
status: $campaign->status,
|
|
createdAt: $campaign->createdAt,
|
|
updatedAt: Timestamp::now(),
|
|
description: $campaign->description,
|
|
startDate: $campaign->startDate
|
|
);
|
|
}
|
|
|
|
public function findById(int $id): ?PreSaveCampaign
|
|
{
|
|
return $this->campaigns[$id] ?? null;
|
|
}
|
|
|
|
/** @return array<PreSaveCampaign> */
|
|
public function findAll(array $filters = []): array
|
|
{
|
|
return array_values($this->campaigns);
|
|
}
|
|
|
|
/** @return array<PreSaveCampaign> */
|
|
public function findReadyForRelease(): array
|
|
{
|
|
$now = Timestamp::now();
|
|
return array_filter(
|
|
$this->campaigns,
|
|
function (PreSaveCampaign $c) use ($now) {
|
|
return $c->status->equals(CampaignStatus::ACTIVE)
|
|
&& $c->releaseDate->isBefore($now);
|
|
}
|
|
);
|
|
}
|
|
|
|
/** @return array<PreSaveCampaign> */
|
|
public function findReadyForProcessing(): array
|
|
{
|
|
return array_filter(
|
|
$this->campaigns,
|
|
fn(PreSaveCampaign $c) => $c->status->equals(CampaignStatus::RELEASED)
|
|
);
|
|
}
|
|
|
|
public function delete(int $id): bool
|
|
{
|
|
if (isset($this->campaigns[$id])) {
|
|
unset($this->campaigns[$id]);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** @return array<string, int> */
|
|
public function getStatistics(int $campaignId): array
|
|
{
|
|
return [
|
|
'total_registrations' => 0,
|
|
'completed' => 0,
|
|
'pending' => 0,
|
|
'failed' => 0,
|
|
];
|
|
}
|
|
|
|
public function clear(): void
|
|
{
|
|
$this->campaigns = [];
|
|
$this->nextId = 1;
|
|
}
|
|
}
|
|
|
|
class InMemoryPreSaveRegistrationRepository implements PreSaveRegistrationRepositoryInterface
|
|
{
|
|
/** @var array<int, PreSaveRegistration> */
|
|
private array $registrations = [];
|
|
private int $nextId = 1;
|
|
|
|
public function save(PreSaveRegistration $registration): PreSaveRegistration
|
|
{
|
|
if ($registration->id === null) {
|
|
$registration = $this->assignId($registration);
|
|
}
|
|
|
|
$this->registrations[$registration->id] = $registration;
|
|
return $registration;
|
|
}
|
|
|
|
private function assignId(PreSaveRegistration $registration): PreSaveRegistration
|
|
{
|
|
$id = $this->nextId++;
|
|
|
|
return 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
|
|
);
|
|
}
|
|
|
|
public function findForUserAndCampaign(
|
|
string $userId,
|
|
int $campaignId,
|
|
StreamingPlatform $platform
|
|
): ?PreSaveRegistration {
|
|
foreach ($this->registrations as $registration) {
|
|
if ($registration->userId === $userId
|
|
&& $registration->campaignId === $campaignId
|
|
&& $registration->platform->equals($platform)
|
|
) {
|
|
return $registration;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** @return array<PreSaveRegistration> */
|
|
public function findByUserId(string $userId): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->registrations,
|
|
fn(PreSaveRegistration $r) => $r->userId === $userId
|
|
));
|
|
}
|
|
|
|
/** @return array<PreSaveRegistration> */
|
|
public function findPendingByCampaign(int $campaignId): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->registrations,
|
|
function (PreSaveRegistration $r) use ($campaignId) {
|
|
return $r->campaignId === $campaignId
|
|
&& $r->status->equals(RegistrationStatus::PENDING);
|
|
}
|
|
));
|
|
}
|
|
|
|
/** @return array<PreSaveRegistration> */
|
|
public function findRetryable(int $campaignId, int $maxRetries): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->registrations,
|
|
function (PreSaveRegistration $r) use ($campaignId, $maxRetries) {
|
|
return $r->campaignId === $campaignId
|
|
&& $r->status->equals(RegistrationStatus::FAILED)
|
|
&& $r->retryCount < $maxRetries;
|
|
}
|
|
));
|
|
}
|
|
|
|
/** @return array<string, int> */
|
|
public function getStatusCounts(int $campaignId): array
|
|
{
|
|
$registrations = array_filter(
|
|
$this->registrations,
|
|
fn(PreSaveRegistration $r) => $r->campaignId === $campaignId
|
|
);
|
|
|
|
$counts = [
|
|
'pending' => 0,
|
|
'completed' => 0,
|
|
'failed' => 0,
|
|
];
|
|
|
|
foreach ($registrations as $registration) {
|
|
if ($registration->status->equals(RegistrationStatus::PENDING)) {
|
|
$counts['pending']++;
|
|
} elseif ($registration->status->equals(RegistrationStatus::COMPLETED)) {
|
|
$counts['completed']++;
|
|
} elseif ($registration->status->equals(RegistrationStatus::FAILED)) {
|
|
$counts['failed']++;
|
|
}
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
public function delete(int $id): bool
|
|
{
|
|
if (isset($this->registrations[$id])) {
|
|
unset($this->registrations[$id]);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public function clear(): void
|
|
{
|
|
$this->registrations = [];
|
|
$this->nextId = 1;
|
|
}
|
|
}
|
|
|
|
describe('PreSaveCampaignRepository', function () {
|
|
beforeEach(function () {
|
|
$this->repository = new InMemoryPreSaveCampaignRepository();
|
|
});
|
|
|
|
afterEach(function () {
|
|
$this->repository->clear();
|
|
});
|
|
|
|
it('saves and finds campaigns by id', function () {
|
|
$releaseDate = Timestamp::fromDateTime(new DateTimeImmutable('2025-01-15 00:00:00'));
|
|
|
|
$campaign = PreSaveCampaign::create(
|
|
title: 'Test Album',
|
|
artistName: 'Test Artist',
|
|
coverImageUrl: 'https://example.com/cover.jpg',
|
|
releaseDate: $releaseDate,
|
|
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:123')]
|
|
);
|
|
|
|
$saved = $this->repository->save($campaign);
|
|
|
|
expect($saved->id)->not->toBeNull();
|
|
|
|
$found = $this->repository->findById($saved->id);
|
|
|
|
expect($found)->not->toBeNull();
|
|
expect($found->id)->toBe($saved->id);
|
|
expect($found->title)->toBe('Test Album');
|
|
expect($found->artistName)->toBe('Test Artist');
|
|
});
|
|
|
|
it('returns null when campaign not found', function () {
|
|
$found = $this->repository->findById(999);
|
|
expect($found)->toBeNull();
|
|
});
|
|
|
|
it('finds all campaigns', function () {
|
|
$releaseDate1 = Timestamp::fromDateTime(new DateTimeImmutable('2025-01-15'));
|
|
$releaseDate2 = Timestamp::fromDateTime(new DateTimeImmutable('2025-01-20'));
|
|
|
|
$campaign1 = PreSaveCampaign::create(
|
|
title: 'Album 1',
|
|
artistName: 'Artist 1',
|
|
coverImageUrl: 'https://example.com/cover1.jpg',
|
|
releaseDate: $releaseDate1,
|
|
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:1')]
|
|
);
|
|
|
|
$campaign2 = PreSaveCampaign::create(
|
|
title: 'Album 2',
|
|
artistName: 'Artist 2',
|
|
coverImageUrl: 'https://example.com/cover2.jpg',
|
|
releaseDate: $releaseDate2,
|
|
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:2')]
|
|
);
|
|
|
|
$this->repository->save($campaign1);
|
|
$this->repository->save($campaign2);
|
|
|
|
$all = $this->repository->findAll();
|
|
|
|
expect($all)->toHaveCount(2);
|
|
expect($all[0]->title)->toBe('Album 1');
|
|
expect($all[1]->title)->toBe('Album 2');
|
|
});
|
|
|
|
it('updates existing campaigns', function () {
|
|
$releaseDate = Timestamp::fromDateTime(new DateTimeImmutable('2025-01-15'));
|
|
|
|
$campaign = PreSaveCampaign::create(
|
|
title: 'Original Title',
|
|
artistName: 'Artist',
|
|
coverImageUrl: 'https://example.com/cover.jpg',
|
|
releaseDate: $releaseDate,
|
|
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:1')]
|
|
);
|
|
|
|
$saved = $this->repository->save($campaign);
|
|
$updated = $saved->markAsReleased();
|
|
$this->repository->save($updated);
|
|
|
|
$found = $this->repository->findById($saved->id);
|
|
expect($found->status->equals(CampaignStatus::RELEASED))->toBeTrue();
|
|
});
|
|
|
|
it('deletes campaigns', function () {
|
|
$releaseDate = Timestamp::fromDateTime(new DateTimeImmutable('2025-01-15'));
|
|
|
|
$campaign = PreSaveCampaign::create(
|
|
title: 'Test Album',
|
|
artistName: 'Test Artist',
|
|
coverImageUrl: 'https://example.com/cover.jpg',
|
|
releaseDate: $releaseDate,
|
|
trackUrls: [TrackUrl::create(StreamingPlatform::SPOTIFY, 'spotify:track:123')]
|
|
);
|
|
|
|
$saved = $this->repository->save($campaign);
|
|
expect($this->repository->findById($saved->id))->not->toBeNull();
|
|
|
|
$deleted = $this->repository->delete($saved->id);
|
|
expect($deleted)->toBeTrue();
|
|
expect($this->repository->findById($saved->id))->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('PreSaveRegistrationRepository', function () {
|
|
beforeEach(function () {
|
|
$this->repository = new InMemoryPreSaveRegistrationRepository();
|
|
});
|
|
|
|
afterEach(function () {
|
|
$this->repository->clear();
|
|
});
|
|
|
|
it('saves and finds registrations', function () {
|
|
$registration = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_456',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$saved = $this->repository->save($registration);
|
|
|
|
expect($saved->id)->not->toBeNull();
|
|
expect($saved->campaignId)->toBe(1);
|
|
expect($saved->userId)->toBe('user_456');
|
|
expect($saved->platform->equals(StreamingPlatform::SPOTIFY))->toBeTrue();
|
|
});
|
|
|
|
it('finds registrations for user and campaign', function () {
|
|
$reg1 = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_1',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$reg2 = PreSaveRegistration::create(
|
|
campaignId: 2,
|
|
userId: 'user_1',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$saved1 = $this->repository->save($reg1);
|
|
$this->repository->save($reg2);
|
|
|
|
$found = $this->repository->findForUserAndCampaign(
|
|
'user_1',
|
|
1,
|
|
StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
expect($found)->not->toBeNull();
|
|
expect($found->id)->toBe($saved1->id);
|
|
expect($found->campaignId)->toBe(1);
|
|
});
|
|
|
|
it('finds registrations by user id', function () {
|
|
$reg1 = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_123',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$reg2 = PreSaveRegistration::create(
|
|
campaignId: 2,
|
|
userId: 'user_123',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$reg3 = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_456',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$this->repository->save($reg1);
|
|
$this->repository->save($reg2);
|
|
$this->repository->save($reg3);
|
|
|
|
$userRegistrations = $this->repository->findByUserId('user_123');
|
|
|
|
expect($userRegistrations)->toHaveCount(2);
|
|
expect($userRegistrations[0]->campaignId)->toBe(1);
|
|
expect($userRegistrations[1]->campaignId)->toBe(2);
|
|
});
|
|
|
|
it('finds pending registrations by campaign', function () {
|
|
$pending = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_1',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$completed = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_2',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
$savedCompleted = $this->repository->save($completed);
|
|
$completed = $savedCompleted->markAsCompleted();
|
|
$this->repository->save($completed);
|
|
|
|
$savedPending = $this->repository->save($pending);
|
|
|
|
$pendingRegistrations = $this->repository->findPendingByCampaign(1);
|
|
|
|
expect($pendingRegistrations)->toHaveCount(1);
|
|
expect($pendingRegistrations[0]->id)->toBe($savedPending->id);
|
|
expect($pendingRegistrations[0]->status->equals(RegistrationStatus::PENDING))->toBeTrue();
|
|
});
|
|
|
|
it('finds retryable registrations', function () {
|
|
$retryable = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_1',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
$savedRetryable = $this->repository->save($retryable);
|
|
$retryable = $savedRetryable->markAsFailed('First failure');
|
|
$this->repository->save($retryable);
|
|
|
|
$maxedOut = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_2',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
$savedMaxedOut = $this->repository->save($maxedOut);
|
|
$maxedOut = $savedMaxedOut->markAsFailed('Failure 1');
|
|
$maxedOut = $this->repository->save($maxedOut);
|
|
$maxedOut = $maxedOut->resetForRetry()->markAsFailed('Failure 2');
|
|
$maxedOut = $this->repository->save($maxedOut);
|
|
$maxedOut = $maxedOut->resetForRetry()->markAsFailed('Failure 3');
|
|
$this->repository->save($maxedOut);
|
|
|
|
$retryableList = $this->repository->findRetryable(1, 3);
|
|
|
|
expect($retryableList)->toHaveCount(1);
|
|
expect($retryableList[0]->id)->toBe($savedRetryable->id);
|
|
expect($retryableList[0]->retryCount)->toBe(0);
|
|
});
|
|
|
|
it('deletes registrations', function () {
|
|
$registration = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_456',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$saved = $this->repository->save($registration);
|
|
|
|
$deleted = $this->repository->delete($saved->id);
|
|
expect($deleted)->toBeTrue();
|
|
});
|
|
|
|
it('updates existing registrations', function () {
|
|
$registration = PreSaveRegistration::create(
|
|
campaignId: 1,
|
|
userId: 'user_456',
|
|
platform: StreamingPlatform::SPOTIFY
|
|
);
|
|
|
|
$saved = $this->repository->save($registration);
|
|
$updated = $saved->markAsCompleted();
|
|
$this->repository->save($updated);
|
|
|
|
$found = $this->repository->findForUserAndCampaign(
|
|
'user_456',
|
|
1,
|
|
StreamingPlatform::SPOTIFY
|
|
);
|
|
expect($found->status->equals(RegistrationStatus::COMPLETED))->toBeTrue();
|
|
});
|
|
});
|