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,63 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Domain\SmartLink\Entities\LinkDestination;
use App\Domain\SmartLink\Repositories\LinkDestinationRepository;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
final class InMemoryLinkDestinationRepository implements LinkDestinationRepository
{
/** @var array<string, LinkDestination> */
private array $destinations = [];
public function save(LinkDestination $destination): void
{
$this->destinations[$destination->id] = $destination;
}
public function findById(string $id): ?LinkDestination
{
return $this->destinations[$id] ?? null;
}
public function findByLinkId(SmartLinkId $linkId): array
{
return array_values(array_filter(
$this->destinations,
fn(LinkDestination $destination) => $destination->linkId->equals($linkId)
));
}
public function findDefaultByLinkId(SmartLinkId $linkId): ?LinkDestination
{
foreach ($this->destinations as $destination) {
if ($destination->linkId->equals($linkId) && $destination->isDefault) {
return $destination;
}
}
return null;
}
public function deleteByLinkId(SmartLinkId $linkId): void
{
$this->destinations = array_filter(
$this->destinations,
fn(LinkDestination $destination) => ! $destination->linkId->equals($linkId)
);
}
public function deleteById(string $id): void
{
unset($this->destinations[$id]);
}
// Test helper
public function clear(): void
{
$this->destinations = [];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Domain\SmartLink\Entities\SmartLink;
use App\Domain\SmartLink\Enums\LinkStatus;
use App\Domain\SmartLink\Repositories\SmartLinkRepository;
use App\Domain\SmartLink\ValueObjects\ShortCode;
use App\Domain\SmartLink\ValueObjects\SmartLinkId;
final class InMemorySmartLinkRepository implements SmartLinkRepository
{
/** @var array<string, SmartLink> */
private array $links = [];
/** @var array<string, int> */
private array $clicks = [];
public function save(SmartLink $link): void
{
$this->links[$link->id->toString()] = $link;
}
public function findById(SmartLinkId $id): ?SmartLink
{
return $this->links[$id->toString()] ?? null;
}
public function findByShortCode(ShortCode $shortCode): ?SmartLink
{
foreach ($this->links as $link) {
if ($link->shortCode->equals($shortCode)) {
return $link;
}
}
return null;
}
public function findByUserId(string $userId, ?LinkStatus $status = null): array
{
$result = [];
foreach ($this->links as $link) {
if ($link->userId === $userId) {
if ($status === null || $link->status === $status) {
$result[] = $link;
}
}
}
return $result;
}
public function existsShortCode(ShortCode $shortCode): bool
{
return $this->findByShortCode($shortCode) !== null;
}
public function delete(SmartLinkId $id): void
{
unset($this->links[$id->toString()]);
}
public function findActiveLinks(): array
{
return array_filter(
$this->links,
fn(SmartLink $link) => $link->status === LinkStatus::ACTIVE
);
}
public function getTotalClicks(SmartLinkId $id): int
{
return $this->clicks[$id->toString()] ?? 0;
}
// Test helpers
public function clear(): void
{
$this->links = [];
$this->clicks = [];
}
public function setClicks(SmartLinkId $id, int $clicks): void
{
$this->clicks[$id->toString()] = $clicks;
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Framework\LiveComponents\Services\UploadProgressTrackerInterface;
use App\Framework\LiveComponents\ValueObjects\UploadSession;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
/**
* In-Memory Upload Progress Tracker for Testing
*
* Records all broadcast events for assertion in tests.
* Does not require actual SSE infrastructure.
*/
final class InMemoryUploadProgressTracker implements UploadProgressTrackerInterface
{
/** @var array<array{type: string, session_id: string, user_id: string, data: array}> */
private array $broadcasts = [];
public function broadcastInitialized(UploadSession $session, string $userId): int
{
$this->broadcasts[] = [
'type' => 'initialized',
'session_id' => $session->sessionId->toString(),
'user_id' => $userId,
'data' => [
'file_name' => $session->fileName,
'total_size' => $session->totalSize->toBytes(),
'total_chunks' => $session->totalChunks,
],
];
return 1; // Simulated client count
}
public function broadcastChunkUploaded(UploadSession $session, string $userId): int
{
$this->broadcasts[] = [
'type' => 'chunk_uploaded',
'session_id' => $session->sessionId->toString(),
'user_id' => $userId,
'data' => [
'uploaded_chunks' => count($session->getUploadedChunks()),
'total_chunks' => $session->totalChunks,
'progress' => $session->getProgress(),
],
];
return 1;
}
public function broadcastCompleted(UploadSession $session, string $userId): int
{
$this->broadcasts[] = [
'type' => 'completed',
'session_id' => $session->sessionId->toString(),
'user_id' => $userId,
'data' => [
'file_name' => $session->fileName,
'completed_at' => $session->completedAt?->format('Y-m-d H:i:s'),
],
];
return 1;
}
public function broadcastAborted(UploadSessionId $sessionId, string $userId, string $reason = 'User cancelled'): int
{
$this->broadcasts[] = [
'type' => 'aborted',
'session_id' => $sessionId->toString(),
'user_id' => $userId,
'data' => [
'reason' => $reason,
],
];
return 1;
}
public function broadcastError(UploadSessionId $sessionId, string $userId, string $error): int
{
$this->broadcasts[] = [
'type' => 'error',
'session_id' => $sessionId->toString(),
'user_id' => $userId,
'data' => [
'error' => $error,
],
];
return 1;
}
public function broadcastQuarantineStatus(UploadSession $session, string $userId): int
{
$this->broadcasts[] = [
'type' => 'quarantine_status',
'session_id' => $session->sessionId->toString(),
'user_id' => $userId,
'data' => [
'quarantine_status' => $session->quarantineStatus->value,
],
];
return 1;
}
public function getProgress(UploadSessionId $sessionId): ?array
{
// In-memory mock doesn't track progress state
// Return null to indicate not available
return null;
}
/**
* Get all broadcast events (for testing)
*
* @return array<array{type: string, session_id: string, user_id: string, data: array}>
*/
public function getBroadcasts(): array
{
return $this->broadcasts;
}
/**
* Get broadcasts by type (for testing)
*
* @return array<array{type: string, session_id: string, user_id: string, data: array}>
*/
public function getBroadcastsByType(string $type): array
{
return array_filter($this->broadcasts, fn($b) => $b['type'] === $type);
}
/**
* Get broadcast count by type (for testing)
*/
public function getBroadcastCount(string $type): int
{
return count($this->getBroadcastsByType($type));
}
/**
* Clear all broadcasts (for testing)
*/
public function clear(): void
{
$this->broadcasts = [];
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Framework\LiveComponents\Services\UploadSessionStore;
use App\Framework\LiveComponents\ValueObjects\UploadSession;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
use DateTimeImmutable;
/**
* In-Memory Upload Session Store for Testing
*
* Thread-safe in-memory implementation of UploadSessionStore
* for use in tests. Does not require database or cache.
*/
final class InMemoryUploadSessionStore implements UploadSessionStore
{
/** @var array<string, UploadSession> */
private array $sessions = [];
public function save(UploadSession $session): void
{
$this->sessions[$session->sessionId->value] = $session;
}
public function get(UploadSessionId $sessionId): ?UploadSession
{
return $this->sessions[$sessionId->value] ?? null;
}
public function delete(UploadSessionId $sessionId): void
{
unset($this->sessions[$sessionId->value]);
}
public function exists(UploadSessionId $sessionId): bool
{
return isset($this->sessions[$sessionId->value]);
}
public function cleanupExpired(): int
{
$now = new DateTimeImmutable();
$cleaned = 0;
foreach ($this->sessions as $key => $session) {
if ($session->isExpired()) {
unset($this->sessions[$key]);
$cleaned++;
}
}
return $cleaned;
}
/**
* Get all stored sessions (for testing)
*
* @return array<UploadSession>
*/
public function getAll(): array
{
return array_values($this->sessions);
}
/**
* Get count of stored sessions (for testing)
*/
public function count(): int
{
return count($this->sessions);
}
/**
* Clear all sessions (for testing)
*/
public function clear(): void
{
$this->sessions = [];
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Framework\Http\SseStreamInterface;
use App\Framework\Router\Result\SseEvent;
/**
* Mock SSE Stream for Testing
*
* Simulates SSE stream behavior without actual HTTP connection.
* Records all sent events for assertion in tests.
*
* This is a standalone test implementation that mimics SseStream
* but does not extend it (since it's final).
*/
final class MockSseStream implements SseStreamInterface
{
/** @var array<SseEvent> */
private array $sentEvents = [];
private bool $isActive = true;
private bool $connectionActive = true;
public function __construct()
{
// No parent constructor call - we don't need actual stream
}
public function sendEvent(SseEvent $event): void
{
if (!$this->isActive || !$this->connectionActive) {
throw new \RuntimeException('Connection is not active');
}
$this->sentEvents[] = $event;
}
public function sendHeartbeat(): void
{
if (!$this->isActive || !$this->connectionActive) {
throw new \RuntimeException('Connection is not active');
}
// Record heartbeat as special event
$this->sentEvents[] = new SseEvent(
data: ':heartbeat',
event: 'heartbeat'
);
}
public function isActive(): bool
{
return $this->isActive;
}
public function isConnectionActive(): bool
{
return $this->connectionActive;
}
/**
* Simulate connection becoming inactive (for testing)
*/
public function simulateDisconnect(): void
{
$this->isActive = false;
$this->connectionActive = false;
}
/**
* Simulate connection timeout (for testing)
*/
public function simulateTimeout(): void
{
$this->connectionActive = false;
}
/**
* Get all sent events (for testing)
*
* @return array<SseEvent>
*/
public function getSentEvents(): array
{
return $this->sentEvents;
}
/**
* Get sent events by type (for testing)
*
* @return array<SseEvent>
*/
public function getSentEventsByType(string $eventType): array
{
return array_filter(
$this->sentEvents,
fn(SseEvent $event) => $event->event === $eventType
);
}
/**
* Get sent events count
*/
public function getSentEventsCount(): int
{
return count($this->sentEvents);
}
/**
* Clear all sent events (for testing)
*/
public function clear(): void
{
$this->sentEvents = [];
}
/**
* Reset connection state (for testing)
*/
public function reset(): void
{
$this->sentEvents = [];
$this->isActive = true;
$this->connectionActive = true;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Framework\Logging\ValueObjects\LogRecord;
/**
* Simple in-memory log handler for testing
*/
final class TestLogHandler
{
/** @var array<LogRecord> */
private array $records = [];
public function handle(LogRecord $record): void
{
$this->records[] = $record;
}
/**
* @return array<LogRecord>
*/
public function getRecords(): array
{
return $this->records;
}
public function clear(): void
{
$this->records = [];
}
public function hasRecordThatContains(string $needle): bool
{
foreach ($this->records as $record) {
if (str_contains($record->message, $needle)) {
return true;
}
}
return false;
}
public function getRecordCount(): int
{
return count($this->records);
}
}