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,209 @@
<?php
declare(strict_types=1);
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\LinkStatus;
use App\Domain\SmartLink\Enums\LinkType;
use App\Domain\SmartLink\ValueObjects\LinkTitle;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Framework\DateTime\SystemClock;
describe('SmartLink Entity', function () {
beforeEach(function () {
$this->clock = new SystemClock();
$this->shortCode = ShortCode::fromString('abc123');
$this->title = LinkTitle::fromString('My Smart Link');
});
describe('creation', function () {
it('creates smart link with required fields', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
expect($link->shortCode->toString())->toBe('abc123');
expect($link->type)->toBe(LinkType::RELEASE);
expect($link->title->toString())->toBe('My Smart Link');
expect($link->status)->toBe(LinkStatus::DRAFT);
expect($link->userId)->toBeNull();
expect($link->coverImageUrl)->toBeNull();
});
it('creates smart link with user ID', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title,
userId: 'user123'
);
expect($link->userId)->toBe('user123');
expect($link->isOwnedBy('user123'))->toBeTrue();
});
it('creates smart link with cover image', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title,
coverImageUrl: 'https://example.com/cover.jpg'
);
expect($link->coverImageUrl)->toBe('https://example.com/cover.jpg');
});
it('starts in DRAFT status', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
expect($link->status)->toBe(LinkStatus::DRAFT);
expect($link->isActive())->toBeFalse();
});
});
describe('status transitions', function () {
it('transitions from DRAFT to ACTIVE', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
$activeLink = $link->withStatus(LinkStatus::ACTIVE);
expect($activeLink->status)->toBe(LinkStatus::ACTIVE);
expect($activeLink->isActive())->toBeTrue();
});
it('transitions from ACTIVE to PAUSED', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
)->withStatus(LinkStatus::ACTIVE);
$pausedLink = $link->withStatus(LinkStatus::PAUSED);
expect($pausedLink->status)->toBe(LinkStatus::PAUSED);
});
it('throws exception for invalid transition', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
// Cannot transition directly from DRAFT to PAUSED
expect(fn() => $link->withStatus(LinkStatus::PAUSED))
->toThrow(\DomainException::class);
});
it('throws exception for invalid transition from EXPIRED', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
)->withStatus(LinkStatus::ACTIVE)->withStatus(LinkStatus::EXPIRED);
// Cannot transition from EXPIRED to ACTIVE
expect(fn() => $link->withStatus(LinkStatus::ACTIVE))
->toThrow(\DomainException::class);
});
});
describe('title updates', function () {
it('updates title', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
$newTitle = LinkTitle::fromString('Updated Title');
$updatedLink = $link->withTitle($newTitle);
expect($updatedLink->title->toString())->toBe('Updated Title');
expect($link->title->toString())->toBe('My Smart Link'); // Original unchanged
});
});
describe('access control', function () {
it('identifies active links as active', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
)->withStatus(LinkStatus::ACTIVE);
expect($link->isActive())->toBeTrue();
expect($link->canBeAccessed())->toBeTrue();
});
it('identifies draft links as not accessible', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
expect($link->canBeAccessed())->toBeFalse();
});
it('identifies paused links as not accessible', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
)->withStatus(LinkStatus::ACTIVE)->withStatus(LinkStatus::PAUSED);
expect($link->canBeAccessed())->toBeFalse();
});
});
describe('ownership', function () {
it('identifies owner correctly', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title,
userId: 'user123'
);
expect($link->isOwnedBy('user123'))->toBeTrue();
expect($link->isOwnedBy('user456'))->toBeFalse();
expect($link->isOwnedBy(null))->toBeFalse();
});
it('handles anonymous links', function () {
$link = SmartLink::create(
clock: $this->clock,
shortCode: $this->shortCode,
type: LinkType::RELEASE,
title: $this->title
);
expect($link->userId)->toBeNull();
expect($link->isOwnedBy('user123'))->toBeFalse();
});
});
});

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Domain\SmartLink\Services\ShortCodeGenerator;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use Tests\Support\InMemorySmartLinkRepository;
describe('ShortCodeGenerator', function () {
beforeEach(function () {
$this->repository = new InMemorySmartLinkRepository();
$this->generator = new ShortCodeGenerator($this->repository);
});
it('generates unique short code', function () {
$shortCode = $this->generator->generateUnique();
expect($shortCode)->toBeInstanceOf(ShortCode::class);
expect($shortCode->toString())->toHaveLength(6);
});
it('generates alphanumeric codes', function () {
$shortCode = $this->generator->generateUnique();
expect($shortCode->toString())->toMatch('/^[a-zA-Z0-9]{6}$/');
});
it('generates different codes on multiple calls', function () {
$codes = [];
for ($i = 0; $i < 5; $i++) {
$codes[] = $this->generator->generateUnique()->toString();
}
$uniqueCodes = array_unique($codes);
expect(count($uniqueCodes))->toBeGreaterThan(3);
});
it('generates code with custom length', function () {
$shortCode = $this->generator->generateUnique(length: 8);
expect($shortCode->toString())->toHaveLength(8);
});
it('returns different code when collision detected', function () {
// Generate a code and add it to repository
$firstCode = $this->generator->generateUnique();
// Manually add the code to repository to simulate it exists
$link = \App\Domain\SmartLink\Entities\SmartLink::create(
clock: new \App\Framework\DateTime\SystemClock(),
shortCode: $firstCode,
type: \App\Domain\SmartLink\Enums\LinkType::RELEASE,
title: \App\Domain\SmartLink\ValueObjects\LinkTitle::fromString('Test')
);
$this->repository->save($link);
// Generate another code - should be different
$secondCode = $this->generator->generateUnique();
expect($this->repository->existsShortCode($firstCode))->toBeTrue();
expect($secondCode)->toBeInstanceOf(ShortCode::class);
});
});

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();
});
});
});

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Domain\SmartLink\ValueObjects\DestinationUrl;
describe('DestinationUrl Value Object', function () {
it('creates from valid HTTP URL', function () {
$url = DestinationUrl::fromString('http://example.com');
expect($url->toString())->toBe('http://example.com');
});
it('creates from valid HTTPS URL', function () {
$url = DestinationUrl::fromString('https://example.com');
expect($url->toString())->toBe('https://example.com');
});
it('creates from URL with path', function () {
$url = DestinationUrl::fromString('https://example.com/path/to/page');
expect($url->toString())->toBe('https://example.com/path/to/page');
});
it('creates from URL with query parameters', function () {
$url = DestinationUrl::fromString('https://example.com?param1=value1&param2=value2');
expect($url->toString())->toBe('https://example.com?param1=value1&param2=value2');
});
it('creates from URL with fragment', function () {
$url = DestinationUrl::fromString('https://example.com#section');
expect($url->toString())->toBe('https://example.com#section');
});
it('throws exception for invalid URL format', function () {
expect(fn() => DestinationUrl::fromString('not-a-url'))
->toThrow(\InvalidArgumentException::class, 'Invalid destination URL format');
});
it('throws exception for empty string', function () {
expect(fn() => DestinationUrl::fromString(''))
->toThrow(\InvalidArgumentException::class, 'Invalid destination URL format');
});
it('throws exception for FTP protocol', function () {
expect(fn() => DestinationUrl::fromString('ftp://example.com'))
->toThrow(\InvalidArgumentException::class, 'Destination URL must use HTTP or HTTPS protocol');
});
it('throws exception for file protocol', function () {
expect(fn() => DestinationUrl::fromString('file:///path/to/file'))
->toThrow(\InvalidArgumentException::class, 'Destination URL must use HTTP or HTTPS protocol');
});
it('throws exception for javascript protocol', function () {
// Note: filter_var may accept javascript: as valid URL format
// but parse_url scheme check should reject it
expect(fn() => DestinationUrl::fromString('javascript:alert(1)'))
->toThrow(\InvalidArgumentException::class);
});
it('extracts host correctly', function () {
$url = DestinationUrl::fromString('https://example.com/path');
expect($url->getHost())->toBe('example.com');
});
it('extracts host with subdomain', function () {
$url = DestinationUrl::fromString('https://www.example.com');
expect($url->getHost())->toBe('www.example.com');
});
it('identifies HTTPS as secure', function () {
$url = DestinationUrl::fromString('https://example.com');
expect($url->isSecure())->toBeTrue();
});
it('identifies HTTP as not secure', function () {
$url = DestinationUrl::fromString('http://example.com');
expect($url->isSecure())->toBeFalse();
});
it('compares URLs correctly', function () {
$url1 = DestinationUrl::fromString('https://example.com');
$url2 = DestinationUrl::fromString('https://example.com');
$url3 = DestinationUrl::fromString('https://different.com');
expect($url1->equals($url2))->toBeTrue();
expect($url1->equals($url3))->toBeFalse();
});
it('converts to string via toString', function () {
$urlString = 'https://example.com/path';
$url = DestinationUrl::fromString($urlString);
expect($url->toString())->toBe($urlString);
});
it('converts to string via __toString', function () {
$urlString = 'https://example.com/path';
$url = DestinationUrl::fromString($urlString);
expect((string) $url)->toBe($urlString);
});
it('handles complex URLs', function () {
$complexUrl = 'https://user:pass@example.com:8080/path/to/resource?param=value#fragment';
$url = DestinationUrl::fromString($complexUrl);
expect($url->toString())->toBe($complexUrl);
expect($url->getHost())->toBe('example.com');
expect($url->isSecure())->toBeTrue();
});
});

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Domain\SmartLink\ValueObjects\ShortCode;
describe('ShortCode Value Object', function () {
it('creates valid short code', function () {
$code = ShortCode::fromString('abc123');
expect($code->toString())->toBe('abc123');
});
it('creates short code with valid length 6', function () {
$code = ShortCode::fromString('abcdef');
expect($code->toString())->toBe('abcdef');
});
it('creates short code with valid length 7', function () {
$code = ShortCode::fromString('abc1234');
expect($code->toString())->toBe('abc1234');
});
it('creates short code with valid length 8', function () {
$code = ShortCode::fromString('abc12345');
expect($code->toString())->toBe('abc12345');
});
it('throws exception for too short code', function () {
expect(fn() => ShortCode::fromString('abc12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode must be between 6 and 8 characters');
});
it('throws exception for too long code', function () {
expect(fn() => ShortCode::fromString('abc123456'))
->toThrow(\InvalidArgumentException::class, 'ShortCode must be between 6 and 8 characters');
});
it('accepts lowercase alphanumeric characters', function () {
$code = ShortCode::fromString('abc123');
expect($code->toString())->toBe('abc123');
});
it('accepts uppercase alphanumeric characters', function () {
$code = ShortCode::fromString('ABC123');
expect($code->toString())->toBe('ABC123');
});
it('accepts mixed case alphanumeric characters', function () {
$code = ShortCode::fromString('aBcDeF');
expect($code->toString())->toBe('aBcDeF');
});
it('rejects dash characters', function () {
expect(fn() => ShortCode::fromString('abc-12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode can only contain alphanumeric characters');
});
it('rejects underscore characters', function () {
expect(fn() => ShortCode::fromString('abc_12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode can only contain alphanumeric characters');
});
it('rejects space characters', function () {
expect(fn() => ShortCode::fromString('abc 12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode can only contain alphanumeric characters');
});
it('rejects special characters', function () {
expect(fn() => ShortCode::fromString('abc@12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode can only contain alphanumeric characters');
});
it('rejects dot characters', function () {
expect(fn() => ShortCode::fromString('abc.12'))
->toThrow(\InvalidArgumentException::class, 'ShortCode can only contain alphanumeric characters');
});
it('rejects too short code', function () {
// Empty string triggers length validation, not empty check
expect(fn() => ShortCode::fromString(''))
->toThrow(\InvalidArgumentException::class, 'ShortCode must be between 6 and 8 characters');
});
it('compares short codes correctly', function () {
$code1 = ShortCode::fromString('abc123');
$code2 = ShortCode::fromString('abc123');
$code3 = ShortCode::fromString('xyz789');
expect($code1->equals($code2))->toBeTrue();
expect($code1->equals($code3))->toBeFalse();
});
it('converts to string', function () {
$code = ShortCode::fromString('test12');
expect($code->toString())->toBe('test12');
expect((string) $code)->toBe('test12');
});
it('generates random short codes of length 6', function () {
$code = ShortCode::generate(6);
expect($code->toString())->toHaveLength(6);
expect($code->toString())->toMatch('/^[a-zA-Z0-9]{6}$/');
});
it('generates unique codes on multiple calls', function () {
$codes = [];
for ($i = 0; $i < 10; $i++) {
$codes[] = ShortCode::generate(6)->toString();
}
$uniqueCodes = array_unique($codes);
// With 62 possible characters and 6 positions, duplicates are extremely unlikely
expect(count($uniqueCodes))->toBeGreaterThan(8);
});
});

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
describe('SmartLinkId Value Object', function () {
beforeEach(function () {
$this->clock = new SystemClock();
});
it('generates valid ULID', function () {
$id = SmartLinkId::generate($this->clock);
expect($id->toString())->toHaveLength(26);
expect($id->toString())->toMatch('/^[0-9A-HJKMNP-TV-Z]{26}$/');
});
it('creates from valid ULID string', function () {
$ulidString = '01ARZ3NDEK0MBY01ENPMVEW9WG';
$id = SmartLinkId::fromString($ulidString);
expect($id->toString())->toBe($ulidString);
});
it('throws exception for invalid format', function () {
expect(fn() => SmartLinkId::fromString('invalid-format'))
->toThrow(\InvalidArgumentException::class, 'Invalid SmartLink ID format');
});
it('throws exception for too short string', function () {
expect(fn() => SmartLinkId::fromString('01ARZ3NDEK0MBY'))
->toThrow(\InvalidArgumentException::class, 'Invalid SmartLink ID format');
});
it('throws exception for empty string', function () {
expect(fn() => SmartLinkId::fromString(''))
->toThrow(\InvalidArgumentException::class, 'Invalid SmartLink ID format');
});
it('compares IDs correctly', function () {
$id1 = SmartLinkId::fromString('01ARZ3NDEK0MBY01ENPMVEW9WG');
$id2 = SmartLinkId::fromString('01ARZ3NDEK0MBY01ENPMVEW9WG');
$id3 = SmartLinkId::fromString('01ARZ3NDEK0MBY01ENPMVEW9WH');
expect($id1->equals($id2))->toBeTrue();
expect($id1->equals($id3))->toBeFalse();
});
it('converts to string via toString', function () {
$ulidString = '01ARZ3NDEK0MBY01ENPMVEW9WG';
$id = SmartLinkId::fromString($ulidString);
expect($id->toString())->toBe($ulidString);
});
it('converts to string via __toString', function () {
$ulidString = '01ARZ3NDEK0MBY01ENPMVEW9WG';
$id = SmartLinkId::fromString($ulidString);
expect((string) $id)->toBe($ulidString);
});
it('generates unique IDs', function () {
$ids = [];
for ($i = 0; $i < 10; $i++) {
$ids[] = SmartLinkId::generate($this->clock)->toString();
usleep(1000); // Small delay to ensure uniqueness
}
$uniqueIds = array_unique($ids);
expect(count($uniqueIds))->toBe(10);
});
});