Files
michaelschiemer/tests/Feature/Domain/PreSave/PreSaveIntegrationTest.php

678 lines
27 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\Duration;
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\AccessToken;
use App\Framework\OAuth\ValueObjects\OAuthToken;
use App\Framework\OAuth\ValueObjects\RefreshToken;
use App\Framework\OAuth\ValueObjects\TokenScope;
use App\Framework\OAuth\ValueObjects\TokenType;
// 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->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);
});
});
});