66 changed files with 6454 additions and 54 deletions
@ -0,0 +1,70 @@ |
|||||||
|
FROM php:8.2-fpm-alpine |
||||||
|
|
||||||
|
# Install system dependencies and development tools |
||||||
|
RUN apk add --no-cache \ |
||||||
|
$PHPIZE_DEPS \ |
||||||
|
icu-dev \ |
||||||
|
libzip-dev \ |
||||||
|
libpng-dev \ |
||||||
|
jpeg-dev \ |
||||||
|
freetype-dev \ |
||||||
|
linux-headers \ |
||||||
|
git \ |
||||||
|
vim \ |
||||||
|
curl \ |
||||||
|
shadow \ |
||||||
|
sudo |
||||||
|
|
||||||
|
RUN pecl install xdebug && docker-php-ext-enable xdebug |
||||||
|
|
||||||
|
RUN apk add icu-dev |
||||||
|
RUN docker-php-ext-install \ |
||||||
|
intl \ |
||||||
|
pdo_mysql \ |
||||||
|
zip \ |
||||||
|
gd |
||||||
|
|
||||||
|
# Configure PHP for development, xdebug.client_host=127.0.0.1 for in-container xdebug server |
||||||
|
RUN echo "memory_limit = 512M" > /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "upload_max_filesize = 100M" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "post_max_size = 100M" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "display_errors = On" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "display_startup_errors = On" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "error_reporting = E_ALL" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "xdebug.mode=debug,develop" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "xdebug.client_host=127.0.0.1" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "xdebug.client_port=9003" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "xdebug.idekey=VSCODE" >> /usr/local/etc/php/conf.d/custom.ini |
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /var/www/html |
||||||
|
|
||||||
|
# Install Composer |
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer |
||||||
|
|
||||||
|
# Configure user permissions |
||||||
|
ARG USER_ID=1000 |
||||||
|
ARG GROUP_ID=1000 |
||||||
|
|
||||||
|
# Create a user with matching UID/GID |
||||||
|
RUN if getent passwd $USER_ID > /dev/null 2>&1; then \ |
||||||
|
usermod -u $USER_ID -g $GROUP_ID www-data; \ |
||||||
|
else \ |
||||||
|
addgroup -g $GROUP_ID developer; \ |
||||||
|
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \ |
||||||
|
echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer; \ |
||||||
|
chmod 0440 /etc/sudoers.d/developer; \ |
||||||
|
usermod -a -G developer www-data; \ |
||||||
|
fi |
||||||
|
|
||||||
|
RUN chown -R $USER_ID:$GROUP_ID /var/www/html |
||||||
|
|
||||||
|
USER $USER_ID:$GROUP_ID |
||||||
|
|
||||||
|
# Expose port 9000 for PHP-FPM |
||||||
|
EXPOSE 9000 |
||||||
|
|
||||||
|
# Start PHP-FPM |
||||||
|
CMD ["php-fpm"] |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
server { |
||||||
|
listen 80; |
||||||
|
server_name localhost; |
||||||
|
root /var/www/html; |
||||||
|
index index.php index.html; |
||||||
|
|
||||||
|
location / { |
||||||
|
try_files $uri $uri/ /index.php?$query_string; |
||||||
|
} |
||||||
|
|
||||||
|
location ~ \.php$ { |
||||||
|
try_files $uri =404; |
||||||
|
fastcgi_split_path_info ^(.+\.php)(/.+)$; |
||||||
|
fastcgi_pass php:9000; |
||||||
|
fastcgi_index index.php; |
||||||
|
include fastcgi_params; |
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; |
||||||
|
fastcgi_param PATH_INFO $fastcgi_path_info; |
||||||
|
} |
||||||
|
|
||||||
|
location ~ /\.(?!well-known).* { |
||||||
|
deny all; |
||||||
|
} |
||||||
|
|
||||||
|
# Enable access log for debugging in development |
||||||
|
access_log /var/log/nginx/access.log; |
||||||
|
error_log /var/log/nginx/error.log warn; |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"name": "PHP 8.2 dev container", |
||||||
|
"dockerComposeFile": "./docker-compose.yml", |
||||||
|
"service": "php", |
||||||
|
"workspaceFolder": "/var/www/html", |
||||||
|
"customizations": { |
||||||
|
"vscode": { |
||||||
|
"settings": { |
||||||
|
"terminal.integrated.defaultProfile.linux": "bash", |
||||||
|
"php.validate.executablePath": "/usr/local/bin/php", |
||||||
|
"php.debug.executablePath": "/usr/local/bin/php" |
||||||
|
}, |
||||||
|
"extensions": [ |
||||||
|
"xdebug.php-debug", |
||||||
|
"bmewburn.vscode-intelephense-client", |
||||||
|
"ms-vscode.vscode-json", |
||||||
|
"mrmlnc.vscode-json5", |
||||||
|
"mrmlnc.vscode-json2" |
||||||
|
] |
||||||
|
} |
||||||
|
}, |
||||||
|
"forwardPorts": [50080], |
||||||
|
"remoteUser": "developer", |
||||||
|
"containerEnv": { |
||||||
|
"USER_ID": "${localEnv:USER_ID:-1000}", |
||||||
|
"GROUP_ID": "${localEnv:GROUP_ID:-1000}" |
||||||
|
}, |
||||||
|
"postCreateCommand": "sudo chown -R developer:developer /var/www/html" |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
version: "3.9" |
||||||
|
services: |
||||||
|
php: |
||||||
|
build: |
||||||
|
context: .. |
||||||
|
dockerfile: .devcontainer/Dockerfile |
||||||
|
args: |
||||||
|
USER_ID: ${USER_ID:-1000} |
||||||
|
GROUP_ID: ${GROUP_ID:-1000} |
||||||
|
image: dev-php82-img |
||||||
|
container_name: dev-php82 |
||||||
|
user: "developer" |
||||||
|
volumes: |
||||||
|
- ../:/var/www/html:cached |
||||||
|
- composer-cache:/home/www-data/.composer/cache |
||||||
|
environment: |
||||||
|
PHP_IDE_CONFIG: serverName=localhost |
||||||
|
XDEBUG_MODE: debug,develop |
||||||
|
networks: |
||||||
|
- dev-network |
||||||
|
|
||||||
|
nginx: |
||||||
|
build: |
||||||
|
context: . |
||||||
|
dockerfile: nginx.Dockerfile |
||||||
|
image: dev-php82-nginx-img |
||||||
|
container_name: dev-php82-nginx |
||||||
|
ports: |
||||||
|
- "50080:80" |
||||||
|
volumes: |
||||||
|
- ../:/var/www/html:cached |
||||||
|
depends_on: |
||||||
|
- php |
||||||
|
networks: |
||||||
|
- dev-network |
||||||
|
|
||||||
|
volumes: |
||||||
|
composer-cache: |
||||||
|
|
||||||
|
networks: |
||||||
|
dev-network: |
||||||
|
driver: bridge |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
FROM nginx:alpine |
||||||
|
|
||||||
|
COPY default.dev.conf /etc/nginx/conf.d/default.conf |
||||||
|
|
||||||
|
EXPOSE 80 |
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"] |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
.git |
||||||
|
.github |
||||||
|
.vscode |
||||||
|
.phpunit.result.cache |
||||||
|
.php_cs.cache |
||||||
|
node_modules |
||||||
|
npm-debug.log |
||||||
|
yarn-error.log |
||||||
|
.env |
||||||
|
.env.backup |
||||||
|
.env.* |
||||||
|
!.env.example |
||||||
|
.DS_Store |
||||||
|
Thumbs.db |
||||||
|
*.log |
||||||
|
*.zip |
||||||
|
*.tar.gz |
||||||
|
.docker |
||||||
|
.devcontainer |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
require_once __DIR__ . '/../../vendor/autoload.php'; |
||||||
|
|
||||||
|
use AutoStore\DiContainer; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
$diContainer = new DiContainer(); |
||||||
|
$logger = $diContainer->get(LoggerInterface::class); |
||||||
|
|
||||||
|
try { |
||||||
|
// Example log cleanup task |
||||||
|
$logFile = $storagePath . '/app.log'; |
||||||
|
|
||||||
|
if (file_exists($logFile) && filesize($logFile) > 10 * 1024 * 1024) { // 10MB |
||||||
|
// Rotate log file |
||||||
|
$backupFile = $storagePath . '/app.log.' . date('Y-m-d_H-i-s'); |
||||||
|
rename($logFile, $backupFile); |
||||||
|
$logger->info("Log file rotated to: {$backupFile}"); |
||||||
|
} |
||||||
|
|
||||||
|
$logger->info('Log cleanup check completed'); |
||||||
|
exit(0); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$logger->error('Error during log cleanup: ' . $e->getMessage()); |
||||||
|
exit(1); |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
require_once __DIR__ . '/../../vendor/autoload.php'; |
||||||
|
|
||||||
|
use AutoStore\DiContainer; |
||||||
|
use AutoStore\Application\Commands\HandleExpiredItems; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
$diContainer = new DiContainer(); |
||||||
|
$logger = $diContainer->get(LoggerInterface::class); |
||||||
|
|
||||||
|
try { |
||||||
|
$handleExpiredItems = $diContainer->get(HandleExpiredItems::class); |
||||||
|
$handleExpiredItems->execute(); |
||||||
|
|
||||||
|
$logger->info('Expired items check completed successfully'); |
||||||
|
exit(0); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$logger->error('Error handling expired items: ' . $e->getMessage()); |
||||||
|
exit(1); |
||||||
|
} |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
/** |
||||||
|
* CLI Task Scheduler |
||||||
|
* |
||||||
|
* Scans scheduler-tasks/ directory for PHP files with pattern: task-name.interval.php |
||||||
|
* Each task is executed as a separate CLI process at the specified interval (in seconds) |
||||||
|
* Handles graceful shutdown on SIGTERM/SIGINT signals |
||||||
|
*/ |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php'; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use AutoStore\DiContainer; |
||||||
|
|
||||||
|
class Scheduler |
||||||
|
{ |
||||||
|
private string $tasksDirectory; |
||||||
|
private LoggerInterface $logger; |
||||||
|
private array $tasks = []; |
||||||
|
private bool $running = true; |
||||||
|
|
||||||
|
public function __construct(string $tasksDirectory, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->tasksDirectory = $tasksDirectory; |
||||||
|
$this->logger = $logger; |
||||||
|
$this->loadTasks(); |
||||||
|
} |
||||||
|
|
||||||
|
private function loadTasks(): void |
||||||
|
{ |
||||||
|
if (!is_dir($this->tasksDirectory)) { |
||||||
|
$this->logger->error("Tasks directory not found: {$this->tasksDirectory}"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$files = scandir($this->tasksDirectory); |
||||||
|
foreach ($files as $file) { |
||||||
|
if ($file === '.' || $file === '..') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$filePath = $this->tasksDirectory . '/' . $file; |
||||||
|
if (!is_file($filePath)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Parse interval from filename (e.g., "task.60.php" -> 60 seconds) |
||||||
|
if (preg_match('/^(.+)\.(\d+)\.php$/', $file, $matches)) { |
||||||
|
$taskName = $matches[1]; |
||||||
|
$interval = (int) $matches[2]; |
||||||
|
|
||||||
|
$this->tasks[] = [ |
||||||
|
'name' => $taskName, |
||||||
|
'file' => $filePath, |
||||||
|
'interval' => $interval, |
||||||
|
'last_run' => null, |
||||||
|
]; |
||||||
|
|
||||||
|
$this->logger->info("Loaded task: {$taskName} with interval {$interval} seconds"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function run(): void |
||||||
|
{ |
||||||
|
$this->logger->info('Scheduler started'); |
||||||
|
|
||||||
|
// Install signal handlers for graceful shutdown |
||||||
|
pcntl_async_signals(true); |
||||||
|
pcntl_signal(SIGTERM, [$this, 'handleSignal']); |
||||||
|
pcntl_signal(SIGINT, [$this, 'handleSignal']); |
||||||
|
|
||||||
|
while ($this->running) { |
||||||
|
$this->executeDueTasks(); |
||||||
|
sleep(1); // Check every second |
||||||
|
} |
||||||
|
|
||||||
|
$this->logger->info('Scheduler stopped'); |
||||||
|
} |
||||||
|
|
||||||
|
private function executeDueTasks(): void |
||||||
|
{ |
||||||
|
$currentTime = time(); |
||||||
|
|
||||||
|
foreach ($this->tasks as &$task) { |
||||||
|
if ($task['last_run'] === null || ($currentTime - $task['last_run']) >= $task['interval']) { |
||||||
|
$this->executeTask($task); |
||||||
|
$task['last_run'] = $currentTime; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function executeTask(array $task): void |
||||||
|
{ |
||||||
|
$this->logger->info("Executing task: {$task['name']}"); |
||||||
|
|
||||||
|
$command = sprintf('php %s > /dev/null 2>&1 &', escapeshellarg($task['file'])); |
||||||
|
exec($command, $output, $exitCode); |
||||||
|
|
||||||
|
if ($exitCode === 0) { |
||||||
|
$this->logger->info("Task {$task['name']} executed successfully"); |
||||||
|
} else { |
||||||
|
$this->logger->error("Task {$task['name']} failed with exit code: {$exitCode}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function handleSignal(int $signal): void |
||||||
|
{ |
||||||
|
$this->logger->info("Received signal: {$signal}. Shutting down gracefully..."); |
||||||
|
$this->running = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Main execution |
||||||
|
try { |
||||||
|
$diContainer = new DiContainer(); |
||||||
|
$logger = $diContainer->get(LoggerInterface::class); |
||||||
|
$tasksDirectory = __DIR__ . '/scheduler-tasks'; |
||||||
|
|
||||||
|
$scheduler = new Scheduler($tasksDirectory, $logger); |
||||||
|
$scheduler->run(); |
||||||
|
|
||||||
|
exit(0); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$logger = $logger ?? new \Monolog\Logger('scheduler'); |
||||||
|
$logger->error('Scheduler failed: ' . $e->getMessage()); |
||||||
|
exit(1); |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
{ |
||||||
|
"name": "autostore/php8-implementation", |
||||||
|
"description": "PHP 8.2 implementation of AutoStore application following Clean Architecture", |
||||||
|
"type": "project", |
||||||
|
"license": "MIT", |
||||||
|
"require": { |
||||||
|
"php": ">=8.1", |
||||||
|
"firebase/php-jwt": "^6.10", |
||||||
|
"guzzlehttp/guzzle": "^7.8", |
||||||
|
"psr/container": "^2.0", |
||||||
|
"psr/http-message": "^2.0", |
||||||
|
"psr/http-server-handler": "^1.0", |
||||||
|
"psr/http-server-middleware": "^1.0", |
||||||
|
"monolog/monolog": "^3.5", |
||||||
|
"vlucas/phpdotenv": "^5.6", |
||||||
|
"slim/slim": "^4.12", |
||||||
|
"slim/psr7": "^1.6", |
||||||
|
"league/container": "^4.0" |
||||||
|
}, |
||||||
|
"require-dev": { |
||||||
|
"phpunit/phpunit": "^10.5", |
||||||
|
"phpstan/phpstan": "^1.10", |
||||||
|
"squizlabs/php_codesniffer": "^3.8" |
||||||
|
}, |
||||||
|
"autoload": { |
||||||
|
"psr-4": { |
||||||
|
"AutoStore\\": "src/" |
||||||
|
} |
||||||
|
}, |
||||||
|
"autoload-dev": { |
||||||
|
"psr-4": { |
||||||
|
"AutoStore\\Tests\\": "tests/" |
||||||
|
} |
||||||
|
}, |
||||||
|
"scripts": { |
||||||
|
"test": "phpunit", |
||||||
|
"phpstan": "phpstan analyse src tests", |
||||||
|
"cs-check": "phpcs src tests --standard=PSR12", |
||||||
|
"cs-fix": "phpcbf src tests --standard=PSR12" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"storage_directory": "./storage", |
||||||
|
"jwt_secret": "secret-key" |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
FROM php:8.2-fpm-alpine |
||||||
|
|
||||||
|
# Install system dependencies |
||||||
|
RUN apk add --no-cache \ |
||||||
|
icu-dev \ |
||||||
|
libzip-dev \ |
||||||
|
libpng-dev \ |
||||||
|
jpeg-dev \ |
||||||
|
freetype-dev \ |
||||||
|
&& docker-php-ext-install \ |
||||||
|
intl \ |
||||||
|
pdo_mysql \ |
||||||
|
zip \ |
||||||
|
gd |
||||||
|
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ |
||||||
|
&& docker-php-ext-configure pcntl --enable-pcntl \ |
||||||
|
&& docker-php-ext-install pcntl |
||||||
|
|
||||||
|
# Create PHP configuration file |
||||||
|
RUN echo "memory_limit = 256M" > /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "upload_max_filesize = 100M" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "post_max_size = 100M" >> /usr/local/etc/php/conf.d/custom.ini \ |
||||||
|
&& echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/custom.ini |
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /var/www/html |
||||||
|
|
||||||
|
# Copy application code |
||||||
|
COPY --chown=www-data:www-data . . |
||||||
|
|
||||||
|
# Expose port 9000 for PHP-FPM |
||||||
|
EXPOSE 9000 |
||||||
|
|
||||||
|
# Start PHP-FPM |
||||||
|
CMD ["php-fpm"] |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
server { |
||||||
|
listen 80; |
||||||
|
server_name localhost; |
||||||
|
root /var/www/html; |
||||||
|
index index.php index.html; |
||||||
|
|
||||||
|
location / { |
||||||
|
try_files $uri $uri/ /index.php?$query_string; |
||||||
|
} |
||||||
|
|
||||||
|
location ~ \.php$ { |
||||||
|
try_files $uri =404; |
||||||
|
fastcgi_split_path_info ^(.+\.php)(/.+)$; |
||||||
|
fastcgi_pass php:9000; |
||||||
|
fastcgi_index index.php; |
||||||
|
include fastcgi_params; |
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; |
||||||
|
fastcgi_param PATH_INFO $fastcgi_path_info; |
||||||
|
} |
||||||
|
|
||||||
|
location ~ /\.(?!well-known).* { |
||||||
|
deny all; |
||||||
|
} |
||||||
|
|
||||||
|
# Security headers |
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always; |
||||||
|
add_header X-Content-Type-Options "nosniff" always; |
||||||
|
add_header X-XSS-Protection "1; mode=block" always; |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
version: "3.9" |
||||||
|
services: |
||||||
|
php: |
||||||
|
build: |
||||||
|
context: .. |
||||||
|
dockerfile: docker/Dockerfile |
||||||
|
image: php82-app-img |
||||||
|
container_name: php82-app |
||||||
|
volumes: |
||||||
|
- ..:/var/www/html |
||||||
|
networks: |
||||||
|
- app-network |
||||||
|
|
||||||
|
nginx: |
||||||
|
build: |
||||||
|
context: .. |
||||||
|
dockerfile: docker/nginx.Dockerfile |
||||||
|
image: php82-nginx-img |
||||||
|
container_name: php82-nginx |
||||||
|
ports: |
||||||
|
- "8081:80" |
||||||
|
volumes: |
||||||
|
- ..:/var/www/html:ro |
||||||
|
depends_on: |
||||||
|
- php |
||||||
|
networks: |
||||||
|
- app-network |
||||||
|
|
||||||
|
scheduler: |
||||||
|
build: |
||||||
|
context: .. |
||||||
|
dockerfile: docker/Dockerfile |
||||||
|
image: php82-app-img |
||||||
|
container_name: php82-scheduler |
||||||
|
volumes: |
||||||
|
- ..:/var/www/html |
||||||
|
command: ["php", "/var/www/html/cli/scheduler.php"] |
||||||
|
depends_on: |
||||||
|
- php |
||||||
|
networks: |
||||||
|
- app-network |
||||||
|
restart: unless-stopped |
||||||
|
|
||||||
|
networks: |
||||||
|
app-network: |
||||||
|
driver: bridge |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
FROM nginx:alpine |
||||||
|
|
||||||
|
COPY docker/default.conf /etc/nginx/conf.d/default.conf |
||||||
|
|
||||||
|
EXPOSE 80 |
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"] |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
use AutoStore\Application; |
||||||
|
require_once __DIR__ . '/vendor/autoload.php'; |
||||||
|
|
||||||
|
$app = new Application(); |
||||||
|
$app->run(); |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" |
||||||
|
bootstrap="tests/bootstrap.php" |
||||||
|
colors="true"> |
||||||
|
<testsuites> |
||||||
|
<testsuite name="Unit"> |
||||||
|
<directory>tests/Unit</directory> |
||||||
|
</testsuite> |
||||||
|
<testsuite name="Integration"> |
||||||
|
<directory>tests/Integration</directory> |
||||||
|
</testsuite> |
||||||
|
</testsuites> |
||||||
|
</phpunit> |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore; |
||||||
|
|
||||||
|
use AutoStore\Infrastructure\Http\JwtMiddleware; |
||||||
|
use AutoStore\WebApi\Controllers\AuthController; |
||||||
|
use AutoStore\WebApi\Controllers\StoreController; |
||||||
|
use Slim\Factory\AppFactory; |
||||||
|
use Slim\Routing\RouteCollectorProxy; |
||||||
|
|
||||||
|
class Application |
||||||
|
{ |
||||||
|
private \Slim\App $app; |
||||||
|
private DiContainer $di; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
$this->di = new DiContainer(); |
||||||
|
$this->app = AppFactory::create(); |
||||||
|
|
||||||
|
$this->setupMiddleware(); |
||||||
|
$this->setupRoutes(); |
||||||
|
} |
||||||
|
|
||||||
|
private function setupMiddleware(): void |
||||||
|
{ |
||||||
|
$this->app->addBodyParsingMiddleware(); |
||||||
|
$this->app->addRoutingMiddleware(); |
||||||
|
$this->app->addErrorMiddleware(true, true, true); |
||||||
|
} |
||||||
|
|
||||||
|
private function setupRoutes(): void |
||||||
|
{ |
||||||
|
$jwtMiddleware = $this->di->get(JwtMiddleware::class); |
||||||
|
$authController = $this->di->get(AuthController::class); |
||||||
|
$storeController = $this->di->get(StoreController::class); |
||||||
|
|
||||||
|
// Public routes |
||||||
|
$this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($authController) { |
||||||
|
$group->post('/login', [$authController, 'login']); |
||||||
|
}); |
||||||
|
|
||||||
|
// Protected routes |
||||||
|
$this->app->group('/api/v1', function (RouteCollectorProxy $group) use ($storeController, $jwtMiddleware) { |
||||||
|
$group->group('/items', function (RouteCollectorProxy $itemGroup) use ($storeController) { |
||||||
|
$itemGroup->get('', [$storeController, 'listItems']); |
||||||
|
$itemGroup->post('', [$storeController, 'addItem']); |
||||||
|
$itemGroup->get('/{id}', [$storeController, 'getItem']); |
||||||
|
$itemGroup->delete('/{id}', [$storeController, 'deleteItem']); |
||||||
|
})->add($jwtMiddleware); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public function run(): void |
||||||
|
{ |
||||||
|
$this->app->run(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,67 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Commands; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Domain\Policies\ItemExpirationPolicy; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class AddItem |
||||||
|
{ |
||||||
|
private IItemRepository $itemRepository; |
||||||
|
private IOrderService $orderService; |
||||||
|
private ITimeProvider $timeProvider; |
||||||
|
private ItemExpirationPolicy $expirationPolicy; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
IItemRepository $itemRepository, |
||||||
|
IOrderService $orderService, |
||||||
|
ITimeProvider $timeProvider, |
||||||
|
LoggerInterface $logger |
||||||
|
) { |
||||||
|
$this->itemRepository = $itemRepository; |
||||||
|
$this->orderService = $orderService; |
||||||
|
$this->timeProvider = $timeProvider; |
||||||
|
$this->expirationPolicy = new ItemExpirationPolicy(); |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(string $name, string $expirationDate, string $orderUrl, string $userId): string |
||||||
|
{ |
||||||
|
try { |
||||||
|
$item = new Item( |
||||||
|
uniqid('item_', true), |
||||||
|
$name, |
||||||
|
new \DateTimeImmutable($expirationDate), |
||||||
|
$orderUrl, |
||||||
|
$userId |
||||||
|
); |
||||||
|
|
||||||
|
$currentTime = $this->timeProvider->now(); |
||||||
|
$this->expirationPolicy->checkExpiration($item, $currentTime); |
||||||
|
|
||||||
|
if ($item->isExpired()) { |
||||||
|
try { |
||||||
|
$this->orderService->orderItem($item); |
||||||
|
$item->markAsOrdered(); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to place order for expired item: ' . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->itemRepository->save($item); |
||||||
|
|
||||||
|
return $item->getId(); |
||||||
|
} catch (DomainException $e) { |
||||||
|
throw new ApplicationException('Failed to add item: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Commands; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Exceptions\ItemNotFoundException; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
|
||||||
|
class DeleteItem |
||||||
|
{ |
||||||
|
private IItemRepository $itemRepository; |
||||||
|
|
||||||
|
public function __construct(IItemRepository $itemRepository) |
||||||
|
{ |
||||||
|
$this->itemRepository = $itemRepository; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(string $itemId, string $userId): void |
||||||
|
{ |
||||||
|
try { |
||||||
|
$item = $this->itemRepository->findById($itemId); |
||||||
|
|
||||||
|
if (!$item) { |
||||||
|
throw new ItemNotFoundException("Item with ID {$itemId} not found"); |
||||||
|
} |
||||||
|
|
||||||
|
if ($item->getUserId() !== $userId) { |
||||||
|
throw new ApplicationException("User {$userId} is not authorized to delete item {$itemId}"); |
||||||
|
} |
||||||
|
|
||||||
|
$this->itemRepository->delete($itemId); |
||||||
|
} catch (DomainException $e) { |
||||||
|
throw new ApplicationException('Failed to delete item: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Commands; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use AutoStore\Domain\Policies\ItemExpirationPolicy; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class HandleExpiredItems |
||||||
|
{ |
||||||
|
private IItemRepository $itemRepository; |
||||||
|
private IOrderService $orderService; |
||||||
|
private ITimeProvider $timeProvider; |
||||||
|
private ItemExpirationPolicy $expirationPolicy; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
IItemRepository $itemRepository, |
||||||
|
IOrderService $orderService, |
||||||
|
ITimeProvider $timeProvider, |
||||||
|
LoggerInterface $logger |
||||||
|
) { |
||||||
|
$this->itemRepository = $itemRepository; |
||||||
|
$this->orderService = $orderService; |
||||||
|
$this->timeProvider = $timeProvider; |
||||||
|
$this->expirationPolicy = new ItemExpirationPolicy(); |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(): void |
||||||
|
{ |
||||||
|
try { |
||||||
|
$currentTime = $this->timeProvider->now(); |
||||||
|
$items = $this->itemRepository->findExpired(); |
||||||
|
|
||||||
|
foreach ($items as $item) { |
||||||
|
$this->expirationPolicy->checkExpiration($item, $currentTime); |
||||||
|
|
||||||
|
if ($item->isExpired() && !$item->isOrdered()) { |
||||||
|
try { |
||||||
|
$this->orderService->orderItem($item); |
||||||
|
$item->markAsOrdered(); |
||||||
|
$this->itemRepository->save($item); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (DomainException $e) { |
||||||
|
throw new ApplicationException('Failed to handle expired items: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Commands; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IAuthService; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
|
||||||
|
class LoginUser |
||||||
|
{ |
||||||
|
private IAuthService $authService; |
||||||
|
|
||||||
|
public function __construct(IAuthService $authService) |
||||||
|
{ |
||||||
|
$this->authService = $authService; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(string $username, string $password): string |
||||||
|
{ |
||||||
|
try { |
||||||
|
$token = $this->authService->authenticate($username, $password); |
||||||
|
|
||||||
|
if (!$token) { |
||||||
|
throw new ApplicationException('Invalid credentials'); |
||||||
|
} |
||||||
|
|
||||||
|
return $token; |
||||||
|
} catch (\Exception $e) { |
||||||
|
throw new ApplicationException('Failed to login: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Exceptions; |
||||||
|
|
||||||
|
use Exception; |
||||||
|
|
||||||
|
class ApplicationException extends Exception |
||||||
|
{ |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Exceptions; |
||||||
|
|
||||||
|
class ItemNotFoundException extends ApplicationException |
||||||
|
{ |
||||||
|
public static function create(string $itemId): self |
||||||
|
{ |
||||||
|
return new self("Item '{$itemId}' not found"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Exceptions; |
||||||
|
|
||||||
|
class OrderException extends ApplicationException |
||||||
|
{ |
||||||
|
public static function create(string $orderUrl, string $reason): self |
||||||
|
{ |
||||||
|
return new self("Failed to order item from '{$orderUrl}': {$reason}"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Exceptions; |
||||||
|
|
||||||
|
class UserNotFoundException extends ApplicationException |
||||||
|
{ |
||||||
|
public static function create(string $userId): self |
||||||
|
{ |
||||||
|
return new self("User '{$userId}' not found"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Interfaces; |
||||||
|
|
||||||
|
interface IAuthService |
||||||
|
{ |
||||||
|
public function authenticate(string $username, string $password): ?string; |
||||||
|
|
||||||
|
public function validateToken(string $token): bool; |
||||||
|
|
||||||
|
public function getUserIdFromToken(string $token): ?string; |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Interfaces; |
||||||
|
|
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
|
||||||
|
interface IItemRepository |
||||||
|
{ |
||||||
|
public function save(Item $item): void; |
||||||
|
|
||||||
|
public function findById(string $id): ?Item; |
||||||
|
|
||||||
|
public function findByUserId(string $userId): array; |
||||||
|
|
||||||
|
public function delete(string $id): void; |
||||||
|
|
||||||
|
public function findExpired(): array; |
||||||
|
|
||||||
|
public function exists(string $id): bool; |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Interfaces; |
||||||
|
|
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Application\Exceptions\OrderException; |
||||||
|
|
||||||
|
interface IOrderService |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @throws OrderException |
||||||
|
*/ |
||||||
|
public function orderItem(Item $item): void; |
||||||
|
} |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Interfaces; |
||||||
|
|
||||||
|
use DateTimeImmutable; |
||||||
|
|
||||||
|
interface ITimeProvider |
||||||
|
{ |
||||||
|
public function now(): DateTimeImmutable; |
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Interfaces; |
||||||
|
|
||||||
|
use AutoStore\Domain\Entities\User; |
||||||
|
|
||||||
|
interface IUserRepository |
||||||
|
{ |
||||||
|
public function save(User $user): void; |
||||||
|
|
||||||
|
public function findById(string $id): ?User; |
||||||
|
|
||||||
|
public function findByUsername(string $username): ?User; |
||||||
|
|
||||||
|
public function exists(string $id): bool; |
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Queries; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Exceptions\ItemNotFoundException; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
|
||||||
|
class GetItem |
||||||
|
{ |
||||||
|
private IItemRepository $itemRepository; |
||||||
|
|
||||||
|
public function __construct(IItemRepository $itemRepository) |
||||||
|
{ |
||||||
|
$this->itemRepository = $itemRepository; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(string $itemId, string $userId): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$item = $this->itemRepository->findById($itemId); |
||||||
|
|
||||||
|
if (!$item) { |
||||||
|
throw new ItemNotFoundException("Item with ID {$itemId} not found"); |
||||||
|
} |
||||||
|
|
||||||
|
if ($item->getUserId() !== $userId) { |
||||||
|
throw new ApplicationException("User {$userId} is not authorized to access item {$itemId}"); |
||||||
|
} |
||||||
|
|
||||||
|
return $item->toArray(); |
||||||
|
} catch (DomainException $e) { |
||||||
|
throw new ApplicationException('Failed to get item: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Application\Queries; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
|
||||||
|
class ListItems |
||||||
|
{ |
||||||
|
private IItemRepository $itemRepository; |
||||||
|
|
||||||
|
public function __construct(IItemRepository $itemRepository) |
||||||
|
{ |
||||||
|
$this->itemRepository = $itemRepository; |
||||||
|
} |
||||||
|
|
||||||
|
public function execute(string $userId): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$items = $this->itemRepository->findByUserId($userId); |
||||||
|
|
||||||
|
return array_map(static function ($item) { |
||||||
|
return $item->toArray(); |
||||||
|
}, $items); |
||||||
|
} catch (DomainException $e) { |
||||||
|
throw new ApplicationException('Failed to list items: ' . $e->getMessage(), 0, $e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,180 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore; |
||||||
|
|
||||||
|
use League\Container\Container; |
||||||
|
use AutoStore\Application\Interfaces\IAuthService; |
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use AutoStore\Application\Interfaces\IUserRepository; |
||||||
|
use AutoStore\Application\Services\TaskScheduler; |
||||||
|
use AutoStore\Application\Commands\AddItem; |
||||||
|
use AutoStore\Application\Commands\DeleteItem; |
||||||
|
use AutoStore\Application\Commands\HandleExpiredItems; |
||||||
|
use AutoStore\Application\Commands\LoginUser; |
||||||
|
use AutoStore\Application\Commands\UpdateItem; |
||||||
|
use AutoStore\Application\Queries\GetItem; |
||||||
|
use AutoStore\Application\Queries\ListItems; |
||||||
|
use AutoStore\Infrastructure\Adapters\SystemTimeProvider; |
||||||
|
use AutoStore\Infrastructure\Auth\JwtAuthService; |
||||||
|
use AutoStore\Infrastructure\Http\HttpOrderService; |
||||||
|
use AutoStore\Infrastructure\Http\JwtMiddleware; |
||||||
|
use AutoStore\Infrastructure\Repositories\FileItemRepository; |
||||||
|
use AutoStore\Infrastructure\Repositories\FileUserRepository; |
||||||
|
use AutoStore\WebApi\Controllers\AuthController; |
||||||
|
use AutoStore\WebApi\Controllers\StoreController; |
||||||
|
use GuzzleHttp\Client; |
||||||
|
use Monolog\Logger; |
||||||
|
use Monolog\Handler\StreamHandler; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class DiContainer |
||||||
|
{ |
||||||
|
private const ROOT_DIR = __DIR__ . '/..'; |
||||||
|
private Container $diContainer; |
||||||
|
private string $storagePath; |
||||||
|
private string $jwtSecret; |
||||||
|
|
||||||
|
public function __construct() |
||||||
|
{ |
||||||
|
// Simplified app config, for real app use settings repository (env variables, database, etc.) |
||||||
|
$configJson = file_get_contents(self::ROOT_DIR . '/configuration.json'); |
||||||
|
$config = json_decode($configJson, true); |
||||||
|
$this->storagePath = $config['storage_directory'] ?? self::ROOT_DIR. '/storage'; |
||||||
|
$this->jwtSecret = $config['jwt_secret'] ?? 'secret-key'; |
||||||
|
|
||||||
|
$this->diContainer = new Container(); |
||||||
|
$this->configure(); |
||||||
|
} |
||||||
|
public function add(string $id, $concrete = null): void |
||||||
|
{ |
||||||
|
$this->diContainer->add($id, $concrete); |
||||||
|
} |
||||||
|
|
||||||
|
public function get($id) |
||||||
|
{ |
||||||
|
return $this->diContainer->get($id); |
||||||
|
} |
||||||
|
|
||||||
|
public function getNew($id) |
||||||
|
{ |
||||||
|
return $this->diContainer->getNew($id); |
||||||
|
} |
||||||
|
|
||||||
|
public function has($id): bool |
||||||
|
{ |
||||||
|
return $this->diContainer->has($id); |
||||||
|
} |
||||||
|
|
||||||
|
private function configure(): void |
||||||
|
{ |
||||||
|
$di = $this->diContainer; |
||||||
|
|
||||||
|
// Configure shared parameters |
||||||
|
$di->add('storagePath', $this->storagePath); |
||||||
|
$di->add('jwtSecret', $this->jwtSecret); |
||||||
|
|
||||||
|
// --- Logger --- |
||||||
|
$di->add(LoggerInterface::class, function () { |
||||||
|
$logger = new Logger('autostore'); |
||||||
|
$logger->pushHandler(new StreamHandler($this->storagePath . '/app.log', \Monolog\Level::Debug)); |
||||||
|
return $logger; |
||||||
|
})->setShared(true); |
||||||
|
|
||||||
|
// --- Time Provider --- |
||||||
|
$di->add(ITimeProvider::class, SystemTimeProvider::class)->setShared(true); |
||||||
|
|
||||||
|
// --- HTTP Client --- |
||||||
|
$di->add(Client::class, function () { |
||||||
|
return new Client([ |
||||||
|
'timeout' => 30, |
||||||
|
'headers' => [ |
||||||
|
'User-Agent' => 'AutoStore/1.0' |
||||||
|
] |
||||||
|
]); |
||||||
|
})->setShared(true); |
||||||
|
|
||||||
|
// --- Repositories --- |
||||||
|
$di->add(IUserRepository::class, FileUserRepository::class) |
||||||
|
->addArgument('storagePath') |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(IItemRepository::class, FileItemRepository::class) |
||||||
|
->addArgument('storagePath') |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
// --- Auth Service --- |
||||||
|
$di->add(IAuthService::class, JwtAuthService::class) |
||||||
|
->addArgument(IUserRepository::class) |
||||||
|
->addArgument('jwtSecret') |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
// --- Order Service --- |
||||||
|
$di->add(IOrderService::class, HttpOrderService::class) |
||||||
|
->addArgument(Client::class) |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
// --- JWT Middleware --- |
||||||
|
$di->add(JwtMiddleware::class) |
||||||
|
->addArgument(IAuthService::class) |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
// --- Use Cases --- |
||||||
|
$di->add(LoginUser::class) |
||||||
|
->addArgument(IAuthService::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(AddItem::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->addArgument(IOrderService::class) |
||||||
|
->addArgument(ITimeProvider::class) |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(GetItem::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(ListItems::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(UpdateItem::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->addArgument(IOrderService::class) |
||||||
|
->addArgument(ITimeProvider::class) |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(DeleteItem::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(HandleExpiredItems::class) |
||||||
|
->addArgument(IItemRepository::class) |
||||||
|
->addArgument(IOrderService::class) |
||||||
|
->addArgument(ITimeProvider::class) |
||||||
|
->addArgument(LoggerInterface::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
// --- Controllers --- |
||||||
|
$di->add(AuthController::class) |
||||||
|
->addArgument(LoginUser::class) |
||||||
|
->setShared(true); |
||||||
|
|
||||||
|
$di->add(StoreController::class) |
||||||
|
->addArgument(AddItem::class) |
||||||
|
->addArgument(GetItem::class) |
||||||
|
->addArgument(ListItems::class) |
||||||
|
->addArgument(DeleteItem::class) |
||||||
|
->setShared(true); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,194 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Entities; |
||||||
|
|
||||||
|
use DateTimeImmutable; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use AutoStore\Domain\Exceptions\InvalidItemDataException; |
||||||
|
|
||||||
|
class Item |
||||||
|
{ |
||||||
|
private string $id; |
||||||
|
private string $name; |
||||||
|
private DateTimeImmutable $expirationDate; |
||||||
|
private string $orderUrl; |
||||||
|
private string $userId; |
||||||
|
private bool $expired; |
||||||
|
private bool $ordered; |
||||||
|
private DateTimeImmutable $createdAt; |
||||||
|
private ?DateTimeImmutable $updatedAt; |
||||||
|
public function __construct( |
||||||
|
string $id, |
||||||
|
string $name, |
||||||
|
DateTimeImmutable $expirationDate, |
||||||
|
string $orderUrl, |
||||||
|
string $userId |
||||||
|
) { |
||||||
|
if (empty($id)) { |
||||||
|
throw InvalidItemDataException::create('Item ID cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
if (empty($name)) { |
||||||
|
throw InvalidItemDataException::create('Item name cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
if (empty($orderUrl)) { |
||||||
|
throw InvalidItemDataException::create('Order URL cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
if (empty($userId)) { |
||||||
|
throw InvalidItemDataException::create('User ID cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
$this->id = $id; |
||||||
|
$this->name = $name; |
||||||
|
$this->expirationDate = $expirationDate; |
||||||
|
$this->orderUrl = $orderUrl; |
||||||
|
$this->userId = $userId; |
||||||
|
$this->expired = false; |
||||||
|
$this->ordered = false; |
||||||
|
$this->createdAt = new DateTimeImmutable(); |
||||||
|
$this->updatedAt = null; |
||||||
|
} |
||||||
|
|
||||||
|
public function getId(): string |
||||||
|
{ |
||||||
|
return $this->id; |
||||||
|
} |
||||||
|
|
||||||
|
public function getName(): string |
||||||
|
{ |
||||||
|
return $this->name; |
||||||
|
} |
||||||
|
|
||||||
|
public function getExpirationDate(): DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->expirationDate; |
||||||
|
} |
||||||
|
|
||||||
|
public function getOrderUrl(): string |
||||||
|
{ |
||||||
|
return $this->orderUrl; |
||||||
|
} |
||||||
|
|
||||||
|
public function getUserId(): string |
||||||
|
{ |
||||||
|
return $this->userId; |
||||||
|
} |
||||||
|
|
||||||
|
public function isExpired(): bool |
||||||
|
{ |
||||||
|
return $this->expired; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->createdAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->updatedAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function markAsExpired(): void |
||||||
|
{ |
||||||
|
if ($this->expired) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->expired = true; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function isOrdered(): bool |
||||||
|
{ |
||||||
|
return $this->ordered; |
||||||
|
} |
||||||
|
|
||||||
|
public function markAsOrdered(): void |
||||||
|
{ |
||||||
|
if ($this->ordered) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->ordered = true; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function updateName(string $name): void |
||||||
|
{ |
||||||
|
if ($this->name === $name) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->name = $name; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function updateExpirationDate(DateTimeImmutable $expirationDate): void |
||||||
|
{ |
||||||
|
if ($this->expirationDate == $expirationDate) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->expirationDate = $expirationDate; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function updateOrderUrl(string $orderUrl): void |
||||||
|
{ |
||||||
|
if ($this->orderUrl === $orderUrl) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->orderUrl = $orderUrl; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function toArray(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
'id' => $this->id, |
||||||
|
'name' => $this->name, |
||||||
|
'expirationDate' => $this->expirationDate->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'orderUrl' => $this->orderUrl, |
||||||
|
'userId' => $this->userId, |
||||||
|
'expired' => $this->expired, |
||||||
|
'is_ordered' => $this->ordered, |
||||||
|
'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'updatedAt' => $this->updatedAt?->format('Y-m-d\TH:i:s.uP'), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public static function fromArray(array $data): self |
||||||
|
{ |
||||||
|
if (!isset($data['id'], $data['name'], $data['expirationDate'], $data['orderUrl'], $data['userId'])) { |
||||||
|
throw new DomainException('Invalid item data'); |
||||||
|
} |
||||||
|
|
||||||
|
$item = new self( |
||||||
|
$data['id'], |
||||||
|
$data['name'], |
||||||
|
new DateTimeImmutable($data['expirationDate']), |
||||||
|
$data['orderUrl'], |
||||||
|
$data['userId'] |
||||||
|
); |
||||||
|
|
||||||
|
if (isset($data['expired']) && is_bool($data['expired'])) { |
||||||
|
if ($data['expired']) { |
||||||
|
$item->markAsExpired(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (isset($data['is_ordered']) && is_bool($data['is_ordered'])) { |
||||||
|
if ($data['is_ordered']) { |
||||||
|
$item->markAsOrdered(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $item; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,97 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Entities; |
||||||
|
|
||||||
|
use DateTimeImmutable; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use AutoStore\Domain\Exceptions\InvalidUserDataException; |
||||||
|
|
||||||
|
class User |
||||||
|
{ |
||||||
|
private string $id; |
||||||
|
private string $username; |
||||||
|
private string $passwordHash; |
||||||
|
private DateTimeImmutable $createdAt; |
||||||
|
private ?DateTimeImmutable $updatedAt; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
string $id, |
||||||
|
string $username, |
||||||
|
string $passwordHash |
||||||
|
) { |
||||||
|
if (empty($id)) { |
||||||
|
throw InvalidUserDataException::create('User ID cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
if (empty($username)) { |
||||||
|
throw InvalidUserDataException::create('Username cannot be empty'); |
||||||
|
} |
||||||
|
|
||||||
|
$this->id = $id; |
||||||
|
$this->username = $username; |
||||||
|
$this->passwordHash = $passwordHash; |
||||||
|
$this->createdAt = new DateTimeImmutable(); |
||||||
|
$this->updatedAt = null; |
||||||
|
} |
||||||
|
|
||||||
|
public function getId(): string |
||||||
|
{ |
||||||
|
return $this->id; |
||||||
|
} |
||||||
|
|
||||||
|
public function getUsername(): string |
||||||
|
{ |
||||||
|
return $this->username; |
||||||
|
} |
||||||
|
|
||||||
|
public function getPasswordHash(): string |
||||||
|
{ |
||||||
|
return $this->passwordHash; |
||||||
|
} |
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->createdAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable |
||||||
|
{ |
||||||
|
return $this->updatedAt; |
||||||
|
} |
||||||
|
|
||||||
|
public function updateUsername(string $username): void |
||||||
|
{ |
||||||
|
if ($this->username === $username) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->username = $username; |
||||||
|
$this->updatedAt = new DateTimeImmutable(); |
||||||
|
} |
||||||
|
|
||||||
|
public function toArray(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
'id' => $this->id, |
||||||
|
'username' => $this->username, |
||||||
|
'passwordHash' => $this->passwordHash, |
||||||
|
'createdAt' => $this->createdAt->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'updatedAt' => $this->updatedAt?->format('Y-m-d\TH:i:s.uP'), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
public static function fromArray(array $data): self |
||||||
|
{ |
||||||
|
if (!isset($data['id'], $data['username'], $data['passwordHash'])) { |
||||||
|
throw new DomainException('Invalid user data'); |
||||||
|
} |
||||||
|
|
||||||
|
return new self( |
||||||
|
$data['id'], |
||||||
|
$data['username'], |
||||||
|
$data['passwordHash'] |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
use Exception; |
||||||
|
|
||||||
|
class DomainException extends Exception |
||||||
|
{ |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
class InvalidItemDataException extends DomainException |
||||||
|
{ |
||||||
|
public static function create(string $reason): self |
||||||
|
{ |
||||||
|
return new self($reason); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
class InvalidUserDataException extends DomainException |
||||||
|
{ |
||||||
|
public static function create(string $reason): self |
||||||
|
{ |
||||||
|
return new self($reason); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
class ItemExpiredException extends DomainException |
||||||
|
{ |
||||||
|
public static function create(string $itemId): self |
||||||
|
{ |
||||||
|
return new self("Item '{$itemId}' is expired"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
use Exception; |
||||||
|
|
||||||
|
class ItemNotFoundException extends Exception |
||||||
|
{ |
||||||
|
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null) |
||||||
|
{ |
||||||
|
parent::__construct($message, $code, $previous); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Exceptions; |
||||||
|
|
||||||
|
class UnauthorizedAccessException extends DomainException |
||||||
|
{ |
||||||
|
public static function create(string $userId, string $itemId): self |
||||||
|
{ |
||||||
|
return new self("User '{$userId}' is not authorized to access item '{$itemId}'"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Domain\Policies; |
||||||
|
|
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use DateTimeImmutable; |
||||||
|
|
||||||
|
class ItemExpirationPolicy |
||||||
|
{ |
||||||
|
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool |
||||||
|
{ |
||||||
|
return $item->getExpirationDate() <= $currentTime; |
||||||
|
} |
||||||
|
|
||||||
|
public function checkExpiration(Item $item, DateTimeImmutable $currentTime): void |
||||||
|
{ |
||||||
|
if ($this->isExpired($item, $currentTime) && !$item->isExpired()) { |
||||||
|
$item->markAsExpired(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Adapters; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use DateTimeImmutable; |
||||||
|
|
||||||
|
class SystemTimeProvider implements ITimeProvider |
||||||
|
{ |
||||||
|
public function now(): DateTimeImmutable |
||||||
|
{ |
||||||
|
return new DateTimeImmutable(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,88 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Auth; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IAuthService; |
||||||
|
use Firebase\JWT\JWT; |
||||||
|
use Firebase\JWT\Key; |
||||||
|
use Firebase\JWT\ExpiredException; |
||||||
|
use Firebase\JWT\SignatureInvalidException; |
||||||
|
use AutoStore\Application\Exceptions\UserNotFoundException; |
||||||
|
use AutoStore\Application\Interfaces\IUserRepository; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class JwtAuthService implements IAuthService |
||||||
|
{ |
||||||
|
private IUserRepository $userRepository; |
||||||
|
private string $secretKey; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
IUserRepository $userRepository, |
||||||
|
string $secretKey, |
||||||
|
LoggerInterface $logger |
||||||
|
) { |
||||||
|
$this->userRepository = $userRepository; |
||||||
|
$this->secretKey = $secretKey; |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @note Showcase only, use a proper service |
||||||
|
*/ |
||||||
|
public function authenticate(string $username, string $password): ?string |
||||||
|
{ |
||||||
|
$user = $this->userRepository->findByUsername($username); |
||||||
|
|
||||||
|
if ($user === null) { |
||||||
|
$this->logger->warning("User not found: {$username}"); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the password against the stored hash |
||||||
|
if (password_verify($password, $user->getPasswordHash())) { |
||||||
|
$payload = [ |
||||||
|
'iss' => 'autostore', |
||||||
|
'sub' => $user->getId(), |
||||||
|
'iat' => time(), |
||||||
|
'exp' => time() + 3600, // 1 hour expiration |
||||||
|
'username' => $user->getUsername() |
||||||
|
]; |
||||||
|
|
||||||
|
return JWT::encode($payload, $this->secretKey, 'HS256'); |
||||||
|
} |
||||||
|
|
||||||
|
$this->logger->warning("Invalid password for user: {$username}"); |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
public function validateToken(string $token): bool |
||||||
|
{ |
||||||
|
try { |
||||||
|
JWT::decode($token, new Key($this->secretKey, 'HS256')); |
||||||
|
return true; |
||||||
|
} catch (ExpiredException $e) { |
||||||
|
$this->logger->warning('Token expired'); |
||||||
|
return false; |
||||||
|
} catch (SignatureInvalidException $e) { |
||||||
|
$this->logger->warning('Invalid token signature'); |
||||||
|
return false; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Token validation error: ' . $e->getMessage()); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function getUserIdFromToken(string $token): ?string |
||||||
|
{ |
||||||
|
try { |
||||||
|
$decoded = JWT::decode($token, new Key($this->secretKey, 'HS256')); |
||||||
|
return $decoded->sub; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Error extracting user ID from token: ' . $e->getMessage()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Http; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\OrderException; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use GuzzleHttp\Client; |
||||||
|
use GuzzleHttp\Exception\RequestException; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class HttpOrderService implements IOrderService |
||||||
|
{ |
||||||
|
private Client $httpClient; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
public function __construct(Client $httpClient, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->httpClient = $httpClient; |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @throws OrderException |
||||||
|
*/ |
||||||
|
public function orderItem(Item $item): void |
||||||
|
{ |
||||||
|
try { |
||||||
|
$orderData = [ |
||||||
|
'itemId' => $item->getId(), |
||||||
|
'itemName' => $item->getName(), |
||||||
|
'userId' => $item->getUserId(), |
||||||
|
'expirationDate' => $item->getExpirationDate()->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'orderTimestamp' => (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.uP') |
||||||
|
]; |
||||||
|
|
||||||
|
$response = $this->httpClient->post($item->getOrderUrl(), [ |
||||||
|
'json' => $orderData, |
||||||
|
'timeout' => 30, |
||||||
|
'headers' => [ |
||||||
|
'Content-Type' => 'application/json', |
||||||
|
'User-Agent' => 'AutoStore/1.0' |
||||||
|
] |
||||||
|
]); |
||||||
|
|
||||||
|
$statusCode = $response->getStatusCode(); |
||||||
|
|
||||||
|
if ($statusCode < 200 || $statusCode >= 300) { |
||||||
|
throw OrderException::create($item->getOrderUrl(), "HTTP status code: {$statusCode}"); |
||||||
|
} |
||||||
|
|
||||||
|
$this->logger->info("Order placed successfully for item {$item->getId()}"); |
||||||
|
} catch (RequestException $e) { |
||||||
|
$this->logger->error("Failed to place order for item {$item->getId()}: " . $e->getMessage()); |
||||||
|
throw OrderException::create($item->getOrderUrl(), $e->getMessage()); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error("Unexpected error placing order for item {$item->getId()}: " . $e->getMessage()); |
||||||
|
throw OrderException::create($item->getOrderUrl(), $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Http; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IAuthService; |
||||||
|
use Psr\Http\Message\ResponseInterface; |
||||||
|
use Psr\Http\Message\ServerRequestInterface; |
||||||
|
use Psr\Http\Server\MiddlewareInterface; |
||||||
|
use Psr\Http\Server\RequestHandlerInterface; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class JwtMiddleware implements MiddlewareInterface |
||||||
|
{ |
||||||
|
private IAuthService $authService; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
public function __construct(IAuthService $authService, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->authService = $authService; |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface |
||||||
|
{ |
||||||
|
$authHeader = $request->getHeaderLine('Authorization'); |
||||||
|
|
||||||
|
if (empty($authHeader)) { |
||||||
|
$this->logger->warning('Missing Authorization header'); |
||||||
|
return $this->createUnauthorizedResponse('Missing Authorization header'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { |
||||||
|
$this->logger->warning('Invalid Authorization header format'); |
||||||
|
return $this->createUnauthorizedResponse('Invalid Authorization header format'); |
||||||
|
} |
||||||
|
|
||||||
|
$token = $matches[1]; |
||||||
|
|
||||||
|
if (!$this->authService->validateToken($token)) { |
||||||
|
$this->logger->warning('Invalid or expired token'); |
||||||
|
return $this->createUnauthorizedResponse('Invalid or expired token'); |
||||||
|
} |
||||||
|
|
||||||
|
$userId = $this->authService->getUserIdFromToken($token); |
||||||
|
|
||||||
|
if ($userId === null) { |
||||||
|
$this->logger->warning('Could not extract user ID from token'); |
||||||
|
return $this->createUnauthorizedResponse('Invalid token'); |
||||||
|
} |
||||||
|
|
||||||
|
$request = $request->withAttribute('userId', $userId); |
||||||
|
|
||||||
|
return $handler->handle($request); |
||||||
|
} |
||||||
|
|
||||||
|
private function createUnauthorizedResponse(string $message): ResponseInterface |
||||||
|
{ |
||||||
|
return new \GuzzleHttp\Psr7\Response(401, ['Content-Type' => 'application/json'], json_encode([ |
||||||
|
'status' => 'error', |
||||||
|
'message' => $message |
||||||
|
])); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,172 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Repositories; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class FileItemRepository implements IItemRepository |
||||||
|
{ |
||||||
|
private string $storagePath; |
||||||
|
private LoggerInterface $logger; |
||||||
|
private array $items = []; |
||||||
|
|
||||||
|
public function __construct(string $storagePath, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->storagePath = $storagePath; |
||||||
|
$this->logger = $logger; |
||||||
|
|
||||||
|
$this->ensureStorageDirectoryExists(); |
||||||
|
$this->loadItems(); |
||||||
|
} |
||||||
|
|
||||||
|
public function save(Item $item): void |
||||||
|
{ |
||||||
|
$this->items[$item->getId()] = $item->toArray(); |
||||||
|
$this->persistItems(); |
||||||
|
|
||||||
|
$this->logger->info("Item saved: {$item->getId()}"); |
||||||
|
} |
||||||
|
|
||||||
|
public function findById(string $id): ?Item |
||||||
|
{ |
||||||
|
if (!isset($this->items[$id])) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
return Item::fromArray($this->items[$id]); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create item from data: " . $e->getMessage()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function findByUserId(string $userId): array |
||||||
|
{ |
||||||
|
$result = []; |
||||||
|
|
||||||
|
foreach ($this->items as $itemData) { |
||||||
|
if ($itemData['userId'] === $userId) { |
||||||
|
try { |
||||||
|
$result[] = Item::fromArray($itemData); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create item from data: " . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $result; |
||||||
|
} |
||||||
|
|
||||||
|
public function findAll(): array |
||||||
|
{ |
||||||
|
$result = []; |
||||||
|
|
||||||
|
foreach ($this->items as $itemData) { |
||||||
|
try { |
||||||
|
$result[] = Item::fromArray($itemData); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create item from data: " . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $result; |
||||||
|
} |
||||||
|
|
||||||
|
public function findExpiredItems(): array |
||||||
|
{ |
||||||
|
return $this->findExpired(); |
||||||
|
} |
||||||
|
|
||||||
|
public function delete(string $id): void |
||||||
|
{ |
||||||
|
if (!isset($this->items[$id])) { |
||||||
|
throw new ApplicationException("Item '{$id}' not found"); |
||||||
|
} |
||||||
|
|
||||||
|
unset($this->items[$id]); |
||||||
|
$this->persistItems(); |
||||||
|
|
||||||
|
$this->logger->info("Item deleted: {$id}"); |
||||||
|
} |
||||||
|
|
||||||
|
public function findExpired(): array |
||||||
|
{ |
||||||
|
$result = []; |
||||||
|
$now = new \DateTimeImmutable(); |
||||||
|
|
||||||
|
foreach ($this->items as $itemData) { |
||||||
|
$expirationDate = new \DateTimeImmutable($itemData['expirationDate']); |
||||||
|
|
||||||
|
if ($expirationDate <= $now) { |
||||||
|
try { |
||||||
|
$result[] = Item::fromArray($itemData); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create item from data: " . $e->getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $result; |
||||||
|
} |
||||||
|
|
||||||
|
public function exists(string $id): bool |
||||||
|
{ |
||||||
|
return isset($this->items[$id]); |
||||||
|
} |
||||||
|
|
||||||
|
private function ensureStorageDirectoryExists(): void |
||||||
|
{ |
||||||
|
if (!is_dir($this->storagePath)) { |
||||||
|
if (!mkdir($this->storagePath, 0755, true)) { |
||||||
|
throw new ApplicationException("Failed to create storage directory: {$this->storagePath}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function loadItems(): void |
||||||
|
{ |
||||||
|
$filename = $this->storagePath . '/items.json'; |
||||||
|
|
||||||
|
if (!file_exists($filename)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$content = file_get_contents($filename); |
||||||
|
|
||||||
|
if ($content === false) { |
||||||
|
throw new ApplicationException("Failed to read items file: {$filename}"); |
||||||
|
} |
||||||
|
|
||||||
|
$data = json_decode($content, true); |
||||||
|
|
||||||
|
if ($data === null) { |
||||||
|
throw new ApplicationException("Failed to decode items JSON: " . json_last_error_msg()); |
||||||
|
} |
||||||
|
|
||||||
|
$this->items = $data; |
||||||
|
$this->logger->info("Loaded " . count($this->items) . " items from storage"); |
||||||
|
} |
||||||
|
|
||||||
|
private function persistItems(): void |
||||||
|
{ |
||||||
|
$filename = $this->storagePath . '/items.json'; |
||||||
|
$content = json_encode($this->items, JSON_PRETTY_PRINT); |
||||||
|
|
||||||
|
if ($content === false) { |
||||||
|
throw new ApplicationException("Failed to encode items to JSON"); |
||||||
|
} |
||||||
|
|
||||||
|
$result = file_put_contents($filename, $content); |
||||||
|
|
||||||
|
if ($result === false) { |
||||||
|
throw new ApplicationException("Failed to write items file: {$filename}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,145 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Infrastructure\Repositories; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Interfaces\IUserRepository; |
||||||
|
use AutoStore\Domain\Entities\User; |
||||||
|
use AutoStore\Domain\Exceptions\DomainException; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class FileUserRepository implements IUserRepository |
||||||
|
{ |
||||||
|
private string $storagePath; |
||||||
|
private LoggerInterface $logger; |
||||||
|
private array $users = []; |
||||||
|
|
||||||
|
public function __construct(string $storagePath, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->storagePath = $storagePath; |
||||||
|
$this->logger = $logger; |
||||||
|
|
||||||
|
$this->ensureStorageDirectoryExists(); |
||||||
|
$this->loadUsers(); |
||||||
|
} |
||||||
|
|
||||||
|
public function save(User $user): void |
||||||
|
{ |
||||||
|
$this->users[$user->getId()] = $user->toArray(); |
||||||
|
$this->persistUsers(); |
||||||
|
|
||||||
|
$this->logger->info("User saved: {$user->getId()}"); |
||||||
|
} |
||||||
|
|
||||||
|
public function findById(string $id): ?User |
||||||
|
{ |
||||||
|
if (!isset($this->users[$id])) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
return User::fromArray($this->users[$id]); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create user from data: " . $e->getMessage()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function findByUsername(string $username): ?User |
||||||
|
{ |
||||||
|
foreach ($this->users as $userData) { |
||||||
|
if ($userData['username'] === $username) { |
||||||
|
try { |
||||||
|
return User::fromArray($userData); |
||||||
|
} catch (DomainException $e) { |
||||||
|
$this->logger->error("Failed to create user from data: " . $e->getMessage()); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
public function exists(string $id): bool |
||||||
|
{ |
||||||
|
return isset($this->users[$id]); |
||||||
|
} |
||||||
|
|
||||||
|
private function ensureStorageDirectoryExists(): void |
||||||
|
{ |
||||||
|
if (!is_dir($this->storagePath)) { |
||||||
|
if (!mkdir($this->storagePath, 0755, true)) { |
||||||
|
throw new ApplicationException("Failed to create storage directory: {$this->storagePath}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function loadUsers(): void |
||||||
|
{ |
||||||
|
$filename = $this->storagePath . '/users.json'; |
||||||
|
|
||||||
|
if (!file_exists($filename)) { |
||||||
|
$this->createDefaultUsers(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$content = file_get_contents($filename); |
||||||
|
|
||||||
|
if ($content === false) { |
||||||
|
throw new ApplicationException("Failed to read users file: {$filename}"); |
||||||
|
} |
||||||
|
|
||||||
|
$data = json_decode($content, true); |
||||||
|
|
||||||
|
if ($data === null) { |
||||||
|
throw new ApplicationException("Failed to decode users JSON: " . json_last_error_msg()); |
||||||
|
} |
||||||
|
|
||||||
|
$this->users = $data; |
||||||
|
$this->logger->info("Loaded " . count($this->users) . " users from storage"); |
||||||
|
} |
||||||
|
|
||||||
|
private function persistUsers(): void |
||||||
|
{ |
||||||
|
$filename = $this->storagePath . '/users.json'; |
||||||
|
$content = json_encode($this->users, JSON_PRETTY_PRINT); |
||||||
|
|
||||||
|
if ($content === false) { |
||||||
|
throw new ApplicationException("Failed to encode users to JSON"); |
||||||
|
} |
||||||
|
|
||||||
|
$result = file_put_contents($filename, $content); |
||||||
|
|
||||||
|
if ($result === false) { |
||||||
|
throw new ApplicationException("Failed to write users file: {$filename}"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function createDefaultUsers(): void |
||||||
|
{ |
||||||
|
$defaultUsers = [ |
||||||
|
[ |
||||||
|
'id' => '1000', |
||||||
|
'username' => 'admin', |
||||||
|
'passwordHash' => password_hash('admin', PASSWORD_DEFAULT), |
||||||
|
'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'updatedAt' => null |
||||||
|
], |
||||||
|
[ |
||||||
|
'id' => '1001', |
||||||
|
'username' => 'user', |
||||||
|
'passwordHash' => password_hash('user', PASSWORD_DEFAULT), |
||||||
|
'createdAt' => (new \DateTimeImmutable())->format('Y-m-d\TH:i:s.uP'), |
||||||
|
'updatedAt' => null |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$this->users = $defaultUsers; |
||||||
|
$this->persistUsers(); |
||||||
|
|
||||||
|
$this->logger->info("Created default users"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\WebApi\Controllers; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Commands\LoginUser; |
||||||
|
use Psr\Http\Message\ResponseInterface as Response; |
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request; |
||||||
|
|
||||||
|
class AuthController extends BaseController |
||||||
|
{ |
||||||
|
private LoginUser $loginUser; |
||||||
|
|
||||||
|
public function __construct(LoginUser $loginUser) |
||||||
|
{ |
||||||
|
$this->loginUser = $loginUser; |
||||||
|
} |
||||||
|
|
||||||
|
public function login(Request $request, Response $response): Response |
||||||
|
{ |
||||||
|
try { |
||||||
|
$data = $this->getParsedBody($request); |
||||||
|
|
||||||
|
if (!isset($data['username'], $data['password'])) { |
||||||
|
return $this->createErrorResponse($response, 'Username and password are required', 400); |
||||||
|
} |
||||||
|
|
||||||
|
$token = $this->loginUser->execute($data['username'], $data['password']); |
||||||
|
|
||||||
|
return $this->createSuccessResponse($response, [ |
||||||
|
'token' => $token, |
||||||
|
'tokenType' => 'Bearer', |
||||||
|
'expiresIn' => 3600 |
||||||
|
]); |
||||||
|
} catch (ApplicationException $e) { |
||||||
|
return $this->createErrorResponse($response, $e->getMessage(), 401); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->createErrorResponse($response, 'Internal server error', 500); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\WebApi\Controllers; |
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response; |
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request; |
||||||
|
|
||||||
|
abstract class BaseController |
||||||
|
{ |
||||||
|
protected function createSuccessResponse(Response $response, ?array $data = null, int $statusCode = 200): Response |
||||||
|
{ |
||||||
|
$payload = [ |
||||||
|
'status' => 'success', |
||||||
|
'data' => $data |
||||||
|
]; |
||||||
|
|
||||||
|
$response->getBody()->write(json_encode($payload)); |
||||||
|
return $response |
||||||
|
->withHeader('Content-Type', 'application/json') |
||||||
|
->withStatus($statusCode); |
||||||
|
} |
||||||
|
|
||||||
|
protected function createErrorResponse(Response $response, string $message, int $statusCode): Response |
||||||
|
{ |
||||||
|
$payload = [ |
||||||
|
'status' => 'error', |
||||||
|
'message' => $message |
||||||
|
]; |
||||||
|
|
||||||
|
$response->getBody()->write(json_encode($payload)); |
||||||
|
return $response |
||||||
|
->withHeader('Content-Type', 'application/json') |
||||||
|
->withStatus($statusCode); |
||||||
|
} |
||||||
|
|
||||||
|
protected function getParsedBody(Request $request): ?array |
||||||
|
{ |
||||||
|
$body = $request->getParsedBody(); |
||||||
|
|
||||||
|
if (is_array($body)) { |
||||||
|
return $body; |
||||||
|
} |
||||||
|
|
||||||
|
$contentType = $request->getHeaderLine('Content-Type'); |
||||||
|
|
||||||
|
if (strpos($contentType, 'application/json') !== false) { |
||||||
|
$rawBody = $request->getBody()->getContents(); |
||||||
|
$parsedBody = json_decode($rawBody, true); |
||||||
|
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) { |
||||||
|
return $parsedBody; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\WebApi\Controllers; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Application\Commands\AddItem; |
||||||
|
use AutoStore\Application\Commands\DeleteItem; |
||||||
|
use AutoStore\Application\Queries\GetItem; |
||||||
|
use AutoStore\Application\Queries\ListItems; |
||||||
|
use Psr\Http\Message\ResponseInterface as Response; |
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request; |
||||||
|
|
||||||
|
class StoreController extends BaseController |
||||||
|
{ |
||||||
|
private AddItem $addItem; |
||||||
|
private GetItem $getItem; |
||||||
|
private ListItems $listItems; |
||||||
|
private DeleteItem $deleteItem; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
AddItem $addItem, |
||||||
|
GetItem $getItem, |
||||||
|
ListItems $listItems, |
||||||
|
DeleteItem $deleteItem |
||||||
|
) { |
||||||
|
$this->addItem = $addItem; |
||||||
|
$this->getItem = $getItem; |
||||||
|
$this->listItems = $listItems; |
||||||
|
$this->deleteItem = $deleteItem; |
||||||
|
} |
||||||
|
|
||||||
|
public function addItem(Request $request, Response $response): Response |
||||||
|
{ |
||||||
|
try { |
||||||
|
$userId = $request->getAttribute('userId'); |
||||||
|
$data = $request->getParsedBody(); |
||||||
|
|
||||||
|
if (!isset($data['name'], $data['expirationDate'], $data['orderUrl'])) { |
||||||
|
return $this->createErrorResponse($response, 'Missing required fields', 400); |
||||||
|
} |
||||||
|
|
||||||
|
$itemId = $this->addItem->execute( |
||||||
|
$data['name'], |
||||||
|
$data['expirationDate'], |
||||||
|
$data['orderUrl'], |
||||||
|
$userId |
||||||
|
); |
||||||
|
|
||||||
|
return $this->createSuccessResponse($response, ['id' => $itemId], 201); |
||||||
|
} catch (ApplicationException $e) { |
||||||
|
return $this->createErrorResponse($response, $e->getMessage(), 400); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->createErrorResponse($response, 'Internal server error', 500); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function getItem(Request $request, Response $response, array $args): Response |
||||||
|
{ |
||||||
|
try { |
||||||
|
$userId = $request->getAttribute('userId'); |
||||||
|
$itemId = $args['id'] ?? ''; |
||||||
|
|
||||||
|
if (empty($itemId)) { |
||||||
|
return $this->createErrorResponse($response, 'Item ID is required', 400); |
||||||
|
} |
||||||
|
|
||||||
|
$itemData = $this->getItem->execute($itemId, $userId); |
||||||
|
|
||||||
|
return $this->createSuccessResponse($response, $itemData); |
||||||
|
} catch (ApplicationException $e) { |
||||||
|
return $this->createErrorResponse($response, $e->getMessage(), 404); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->createErrorResponse($response, 'Internal server error', 500); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function listItems(Request $request, Response $response): Response |
||||||
|
{ |
||||||
|
try { |
||||||
|
$userId = $request->getAttribute('userId'); |
||||||
|
$items = $this->listItems->execute($userId); |
||||||
|
|
||||||
|
return $this->createSuccessResponse($response, $items); |
||||||
|
} catch (ApplicationException $e) { |
||||||
|
return $this->createErrorResponse($response, $e->getMessage(), 400); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->createErrorResponse($response, 'Internal server error', 500); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function deleteItem(Request $request, Response $response, array $args): Response |
||||||
|
{ |
||||||
|
try { |
||||||
|
$userId = $request->getAttribute('userId'); |
||||||
|
$itemId = $args['id'] ?? ''; |
||||||
|
|
||||||
|
if (empty($itemId)) { |
||||||
|
return $this->createErrorResponse($response, 'Item ID is required', 400); |
||||||
|
} |
||||||
|
|
||||||
|
$this->deleteItem->execute($itemId, $userId); |
||||||
|
|
||||||
|
return $this->createSuccessResponse($response, null, 204); |
||||||
|
} catch (ApplicationException $e) { |
||||||
|
return $this->createErrorResponse($response, $e->getMessage(), 404); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->createErrorResponse($response, 'Internal server error', 500); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,207 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Tests\Infrastructure\Repositories; |
||||||
|
|
||||||
|
use AutoStore\Application\Exceptions\ApplicationException; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use AutoStore\Infrastructure\Repositories\FileItemRepository; |
||||||
|
use DateTimeImmutable; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class FileItemRepositoryTest extends TestCase |
||||||
|
{ |
||||||
|
private FileItemRepository $repository; |
||||||
|
private string $testStoragePath; |
||||||
|
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->testStoragePath = $GLOBALS['test_storage_path']; |
||||||
|
|
||||||
|
// Clean up any existing test files |
||||||
|
array_map('unlink', glob("$this->testStoragePath/*.json")); |
||||||
|
|
||||||
|
$this->logger = $this->createMock(LoggerInterface::class); |
||||||
|
$this->repository = new FileItemRepository($this->testStoragePath, $this->logger); |
||||||
|
} |
||||||
|
|
||||||
|
protected function tearDown(): void |
||||||
|
{ |
||||||
|
// Clean up test files |
||||||
|
array_map('unlink', glob("$this->testStoragePath/*.json")); |
||||||
|
} |
||||||
|
|
||||||
|
public function testSaveShouldCreateFileForNewItem(): void |
||||||
|
{ |
||||||
|
$item = new Item( |
||||||
|
'test-id', |
||||||
|
'Test Item', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/order', |
||||||
|
'user-id' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($item); |
||||||
|
|
||||||
|
$filePath = $this->testStoragePath . '/items.json'; |
||||||
|
$this->assertFileExists($filePath); |
||||||
|
|
||||||
|
$fileContent = file_get_contents($filePath); |
||||||
|
$data = json_decode($fileContent, true); |
||||||
|
|
||||||
|
$this->assertIsArray($data); |
||||||
|
$this->assertCount(1, $data); |
||||||
|
|
||||||
|
// Compare the essential fields |
||||||
|
$this->assertEquals($item->getId(), $data[$item->getId()]['id']); |
||||||
|
$this->assertEquals($item->getName(), $data[$item->getId()]['name']); |
||||||
|
$this->assertEquals($item->getOrderUrl(), $data[$item->getId()]['orderUrl']); |
||||||
|
$this->assertEquals($item->getUserId(), $data[$item->getId()]['userId']); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindByIdShouldReturnItemIfExists(): void |
||||||
|
{ |
||||||
|
$item = new Item( |
||||||
|
'test-id', |
||||||
|
'Test Item', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/order', |
||||||
|
'user-id' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($item); |
||||||
|
|
||||||
|
$foundItem = $this->repository->findById('test-id'); |
||||||
|
|
||||||
|
$this->assertNotNull($foundItem); |
||||||
|
$this->assertSame($item->getId(), $foundItem->getId()); |
||||||
|
$this->assertSame($item->getName(), $foundItem->getName()); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindByIdShouldReturnNullIfNotExists(): void |
||||||
|
{ |
||||||
|
$foundItem = $this->repository->findById('non-existent-id'); |
||||||
|
$this->assertNull($foundItem); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindByUserIdShouldReturnItemsForUser(): void |
||||||
|
{ |
||||||
|
$item1 = new Item( |
||||||
|
'test-id-1', |
||||||
|
'Test Item 1', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/order1', |
||||||
|
'user-id-1' |
||||||
|
); |
||||||
|
|
||||||
|
$item2 = new Item( |
||||||
|
'test-id-2', |
||||||
|
'Test Item 2', |
||||||
|
new DateTimeImmutable('+2 days'), |
||||||
|
'http://example.com/order2', |
||||||
|
'user-id-2' |
||||||
|
); |
||||||
|
|
||||||
|
$item3 = new Item( |
||||||
|
'test-id-3', |
||||||
|
'Test Item 3', |
||||||
|
new DateTimeImmutable('+3 days'), |
||||||
|
'http://example.com/order3', |
||||||
|
'user-id-1' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($item1); |
||||||
|
$this->repository->save($item2); |
||||||
|
$this->repository->save($item3); |
||||||
|
|
||||||
|
$userItems = $this->repository->findByUserId('user-id-1'); |
||||||
|
|
||||||
|
$this->assertCount(2, $userItems); |
||||||
|
$this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $userItems)); |
||||||
|
$this->assertContainsEquals($item3->getId(), array_map(fn($i) => $i->getId(), $userItems)); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindAllShouldReturnAllItems(): void |
||||||
|
{ |
||||||
|
$item1 = new Item( |
||||||
|
'test-id-1', |
||||||
|
'Test Item 1', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/order1', |
||||||
|
'user-id-1' |
||||||
|
); |
||||||
|
|
||||||
|
$item2 = new Item( |
||||||
|
'test-id-2', |
||||||
|
'Test Item 2', |
||||||
|
new DateTimeImmutable('+2 days'), |
||||||
|
'http://example.com/order2', |
||||||
|
'user-id-2' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($item1); |
||||||
|
$this->repository->save($item2); |
||||||
|
|
||||||
|
$allItems = $this->repository->findAll(); |
||||||
|
|
||||||
|
$this->assertCount(2, $allItems); |
||||||
|
$this->assertContainsEquals($item1->getId(), array_map(fn($i) => $i->getId(), $allItems)); |
||||||
|
$this->assertContainsEquals($item2->getId(), array_map(fn($i) => $i->getId(), $allItems)); |
||||||
|
} |
||||||
|
|
||||||
|
public function testDeleteShouldRemoveItem(): void |
||||||
|
{ |
||||||
|
$item = new Item( |
||||||
|
'test-id', |
||||||
|
'Test Item', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/order', |
||||||
|
'user-id' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($item); |
||||||
|
|
||||||
|
$this->repository->delete('test-id'); |
||||||
|
|
||||||
|
$foundItem = $this->repository->findById('test-id'); |
||||||
|
$this->assertNull($foundItem); |
||||||
|
} |
||||||
|
|
||||||
|
public function testDeleteShouldThrowExceptionForNonExistentItem(): void |
||||||
|
{ |
||||||
|
$this->expectException(ApplicationException::class); |
||||||
|
$this->expectExceptionMessage("Item 'non-existent-id' not found"); |
||||||
|
|
||||||
|
$this->repository->delete('non-existent-id'); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindExpiredItemsShouldReturnOnlyExpiredItems(): void |
||||||
|
{ |
||||||
|
$expiredItem = new Item( |
||||||
|
'expired-id', |
||||||
|
'Expired Item', |
||||||
|
new DateTimeImmutable('-1 day'), |
||||||
|
'http://example.com/expired-order', |
||||||
|
'user-id' |
||||||
|
); |
||||||
|
|
||||||
|
$validItem = new Item( |
||||||
|
'valid-id', |
||||||
|
'Valid Item', |
||||||
|
new DateTimeImmutable('+1 day'), |
||||||
|
'http://example.com/valid-order', |
||||||
|
'user-id' |
||||||
|
); |
||||||
|
|
||||||
|
$this->repository->save($expiredItem); |
||||||
|
$this->repository->save($validItem); |
||||||
|
|
||||||
|
$expiredItems = $this->repository->findExpiredItems(); |
||||||
|
|
||||||
|
$this->assertCount(1, $expiredItems); |
||||||
|
$this->assertSame($expiredItem->getId(), $expiredItems[0]->getId()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,212 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace AutoStore\Tests\Unit; |
||||||
|
|
||||||
|
use AutoStore\Application\Interfaces\IItemRepository; |
||||||
|
use AutoStore\Application\Interfaces\IOrderService; |
||||||
|
use AutoStore\Application\Interfaces\ITimeProvider; |
||||||
|
use AutoStore\Application\Commands\AddItem; |
||||||
|
use AutoStore\Domain\Entities\Item; |
||||||
|
use DateTimeImmutable; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class AddItemTest extends TestCase |
||||||
|
{ |
||||||
|
private AddItem $addItem; |
||||||
|
private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository; |
||||||
|
private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService; |
||||||
|
private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider; |
||||||
|
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->itemRepository = $this->createMock(IItemRepository::class); |
||||||
|
$this->orderService = $this->createMock(IOrderService::class); |
||||||
|
$this->timeProvider = $this->createMock(ITimeProvider::class); |
||||||
|
$this->logger = $this->createMock(LoggerInterface::class); |
||||||
|
|
||||||
|
$this->addItem = new AddItem( |
||||||
|
$this->itemRepository, |
||||||
|
$this->orderService, |
||||||
|
$this->timeProvider, |
||||||
|
$this->logger |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldSaveItemWhenNotExpired(): void |
||||||
|
{ |
||||||
|
$userId = 'test-user-id'; |
||||||
|
$itemName = 'Test Item'; |
||||||
|
$expirationDate = new DateTimeImmutable('+1 day'); |
||||||
|
$orderUrl = 'http://example.com/order'; |
||||||
|
|
||||||
|
$this->timeProvider->method('now') |
||||||
|
->willReturn(new DateTimeImmutable()); |
||||||
|
|
||||||
|
// Capture the saved item |
||||||
|
$savedItem = null; |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('save') |
||||||
|
->with($this->callback(function (Item $item) use ($itemName, $orderUrl, $userId, &$savedItem) { |
||||||
|
$savedItem = $item; |
||||||
|
return $item->getName() === $itemName && |
||||||
|
$item->getOrderUrl() === $orderUrl && |
||||||
|
$item->getUserId() === $userId && |
||||||
|
!$item->isOrdered(); |
||||||
|
})); |
||||||
|
|
||||||
|
$this->orderService->expects($this->never()) |
||||||
|
->method('orderItem'); |
||||||
|
|
||||||
|
// Mock findById to return the saved item |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findById') |
||||||
|
->willReturnCallback(function ($id) use (&$savedItem) { |
||||||
|
return $savedItem; |
||||||
|
}); |
||||||
|
|
||||||
|
$resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
|
||||||
|
// Retrieve the saved item to verify its properties |
||||||
|
$result = $this->itemRepository->findById($resultId); |
||||||
|
|
||||||
|
$this->assertSame($itemName, $result->getName()); |
||||||
|
// Compare DateTime objects without microseconds |
||||||
|
$this->assertEquals($expirationDate->format('Y-m-d H:i:s'), $result->getExpirationDate()->format('Y-m-d H:i:s')); |
||||||
|
$this->assertSame($orderUrl, $result->getOrderUrl()); |
||||||
|
$this->assertSame($userId, $result->getUserId()); |
||||||
|
$this->assertFalse($result->isOrdered()); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldPlaceOrderWhenItemIsExpired(): void |
||||||
|
{ |
||||||
|
$userId = 'test-user-id'; |
||||||
|
$itemName = 'Test Item'; |
||||||
|
$expirationDate = new DateTimeImmutable('-1 day'); |
||||||
|
$orderUrl = 'http://example.com/order'; |
||||||
|
|
||||||
|
$this->timeProvider->method('now') |
||||||
|
->willReturn(new DateTimeImmutable()); |
||||||
|
|
||||||
|
$savedItem = null; |
||||||
|
$orderedItem = null; |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('save') |
||||||
|
->with($this->callback(function (Item $item) use (&$savedItem) { |
||||||
|
$savedItem = $item; |
||||||
|
return true; |
||||||
|
})); |
||||||
|
|
||||||
|
$this->orderService->expects($this->once()) |
||||||
|
->method('orderItem') |
||||||
|
->with($this->callback(function (Item $item) use (&$orderedItem) { |
||||||
|
$orderedItem = $item; |
||||||
|
return true; |
||||||
|
})); |
||||||
|
|
||||||
|
// Mock findById to return the ordered item |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findById') |
||||||
|
->willReturnCallback(function ($id) use (&$orderedItem) { |
||||||
|
// Mark the item as ordered before returning it |
||||||
|
if ($orderedItem) { |
||||||
|
$orderedItem->markAsOrdered(); |
||||||
|
} |
||||||
|
return $orderedItem; |
||||||
|
}); |
||||||
|
|
||||||
|
$resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
|
||||||
|
// Retrieve the saved item to verify its properties |
||||||
|
$result = $this->itemRepository->findById($resultId); |
||||||
|
|
||||||
|
$this->assertTrue($result->isOrdered()); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldThrowExceptionWhenItemNameIsEmpty(): void |
||||||
|
{ |
||||||
|
$userId = 'test-user-id'; |
||||||
|
$itemName = ''; |
||||||
|
$expirationDate = new DateTimeImmutable('+1 day'); |
||||||
|
$orderUrl = 'http://example.com/order'; |
||||||
|
|
||||||
|
$this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); |
||||||
|
$this->expectExceptionMessage('Failed to add item: Item name cannot be empty'); |
||||||
|
|
||||||
|
$this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldThrowExceptionWhenOrderUrlIsEmpty(): void |
||||||
|
{ |
||||||
|
$userId = 'test-user-id'; |
||||||
|
$itemName = 'Test Item'; |
||||||
|
$expirationDate = new DateTimeImmutable('+1 day'); |
||||||
|
$orderUrl = ''; |
||||||
|
|
||||||
|
$this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); |
||||||
|
$this->expectExceptionMessage('Failed to add item: Order URL cannot be empty'); |
||||||
|
|
||||||
|
$this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldThrowExceptionWhenUserIdIsEmpty(): void |
||||||
|
{ |
||||||
|
$userId = ''; |
||||||
|
$itemName = 'Test Item'; |
||||||
|
$expirationDate = new DateTimeImmutable('+1 day'); |
||||||
|
$orderUrl = 'http://example.com/order'; |
||||||
|
|
||||||
|
$this->expectException(\AutoStore\Application\Exceptions\ApplicationException::class); |
||||||
|
$this->expectExceptionMessage('Failed to add item: User ID cannot be empty'); |
||||||
|
|
||||||
|
$this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExecuteShouldLogErrorWhenOrderServiceFails(): void |
||||||
|
{ |
||||||
|
$userId = 'test-user-id'; |
||||||
|
$itemName = 'Test Item'; |
||||||
|
$expirationDate = new DateTimeImmutable('-1 day'); |
||||||
|
$orderUrl = 'http://example.com/order'; |
||||||
|
|
||||||
|
$this->timeProvider->method('now') |
||||||
|
->willReturn(new DateTimeImmutable()); |
||||||
|
|
||||||
|
// Mock the repository to return a saved item |
||||||
|
$savedItem = null; |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('save') |
||||||
|
->with($this->callback(function (Item $item) use (&$savedItem) { |
||||||
|
$savedItem = $item; |
||||||
|
return true; |
||||||
|
})); |
||||||
|
|
||||||
|
// Mock the order service to throw an exception |
||||||
|
$this->orderService->expects($this->once()) |
||||||
|
->method('orderItem') |
||||||
|
->willThrowException(new \RuntimeException('Order service failed')); |
||||||
|
|
||||||
|
$this->logger->expects($this->once()) |
||||||
|
->method('error') |
||||||
|
->with($this->stringContains('Failed to place order for expired item')); |
||||||
|
|
||||||
|
// Mock findById to return the saved item |
||||||
|
$this->itemRepository->expects($this->once()) |
||||||
|
->method('findById') |
||||||
|
->willReturnCallback(function ($id) use (&$savedItem) { |
||||||
|
return $savedItem; |
||||||
|
}); |
||||||
|
|
||||||
|
// The handler should not throw an exception when the order service fails |
||||||
|
// It should log the error and continue |
||||||
|
$resultId = $this->addItem->execute($itemName, $expirationDate->format('Y-m-d H:i:s'), $orderUrl, $userId); |
||||||
|
|
||||||
|
// Retrieve the saved item to verify its properties |
||||||
|
$result = $this->itemRepository->findById($resultId); |
||||||
|
|
||||||
|
$this->assertFalse($result->isOrdered()); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php'; |
||||||
|
|
||||||
|
// Create test storage directory |
||||||
|
$GLOBALS['test_storage_path'] = __DIR__ . '/test-storage'; |
||||||
|
if (!is_dir($GLOBALS['test_storage_path'])) { |
||||||
|
mkdir($GLOBALS['test_storage_path'], 0755, true); |
||||||
|
} |
||||||
|
|
||||||
|
// Clean up any existing test data |
||||||
|
array_map('unlink', glob("{$GLOBALS['test_storage_path']}/*.json")); |
||||||
|
|
||||||
Loading…
Reference in new issue