Browse Source

Added initial PHP8 implementation

php8
chodak166 4 months ago
parent
commit
1f142849ee
  1. 1
      .gitignore
  2. 2
      README.md
  3. 2
      cpp17/app/src/App.cpp
  4. 2
      cpp17/docker/docker-compose.yml
  5. 2
      cpp17/lib/include/autostore/AutoStore.h
  6. 2
      cpp17/lib/src/infrastructure/http/HttpServer.h
  7. 48
      openapi.yaml
  8. 70
      php8/.devcontainer/Dockerfile
  9. 28
      php8/.devcontainer/default.dev.conf
  10. 29
      php8/.devcontainer/devcontainer.json
  11. 42
      php8/.devcontainer/docker-compose.yml
  12. 7
      php8/.devcontainer/nginx.Dockerfile
  13. 19
      php8/.dockerignore
  14. 29
      php8/cli/scheduler-tasks/cleanup-logs.3600.php
  15. 23
      php8/cli/scheduler-tasks/handle-expired-items.60.php
  16. 131
      php8/cli/scheduler.php
  17. 41
      php8/composer.json
  18. 3646
      php8/composer.lock
  19. 4
      php8/configuration.json
  20. 35
      php8/docker/Dockerfile
  21. 29
      php8/docker/default.conf
  22. 46
      php8/docker/docker-compose.yml
  23. 7
      php8/docker/nginx.Dockerfile
  24. 9
      php8/index.php
  25. 14
      php8/phpunit.xml
  26. 61
      php8/src/Application.php
  27. 67
      php8/src/Application/Commands/AddItem.php
  28. 39
      php8/src/Application/Commands/DeleteItem.php
  29. 59
      php8/src/Application/Commands/HandleExpiredItems.php
  30. 33
      php8/src/Application/Commands/LoginUser.php
  31. 11
      php8/src/Application/Exceptions/ApplicationException.php
  32. 13
      php8/src/Application/Exceptions/ItemNotFoundException.php
  33. 13
      php8/src/Application/Exceptions/OrderException.php
  34. 13
      php8/src/Application/Exceptions/UserNotFoundException.php
  35. 14
      php8/src/Application/Interfaces/IAuthService.php
  36. 23
      php8/src/Application/Interfaces/IItemRepository.php
  37. 16
      php8/src/Application/Interfaces/IOrderService.php
  38. 12
      php8/src/Application/Interfaces/ITimeProvider.php
  39. 18
      php8/src/Application/Interfaces/IUserRepository.php
  40. 39
      php8/src/Application/Queries/GetItem.php
  41. 32
      php8/src/Application/Queries/ListItems.php
  42. 180
      php8/src/DiContainer.php
  43. 194
      php8/src/Domain/Entities/Item.php
  44. 97
      php8/src/Domain/Entities/User.php
  45. 11
      php8/src/Domain/Exceptions/DomainException.php
  46. 13
      php8/src/Domain/Exceptions/InvalidItemDataException.php
  47. 13
      php8/src/Domain/Exceptions/InvalidUserDataException.php
  48. 13
      php8/src/Domain/Exceptions/ItemExpiredException.php
  49. 15
      php8/src/Domain/Exceptions/ItemNotFoundException.php
  50. 13
      php8/src/Domain/Exceptions/UnauthorizedAccessException.php
  51. 23
      php8/src/Domain/Policies/ItemExpirationPolicy.php
  52. 16
      php8/src/Infrastructure/Adapters/SystemTimeProvider.php
  53. 88
      php8/src/Infrastructure/Auth/JwtAuthService.php
  54. 63
      php8/src/Infrastructure/Http/HttpOrderService.php
  55. 65
      php8/src/Infrastructure/Http/JwtMiddleware.php
  56. 172
      php8/src/Infrastructure/Repositories/FileItemRepository.php
  57. 145
      php8/src/Infrastructure/Repositories/FileUserRepository.php
  58. 43
      php8/src/WebApi/Controllers/AuthController.php
  59. 59
      php8/src/WebApi/Controllers/BaseController.php
  60. 112
      php8/src/WebApi/Controllers/StoreController.php
  61. 207
      php8/tests/Integration/FileItemRepositoryTest.php
  62. 212
      php8/tests/Unit/AddItemTest.php
  63. 15
      php8/tests/bootstrap.php
  64. 2
      testing/tavern/export.sh
  65. 3
      testing/tavern/tavern-run-all.sh
  66. 3
      testing/tavern/tavern-run-single.sh

