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