feat(Production): Complete production deployment infrastructure

- 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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,395 @@
<?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();
});
});
});