1
.gitignore vendored

@ -240,3 +240,4 @@ gradle-app.setting
hs_err_pid* hs_err_pid*
replay_pid* replay_pid*
reference-*

2
README.md

@ -128,4 +128,4 @@ Here's a summary of example API endpoints:
| `/items/{id}` | PUT | Update item details | | `/items/{id}` | PUT | Update item details |
| `/items/{id}` | DELETE | Delete item | | `/items/{id}` | DELETE | Delete item |
Suggested base URL is `http://localhost:8080/api/v1/`. Suggested base URL is `http://localhost:50080/api/v1/`.

2
cpp17/app/src/App.cpp

@ -26,7 +26,7 @@ App::App(int argc, char** argv)
AutoStore::Config{ AutoStore::Config{
.dataPath = os::getApplicationDirectory() + "/data", .dataPath = os::getApplicationDirectory() + "/data",
.host = "0.0.0.0", .host = "0.0.0.0",
.port = 8080, .port = 50080,
}, },
logger); logger);

2
cpp17/docker/docker-compose.yml

@ -7,4 +7,4 @@ services:
image: autostore-build-cpp-vcpkg-img image: autostore-build-cpp-vcpkg-img
container_name: autostore-build-cpp-vcpkg container_name: autostore-build-cpp-vcpkg
ports: ports:
- 8080:8080 - 50080:50080

2
cpp17/lib/include/autostore/AutoStore.h

@ -38,7 +38,7 @@ public:
{ {
std::string dataPath; std::string dataPath;
std::string host{"0.0.0.0"}; std::string host{"0.0.0.0"};
uint16_t port{8080}; uint16_t port{50080};
}; };
AutoStore(Config config, ILoggerPtr logger); AutoStore(Config config, ILoggerPtr logger);

2
cpp17/lib/src/infrastructure/http/HttpServer.h

@ -15,7 +15,7 @@ public:
HttpServer(ILoggerPtr logger, application::IAuthService& authService); HttpServer(ILoggerPtr logger, application::IAuthService& authService);
~HttpServer(); ~HttpServer();
bool start(int port = 8080, std::string_view host = "0.0.0.0"); bool start(int port = 50080, std::string_view host = "0.0.0.0");
void stop(); void stop();
bool isRunning() const; bool isRunning() const;

48
openapi.yaml

@ -153,54 +153,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/JsendError' $ref: '#/components/schemas/JsendError'
put:
summary: Update an item
description: Updates an existing item
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Item ID
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ItemInput'
responses:
'200':
description: Item updated successfully
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/JsendSuccess'
- type: object
properties:
data:
$ref: '#/components/schemas/Item'
'400':
description: Invalid input
content:
application/json:
schema:
$ref: '#/components/schemas/JsendError'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/JsendError'
'404':
description: Item not found
content:
application/json:
schema:
$ref: '#/components/schemas/JsendError'
delete: delete:
summary: Delete an item summary: Delete an item
description: Deletes an existing item description: Deletes an existing item

70
php8/.devcontainer/Dockerfile

@ -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"]

28
php8/.devcontainer/default.dev.conf

@ -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;
}

29
php8/.devcontainer/devcontainer.json

@ -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"
}

42
php8/.devcontainer/docker-compose.yml

@ -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

7
php8/.devcontainer/nginx.Dockerfile

@ -0,0 +1,7 @@
FROM nginx:alpine
COPY default.dev.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

19
php8/.dockerignore

@ -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

29
php8/cli/scheduler-tasks/cleanup-logs.3600.php

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

23
php8/cli/scheduler-tasks/handle-expired-items.60.php

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

131
php8/cli/scheduler.php

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

41
php8/composer.json

@ -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"
}
}

3646
php8/composer.lock generated

File diff suppressed because it is too large Load Diff

4
php8/configuration.json

@ -0,0 +1,4 @@
{
"storage_directory": "./storage",
"jwt_secret": "secret-key"
}

35
php8/docker/Dockerfile

@ -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"]

29
php8/docker/default.conf

@ -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;
}

46
php8/docker/docker-compose.yml

@ -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

7
php8/docker/nginx.Dockerfile

@ -0,0 +1,7 @@
FROM nginx:alpine
COPY docker/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

9
php8/index.php

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use AutoStore\Application;
require_once __DIR__ . '/vendor/autoload.php';
$app = new Application();
$app->run();

