- 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.
396 lines
14 KiB
PHP
396 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Domain\SmartLink\Enums\LinkStatus;
|
|
use App\Domain\SmartLink\Enums\LinkType;
|
|
use App\Domain\SmartLink\Enums\ServiceType;
|
|
use App\Domain\SmartLink\Exceptions\ShortCodeAlreadyExistsException;
|
|
use App\Domain\SmartLink\Exceptions\SmartLinkNotFoundException;
|
|
use App\Domain\SmartLink\Services\ShortCodeGenerator;
|
|
use App\Domain\SmartLink\Services\SmartLinkService;
|
|
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
|
|
use App\Domain\SmartLink\ValueObjects\LinkTitle;
|
|
use App\Domain\SmartLink\ValueObjects\ShortCode;
|
|
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
|
|
use App\Framework\DateTime\SystemClock;
|
|
use Tests\Support\InMemoryLinkDestinationRepository;
|
|
use Tests\Support\InMemorySmartLinkRepository;
|
|
|
|
describe('SmartLinkService', function () {
|
|
beforeEach(function () {
|
|
$this->clock = new SystemClock();
|
|
$this->linkRepository = new InMemorySmartLinkRepository();
|
|
$this->destinationRepository = new InMemoryLinkDestinationRepository();
|
|
$this->shortCodeGenerator = new ShortCodeGenerator($this->linkRepository);
|
|
|
|
$this->service = new SmartLinkService(
|
|
linkRepository: $this->linkRepository,
|
|
destinationRepository: $this->destinationRepository,
|
|
shortCodeGenerator: $this->shortCodeGenerator,
|
|
clock: $this->clock
|
|
);
|
|
});
|
|
|
|
describe('createLink', function () {
|
|
it('creates link with auto-generated short code', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
expect($link->type)->toBe(LinkType::RELEASE);
|
|
expect($link->title->toString())->toBe('My Album');
|
|
expect($link->status)->toBe(LinkStatus::DRAFT);
|
|
expect($link->userId)->toBeNull();
|
|
});
|
|
|
|
it('creates link with custom short code', function () {
|
|
$customCode = ShortCode::fromString('custom');
|
|
|
|
$link = $this->service->createLink(
|
|
type: LinkType::BIO_LINK,
|
|
title: LinkTitle::fromString('My Bio'),
|
|
customShortCode: $customCode
|
|
);
|
|
|
|
expect($link->shortCode->equals($customCode))->toBeTrue();
|
|
});
|
|
|
|
it('creates link with user ID', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album'),
|
|
userId: 'user123'
|
|
);
|
|
|
|
expect($link->userId)->toBe('user123');
|
|
expect($link->isOwnedBy('user123'))->toBeTrue();
|
|
});
|
|
|
|
it('creates link with cover image', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album'),
|
|
coverImageUrl: 'https://example.com/cover.jpg'
|
|
);
|
|
|
|
expect($link->coverImageUrl)->toBe('https://example.com/cover.jpg');
|
|
});
|
|
|
|
it('throws exception when custom short code already exists', function () {
|
|
$shortCode = ShortCode::fromString('exist1');
|
|
|
|
$this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('First'),
|
|
customShortCode: $shortCode
|
|
);
|
|
|
|
expect(fn() => $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Second'),
|
|
customShortCode: $shortCode
|
|
))->toThrow(ShortCodeAlreadyExistsException::class);
|
|
});
|
|
|
|
it('persists link to repository', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$foundLink = $this->linkRepository->findById($link->id);
|
|
expect($foundLink)->toBeInstanceOf(\App\Domain\SmartLink\Entities\SmartLink::class);
|
|
expect($foundLink->id->equals($link->id))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('findById', function () {
|
|
it('finds existing link by ID', function () {
|
|
$createdLink = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$foundLink = $this->service->findById($createdLink->id);
|
|
|
|
expect($foundLink->id->equals($createdLink->id))->toBeTrue();
|
|
expect($foundLink->title->toString())->toBe('My Album');
|
|
});
|
|
|
|
it('throws exception when link not found', function () {
|
|
$nonExistentId = SmartLinkId::generate($this->clock);
|
|
|
|
expect(fn() => $this->service->findById($nonExistentId))
|
|
->toThrow(SmartLinkNotFoundException::class);
|
|
});
|
|
});
|
|
|
|
describe('findByShortCode', function () {
|
|
it('finds existing link by short code', function () {
|
|
$shortCode = ShortCode::fromString('abc123');
|
|
|
|
$createdLink = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album'),
|
|
customShortCode: $shortCode
|
|
);
|
|
|
|
$foundLink = $this->service->findByShortCode($shortCode);
|
|
|
|
expect($foundLink->id->equals($createdLink->id))->toBeTrue();
|
|
expect($foundLink->shortCode->equals($shortCode))->toBeTrue();
|
|
});
|
|
|
|
it('throws exception when short code not found', function () {
|
|
$nonExistentCode = ShortCode::fromString('notfound');
|
|
|
|
expect(fn() => $this->service->findByShortCode($nonExistentCode))
|
|
->toThrow(SmartLinkNotFoundException::class);
|
|
});
|
|
|
|
it('is case-insensitive for short code lookup', function () {
|
|
$shortCode = ShortCode::fromString('AbCdEf');
|
|
|
|
$this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album'),
|
|
customShortCode: $shortCode
|
|
);
|
|
|
|
$lowerCaseCode = ShortCode::fromString('abcdef');
|
|
$foundLink = $this->service->findByShortCode($lowerCaseCode);
|
|
|
|
expect($foundLink)->toBeInstanceOf(\App\Domain\SmartLink\Entities\SmartLink::class);
|
|
expect($foundLink->shortCode->toString())->toBe('AbCdEf');
|
|
});
|
|
});
|
|
|
|
describe('updateTitle', function () {
|
|
it('updates link title', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Original Title')
|
|
);
|
|
|
|
$newTitle = LinkTitle::fromString('Updated Title');
|
|
$updatedLink = $this->service->updateTitle($link->id, $newTitle);
|
|
|
|
expect($updatedLink->title->toString())->toBe('Updated Title');
|
|
expect($updatedLink->id->equals($link->id))->toBeTrue();
|
|
});
|
|
|
|
it('persists title update', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Original Title')
|
|
);
|
|
|
|
$newTitle = LinkTitle::fromString('Updated Title');
|
|
$this->service->updateTitle($link->id, $newTitle);
|
|
|
|
$foundLink = $this->service->findById($link->id);
|
|
expect($foundLink->title->toString())->toBe('Updated Title');
|
|
});
|
|
});
|
|
|
|
describe('publishLink', function () {
|
|
it('publishes draft link', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
expect($link->status)->toBe(LinkStatus::DRAFT);
|
|
|
|
$publishedLink = $this->service->publishLink($link->id);
|
|
|
|
expect($publishedLink->status)->toBe(LinkStatus::ACTIVE);
|
|
expect($publishedLink->isActive())->toBeTrue();
|
|
});
|
|
|
|
it('persists published status', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$this->service->publishLink($link->id);
|
|
|
|
$foundLink = $this->service->findById($link->id);
|
|
expect($foundLink->status)->toBe(LinkStatus::ACTIVE);
|
|
});
|
|
});
|
|
|
|
describe('pauseLink', function () {
|
|
it('pauses active link', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$publishedLink = $this->service->publishLink($link->id);
|
|
expect($publishedLink->status)->toBe(LinkStatus::ACTIVE);
|
|
|
|
$pausedLink = $this->service->pauseLink($link->id);
|
|
expect($pausedLink->status)->toBe(LinkStatus::PAUSED);
|
|
});
|
|
});
|
|
|
|
describe('addDestination', function () {
|
|
it('adds destination to link', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$destination = $this->service->addDestination(
|
|
linkId: $link->id,
|
|
serviceType: ServiceType::SPOTIFY,
|
|
url: DestinationUrl::fromString('https://open.spotify.com/album/123')
|
|
);
|
|
|
|
expect($destination->linkId->equals($link->id))->toBeTrue();
|
|
expect($destination->serviceType)->toBe(ServiceType::SPOTIFY);
|
|
expect($destination->url->toString())->toBe('https://open.spotify.com/album/123');
|
|
});
|
|
|
|
it('adds destination with priority', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$destination = $this->service->addDestination(
|
|
linkId: $link->id,
|
|
serviceType: ServiceType::SPOTIFY,
|
|
url: DestinationUrl::fromString('https://open.spotify.com/album/123'),
|
|
priority: 10
|
|
);
|
|
|
|
expect($destination->priority)->toBe(10);
|
|
});
|
|
|
|
it('adds default destination', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$destination = $this->service->addDestination(
|
|
linkId: $link->id,
|
|
serviceType: ServiceType::SPOTIFY,
|
|
url: DestinationUrl::fromString('https://open.spotify.com/album/123'),
|
|
isDefault: true
|
|
);
|
|
|
|
expect($destination->isDefault)->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('getDestinations', function () {
|
|
it('retrieves all destinations for link', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$this->service->addDestination(
|
|
linkId: $link->id,
|
|
serviceType: ServiceType::SPOTIFY,
|
|
url: DestinationUrl::fromString('https://spotify.com/album/1')
|
|
);
|
|
|
|
$this->service->addDestination(
|
|
linkId: $link->id,
|
|
serviceType: ServiceType::APPLE_MUSIC,
|
|
url: DestinationUrl::fromString('https://music.apple.com/album/1')
|
|
);
|
|
|
|
$destinations = $this->service->getDestinations($link->id);
|
|
|
|
expect($destinations)->toHaveCount(2);
|
|
});
|
|
|
|
it('returns empty array when no destinations', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$destinations = $this->service->getDestinations($link->id);
|
|
|
|
expect($destinations)->toBeEmpty();
|
|
});
|
|
});
|
|
|
|
describe('deleteLink', function () {
|
|
it('deletes link from repository', function () {
|
|
$link = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('My Album')
|
|
);
|
|
|
|
$this->service->deleteLink($link->id);
|
|
|
|
expect(fn() => $this->service->findById($link->id))
|
|
->toThrow(SmartLinkNotFoundException::class);
|
|
});
|
|
});
|
|
|
|
describe('getUserLinks', function () {
|
|
it('retrieves all links for user', function () {
|
|
$this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Album 1'),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$this->service->createLink(
|
|
type: LinkType::BIO_LINK,
|
|
title: LinkTitle::fromString('Bio'),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Album 2'),
|
|
userId: 'user456'
|
|
);
|
|
|
|
$userLinks = $this->service->getUserLinks('user123');
|
|
|
|
expect($userLinks)->toHaveCount(2);
|
|
});
|
|
|
|
it('filters links by status', function () {
|
|
$link1 = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Album 1'),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$link2 = $this->service->createLink(
|
|
type: LinkType::RELEASE,
|
|
title: LinkTitle::fromString('Album 2'),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$this->service->publishLink($link2->id);
|
|
|
|
$draftLinks = $this->service->getUserLinks('user123', LinkStatus::DRAFT);
|
|
$activeLinks = $this->service->getUserLinks('user123', LinkStatus::ACTIVE);
|
|
|
|
expect($draftLinks)->toHaveCount(1);
|
|
expect($activeLinks)->toHaveCount(1);
|
|
});
|
|
|
|
it('returns empty array when user has no links', function () {
|
|
$userLinks = $this->service->getUserLinks('nonexistent');
|
|
|
|
expect($userLinks)->toBeEmpty();
|
|
});
|
|
});
|
|
});
|