14
php8/phpunit.xml

@ -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>

61
php8/src/Application.php

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

67
php8/src/Application/Commands/AddItem.php

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

39
php8/src/Application/Commands/DeleteItem.php

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

59
php8/src/Application/Commands/HandleExpiredItems.php

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

33
php8/src/Application/Commands/LoginUser.php

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

11
php8/src/Application/Exceptions/ApplicationException.php

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace AutoStore\Application\Exceptions;
use Exception;
class ApplicationException extends Exception
{
}

13
php8/src/Application/Exceptions/ItemNotFoundException.php

@ -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");
}
}

13
php8/src/Application/Exceptions/OrderException.php

@ -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}");
}
}

13
php8/src/Application/Exceptions/UserNotFoundException.php

@ -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");
}
}

14
php8/src/Application/Interfaces/IAuthService.php

@ -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;
}

23
php8/src/Application/Interfaces/IItemRepository.php

@ -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;
}

16
php8/src/Application/Interfaces/IOrderService.php

@ -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;
}

12
php8/src/Application/Interfaces/ITimeProvider.php

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace AutoStore\Application\Interfaces;
use DateTimeImmutable;
interface ITimeProvider
{
public function now(): DateTimeImmutable;
}

18
php8/src/Application/Interfaces/IUserRepository.php

@ -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;
}

39
php8/src/Application/Queries/GetItem.php

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

32
php8/src/Application/Queries/ListItems.php

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

180
php8/src/DiContainer.php

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

194
php8/src/Domain/Entities/Item.php

@ -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;
}
}

97
php8/src/Domain/Entities/User.php

@ -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']
);
}
}

11
php8/src/Domain/Exceptions/DomainException.php

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Exceptions;
use Exception;
class DomainException extends Exception
{
}

13
php8/src/Domain/Exceptions/InvalidItemDataException.php

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

13
php8/src/Domain/Exceptions/InvalidUserDataException.php

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

13
php8/src/Domain/Exceptions/ItemExpiredException.php

@ -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");
}
}

15
php8/src/Domain/Exceptions/ItemNotFoundException.php

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

13
php8/src/Domain/Exceptions/UnauthorizedAccessException.php

@ -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}'");
}
}

23
php8/src/Domain/Policies/ItemExpirationPolicy.php

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

16
php8/src/Infrastructure/Adapters/SystemTimeProvider.php

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

88
php8/src/Infrastructure/Auth/JwtAuthService.php

@ -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;
}
}
}

63
php8/src/Infrastructure/Http/HttpOrderService.php

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

65
php8/src/Infrastructure/Http/JwtMiddleware.php

@ -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
]));
}
}

172
php8/src/Infrastructure/Repositories/FileItemRepository.php

@ -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}");
}
}
}

145
php8/src/Infrastructure/Repositories/FileUserRepository.php

@ -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");
}
}

43
php8/src/WebApi/Controllers/AuthController.php

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

59
php8/src/WebApi/Controllers/BaseController.php

@ -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;
}
}

112
php8/src/WebApi/Controllers/StoreController.php

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

207
php8/tests/Integration/FileItemRepositoryTest.php

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

212
php8/tests/Unit/AddItemTest.php

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

15
php8/tests/bootstrap.php

@ -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"));

2
testing/tavern/export.sh

@ -8,7 +8,7 @@ else
fi fi
export TEST_SERVER_ADDRESS="127.0.0.1" export TEST_SERVER_ADDRESS="127.0.0.1"
export TEST_SERVER_PORT="8080" export TEST_SERVER_PORT="50080"
export TEST_API_BASE="api/v1" export TEST_API_BASE="api/v1"
export TEST_ORDER_URL="http://192.168.20.2:8888/" export TEST_ORDER_URL="http://192.168.20.2:8888/"

3
testing/tavern/tavern-run-all.sh

@ -1,5 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd "$SCRIPT_DIR"
if [ -z "$TEST_SERVER_ADDRESS" ]; then if [ -z "$TEST_SERVER_ADDRESS" ]; then
source export.sh source export.sh
fi fi

3
testing/tavern/tavern-run-single.sh

@ -1,5 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
cd "$SCRIPT_DIR"
if [ -z "$1" ]; then if [ -z "$1" ]; then
echo "Usage: $0 <test plan>" echo "Usage: $0 <test plan>"
exit 1 exit 1

Loading…
Cancel
Save