Browse Source

Simplified specification classes for PHP8 implementation

php8-fixes2
chodak166 4 months ago
parent
commit
c374030abf
  1. 39
      nestjs/.devcontainer/Dockerfile
  2. 26
      nestjs/.devcontainer/devcontainer.json
  3. 29
      nestjs/.devcontainer/docker-compose.yml
  4. 28
      nestjs/docker/Dockerfile
  5. 17
      nestjs/docker/docker-compose.yml
  6. 8
      nestjs/nest-cli.json
  7. 9566
      nestjs/package-lock.json
  8. 70
      nestjs/package.json
  9. 12
      nestjs/src/app.controller.ts
  10. 10
      nestjs/src/app.module.ts
  11. 8
      nestjs/src/app.service.ts
  12. 8
      nestjs/src/main.ts
  13. 24
      nestjs/tsconfig.json
  14. 8
      php8/src/Application.php
  15. 9
      php8/src/Application/Commands/AddItem.php
  16. 22
      php8/src/Application/Commands/HandleExpiredItems.php
  17. 11
      php8/src/Application/Interfaces/IItemRepository.php
  18. 5
      php8/src/Application/Queries/GetItem.php
  19. 6
      php8/src/Application/Queries/ListItems.php
  20. 40
      php8/src/Application/Services/UserInitializationService.php
  21. 12
      php8/src/DiContainer.php
  22. 30
      php8/src/Domain/Entities/Item.php
  23. 23
      php8/src/Domain/Entities/User.php
  24. 19
      php8/src/Domain/Filters/FilterCondition.php
  25. 9
      php8/src/Domain/Filters/FilterExpr.php
  26. 22
      php8/src/Domain/Filters/FilterGroup.php
  27. 20
      php8/src/Domain/Filters/FilterOperator.php
  28. 236
      php8/src/Domain/Filters/FilterSpecification.php
  29. 25
      php8/src/Domain/Policies/ItemExpirationPolicy.php
  30. 24
      php8/src/Domain/Specifications/ItemExpirationSpec.php
  31. 266
      php8/src/Domain/Specifications/Specification.php
  32. 39
      php8/src/Infrastructure/Mappers/ItemMapper.php
  33. 36
      php8/src/Infrastructure/Mappers/UserMapper.php
  34. 71
      php8/src/Infrastructure/Repositories/FileItemRepository.php
  35. 33
      php8/src/Infrastructure/Repositories/FileUserRepository.php
  36. 9
      php8/src/WebApi/Controllers/StoreController.php
  37. 45
      php8/tests/Integration/FileItemRepositoryTest.php
  38. 30
      php8/tests/Unit/AddItemTest.php
  39. 106
      php8/tests/Unit/HandleExpiredItemsTest.php
  40. 38
      php8/tests/Unit/ItemExpirationSpecTest.php
  41. 688
      php8/tests/Unit/SpecificationTest.php

39
nestjs/.devcontainer/Dockerfile

@ -0,0 +1,39 @@
FROM node:24.0.1-alpine
WORKDIR /usr/src/app
# Install system dependencies
RUN apk add --no-cache \
git \
bash \
curl \
sudo
# Give sudo permissions to the developer user
RUN echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer && \
chmod 0440 /etc/sudoers.d/developer
# Configure user permissions
ARG USER_ID=1000
ARG GROUP_ID=1000
# Create a user with matching UID/GID
RUN if ! getent group $GROUP_ID > /dev/null 2>&1; then \
addgroup -g $GROUP_ID developer; \
else \
addgroup developer; \
fi && \
if ! getent passwd $USER_ID > /dev/null 2>&1; then \
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \
else \
adduser -D -G developer -s /bin/sh developer; \
fi
RUN chown -R $USER_ID:$GROUP_ID /usr/src/app
USER $USER_ID:$GROUP_ID
# Expose port 3000 for NestJS
EXPOSE 3000
CMD ["npm", "run", "start:dev"]

26
nestjs/.devcontainer/devcontainer.json

@ -0,0 +1,26 @@
{
"name": "NestJS dev container",
"dockerComposeFile": "./docker-compose.yml",
"service": "app",
"workspaceFolder": "/usr/src/app",
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"node.js.version": "24.0.1"
},
"extensions": [
"ms-vscode.vscode-typescript-next",
"ms-nodejs.vscode-node-debug2",
"ms-vscode.vscode-json",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"christian-kohler.npm-intellisense",
"christian-kohler.path-intellisense"
]
}
},
"forwardPorts": [3000],
"remoteUser": "developer",
"postCreateCommand": "sudo chown -R developer:developer /usr/src/app && npm install"
}

29
nestjs/.devcontainer/docker-compose.yml

@ -0,0 +1,29 @@
version: "3.9"
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
USER_ID: ${USER_ID:-1000}
GROUP_ID: ${GROUP_ID:-1000}
image: dev-nestjs-img
container_name: dev-nestjs
user: "developer"
volumes:
- ../:/usr/src/app:cached
- node_modules:/usr/src/app/node_modules
environment:
NODE_ENV: development
ports:
- "50080:3000"
networks:
- dev-network
command: sleep infinity
volumes:
node_modules:
networks:
dev-network:
driver: bridge

28
nestjs/docker/Dockerfile

@ -0,0 +1,28 @@
FROM node:24.0.1-alpine as builder
WORKDIR /app
COPY package*.json ./
COPY nest-cli.json ./
COPY tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
FROM node:24.0.1-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD [ "node", "dist/main" ]

17
nestjs/docker/docker-compose.yml

@ -0,0 +1,17 @@
version: "3.9"
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
image: nestjs-app-img
container_name: nestjs-app
ports:
- "50080:3000"
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge

8
nestjs/nest-cli.json

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

9566
nestjs/package-lock.json generated

File diff suppressed because it is too large Load Diff

70
nestjs/package.json

@ -0,0 +1,70 @@
{
"name": "autostore-nestjs",
"version": "0.0.1",
"description": "AutoStore implementation with NestJS",
"author": "",
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"rimraf": "^5.0.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

12
nestjs/src/app.controller.ts

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

10
nestjs/src/app.module.ts

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

8
nestjs/src/app.service.ts

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

8
nestjs/src/main.ts

@ -0,0 +1,8 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();

24
nestjs/tsconfig.json

@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"]
}
}
}

8
php8/src/Application.php

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace AutoStore; namespace AutoStore;
use AutoStore\Application\Services\UserInitializationService;
use AutoStore\WebApi\Router; use AutoStore\WebApi\Router;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
@ -17,6 +18,7 @@ class Application
$this->di = new DiContainer(); $this->di = new DiContainer();
$this->app = AppFactory::create(); $this->app = AppFactory::create();
$this->initializeDefaultUsers();
$this->setupMiddleware(); $this->setupMiddleware();
$this->setupRoutes(); $this->setupRoutes();
} }
@ -34,6 +36,12 @@ class Application
$router->setupRoutes(); $router->setupRoutes();
} }
private function initializeDefaultUsers(): void
{
$userInitializationService = $this->di->get(UserInitializationService::class);
$userInitializationService->createDefaultUsers();
}
public function run(): void public function run(): void
{ {
$this->app->run(); $this->app->run();

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

@ -8,7 +8,7 @@ use AutoStore\Application\Interfaces\IItemRepository;
use AutoStore\Application\Interfaces\IOrderService; use AutoStore\Application\Interfaces\IOrderService;
use AutoStore\Application\Interfaces\ITimeProvider; use AutoStore\Application\Interfaces\ITimeProvider;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Policies\ItemExpirationPolicy; use AutoStore\Domain\Specifications\ItemExpirationSpec;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -18,19 +18,20 @@ class AddItem
private IItemRepository $itemRepository; private IItemRepository $itemRepository;
private IOrderService $orderService; private IOrderService $orderService;
private ITimeProvider $timeProvider; private ITimeProvider $timeProvider;
private ItemExpirationPolicy $expirationPolicy; private ItemExpirationSpec $expirationSpec;
private LoggerInterface $logger; private LoggerInterface $logger;
public function __construct( public function __construct(
IItemRepository $itemRepository, IItemRepository $itemRepository,
IOrderService $orderService, IOrderService $orderService,
ITimeProvider $timeProvider, ITimeProvider $timeProvider,
ItemExpirationSpec $expirationSpec,
LoggerInterface $logger LoggerInterface $logger
) { ) {
$this->itemRepository = $itemRepository; $this->itemRepository = $itemRepository;
$this->orderService = $orderService; $this->orderService = $orderService;
$this->timeProvider = $timeProvider; $this->timeProvider = $timeProvider;
$this->expirationPolicy = new ItemExpirationPolicy(); $this->expirationSpec = $expirationSpec;
$this->logger = $logger; $this->logger = $logger;
} }
@ -46,7 +47,7 @@ class AddItem
); );
$currentTime = $this->timeProvider->now(); $currentTime = $this->timeProvider->now();
if ($this->expirationPolicy->isExpired($item, $currentTime)) { if ($this->expirationSpec->isExpired($item, $currentTime)) {
try { try {
$this->orderService->orderItem($item); $this->orderService->orderItem($item);
return null; return null;

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

@ -7,7 +7,7 @@ namespace AutoStore\Application\Commands;
use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Application\Interfaces\IItemRepository;
use AutoStore\Application\Interfaces\IOrderService; use AutoStore\Application\Interfaces\IOrderService;
use AutoStore\Application\Interfaces\ITimeProvider; use AutoStore\Application\Interfaces\ITimeProvider;
use AutoStore\Domain\Policies\ItemExpirationPolicy; use AutoStore\Domain\Specifications\ItemExpirationSpec;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -17,19 +17,20 @@ class HandleExpiredItems
private IItemRepository $itemRepository; private IItemRepository $itemRepository;
private IOrderService $orderService; private IOrderService $orderService;
private ITimeProvider $timeProvider; private ITimeProvider $timeProvider;
private ItemExpirationPolicy $expirationPolicy; private ItemExpirationSpec $expirationSpec;
private LoggerInterface $logger; private LoggerInterface $logger;
public function __construct( public function __construct(
IItemRepository $itemRepository, IItemRepository $itemRepository,
IOrderService $orderService, IOrderService $orderService,
ITimeProvider $timeProvider, ITimeProvider $timeProvider,
ItemExpirationSpec $expirationSpec,
LoggerInterface $logger LoggerInterface $logger
) { ) {
$this->itemRepository = $itemRepository; $this->itemRepository = $itemRepository;
$this->orderService = $orderService; $this->orderService = $orderService;
$this->timeProvider = $timeProvider; $this->timeProvider = $timeProvider;
$this->expirationPolicy = new ItemExpirationPolicy(); $this->expirationSpec = $expirationSpec;
$this->logger = $logger; $this->logger = $logger;
} }
@ -37,18 +38,15 @@ class HandleExpiredItems
{ {
try { try {
$currentTime = $this->timeProvider->now(); $currentTime = $this->timeProvider->now();
$specification = $this->expirationPolicy->getExpirationSpec($currentTime); $specification = $this->expirationSpec->getSpec($currentTime);
$items = $this->itemRepository->findWhere($specification); $items = $this->itemRepository->findWhere($specification);
foreach ($items as $item) { foreach ($items as $item) {
$isExpired = $this->expirationPolicy->isExpired($item, $currentTime); try {
if ($isExpired) { $this->orderService->orderItem($item);
try { $this->itemRepository->delete($item->getId());
$this->orderService->orderItem($item); } catch (\Exception $e) {
$this->itemRepository->delete($item->getId()); $this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage());
} catch (\Exception $e) {
$this->logger->error('Failed to place order for expired item ' . $item->getId() . ': ' . $e->getMessage());
}
} }
} }
} catch (DomainException $e) { } catch (DomainException $e) {

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

@ -5,8 +5,7 @@ declare(strict_types=1);
namespace AutoStore\Application\Interfaces; namespace AutoStore\Application\Interfaces;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Specifications\Specification;
use AutoStore\Domain\Filters\FilterSpecification;
interface IItemRepository interface IItemRepository
{ {
@ -14,11 +13,17 @@ interface IItemRepository
public function findById(string $id): ?Item; public function findById(string $id): ?Item;
/**
* @return Item[]
*/
public function findByUserId(string $userId): array; public function findByUserId(string $userId): array;
public function delete(string $id): void; public function delete(string $id): void;
public function findWhere(FilterSpecification $specification): array; /**
* @return Item[]
*/
public function findWhere(Specification $specification): array;
public function exists(string $id): bool; public function exists(string $id): bool;
} }

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

@ -7,6 +7,7 @@ namespace AutoStore\Application\Queries;
use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Application\Interfaces\IItemRepository;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Application\Exceptions\ItemNotFoundException; use AutoStore\Application\Exceptions\ItemNotFoundException;
use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
class GetItem class GetItem
@ -18,7 +19,7 @@ class GetItem
$this->itemRepository = $itemRepository; $this->itemRepository = $itemRepository;
} }
public function execute(string $itemId, string $userId): array public function execute(string $itemId, string $userId): Item
{ {
try { try {
$item = $this->itemRepository->findById($itemId); $item = $this->itemRepository->findById($itemId);
@ -31,7 +32,7 @@ class GetItem
throw new ApplicationException("User {$userId} is not authorized to access item {$itemId}"); throw new ApplicationException("User {$userId} is not authorized to access item {$itemId}");
} }
return $item->toArray(); return $item;
} catch (DomainException $e) { } catch (DomainException $e) {
throw new ApplicationException('Failed to get item: ' . $e->getMessage(), 0, $e); throw new ApplicationException('Failed to get item: ' . $e->getMessage(), 0, $e);
} }

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

@ -20,11 +20,7 @@ class ListItems
public function execute(string $userId): array public function execute(string $userId): array
{ {
try { try {
$items = $this->itemRepository->findByUserId($userId); return $this->itemRepository->findByUserId($userId);
return array_map(static function ($item) {
return $item->toArray();
}, $items);
} catch (DomainException $e) { } catch (DomainException $e) {
throw new ApplicationException('Failed to list items: ' . $e->getMessage(), 0, $e); throw new ApplicationException('Failed to list items: ' . $e->getMessage(), 0, $e);
} }

40
php8/src/Application/Services/UserInitializationService.php

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace AutoStore\Application\Services;
use AutoStore\Application\Interfaces\IUserRepository;
use AutoStore\Domain\Entities\User;
use Psr\Log\LoggerInterface;
class UserInitializationService
{
private IUserRepository $userRepository;
private LoggerInterface $logger;
public function __construct(IUserRepository $userRepository, LoggerInterface $logger)
{
$this->userRepository = $userRepository;
$this->logger = $logger;
}
public function createDefaultUsers(): void
{
$defaultUsers = [
['username' => 'admin', 'password' => 'admin'],
['username' => 'user', 'password' => 'user']
];
foreach ($defaultUsers as $userData) {
$user = new User(
uniqid('user_', true),
$userData['username'],
password_hash($userData['password'], PASSWORD_DEFAULT)
);
$this->userRepository->save($user);
}
$this->logger->info("Created default users");
}
}

12
php8/src/DiContainer.php

@ -16,6 +16,8 @@ use AutoStore\Application\Commands\HandleExpiredItems;
use AutoStore\Application\Commands\LoginUser; use AutoStore\Application\Commands\LoginUser;
use AutoStore\Application\Queries\GetItem; use AutoStore\Application\Queries\GetItem;
use AutoStore\Application\Queries\ListItems; use AutoStore\Application\Queries\ListItems;
use AutoStore\Application\Services\UserInitializationService;
use AutoStore\Domain\Specifications\ItemExpirationSpec;
use AutoStore\Infrastructure\Adapters\SystemTimeProvider; use AutoStore\Infrastructure\Adapters\SystemTimeProvider;
use AutoStore\Infrastructure\Auth\JwtAuthService; use AutoStore\Infrastructure\Auth\JwtAuthService;
use AutoStore\Infrastructure\Http\HttpOrderService; use AutoStore\Infrastructure\Http\HttpOrderService;
@ -134,6 +136,7 @@ class DiContainer
->addArgument(IItemRepository::class) ->addArgument(IItemRepository::class)
->addArgument(IOrderService::class) ->addArgument(IOrderService::class)
->addArgument(ITimeProvider::class) ->addArgument(ITimeProvider::class)
->addArgument(ItemExpirationSpec::class)
->addArgument(LoggerInterface::class) ->addArgument(LoggerInterface::class)
->setShared(true); ->setShared(true);
@ -153,6 +156,15 @@ class DiContainer
->addArgument(IItemRepository::class) ->addArgument(IItemRepository::class)
->addArgument(IOrderService::class) ->addArgument(IOrderService::class)
->addArgument(ITimeProvider::class) ->addArgument(ITimeProvider::class)
->addArgument(ItemExpirationSpec::class)
->addArgument(LoggerInterface::class)
->setShared(true);
// --- Services ---
$di->add(ItemExpirationSpec::class)->setShared(true);
$di->add(UserInitializationService::class)
->addArgument(IUserRepository::class)
->addArgument(LoggerInterface::class) ->addArgument(LoggerInterface::class)
->setShared(true); ->setShared(true);

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

@ -76,34 +76,4 @@ class Item
{ {
return $this->createdAt; return $this->createdAt;
} }
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'expirationDate' => $this->expirationDate->format('Y-m-d H:i:s'),
'orderUrl' => $this->orderUrl,
'userId' => $this->userId,
'createdAt' => $this->createdAt->format('Y-m-d H:i:s')
];
}
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']
);
return $item;
}
} }

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

@ -71,27 +71,4 @@ class User
$this->updatedAt = new DateTimeImmutable(); $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']
);
}
} }

19
php8/src/Domain/Filters/FilterCondition.php

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
class FilterCondition extends FilterExpr
{
public string $field;
public string $operator;
public $value;
public function __construct(string $field, string $operator, $value = null)
{
$this->field = $field;
$this->operator = $operator;
$this->value = $value;
}
}

9
php8/src/Domain/Filters/FilterExpr.php

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
abstract class FilterExpr
{
}

22
php8/src/Domain/Filters/FilterGroup.php

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
class FilterGroup extends FilterExpr
{
public string $type;
public array $children = [];
public function __construct(string $type = 'AND')
{
$this->type = strtoupper($type);
}
public function add(FilterExpr $expr): self
{
$this->children[] = $expr;
return $this;
}
}

20
php8/src/Domain/Filters/FilterOperator.php

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
class FilterOperator
{
public const EQUALS = 'equals';
public const NOT_EQUALS = 'not_equals';
public const GREATER_THAN = 'greater_than';
public const LESS_THAN = 'less_than';
public const GREATER_EQ = 'greater_eq';
public const LESS_EQ = 'less_eq';
public const IN = 'in';
public const NOT_IN = 'not_in';
public const LIKE = 'like';
public const IS_NULL = 'is_null';
public const IS_NOT_NULL = 'is_not_null';
}

236
php8/src/Domain/Filters/FilterSpecification.php

@ -1,236 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Filters;
class FilterSpecification
{
private FilterGroup $rootGroup;
private FilterGroup $currentGroup;
private array $groupStack = [];
private ?string $lastField = null;
public function __construct(?string $field = null)
{
$this->rootGroup = new FilterGroup('AND');
$this->currentGroup = $this->rootGroup;
if ($field !== null) {
$this->field($field);
}
}
public function field(string $field): self
{
$this->lastField = $field;
return $this;
}
public function equals($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::EQUALS, $value));
return $this;
}
public function notEquals($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_EQUALS, $value));
return $this;
}
public function greaterThan($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_THAN, $value));
return $this;
}
public function lessThan($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_THAN, $value));
return $this;
}
public function greaterEq($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::GREATER_EQ, $value));
return $this;
}
public function lessEq($value): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LESS_EQ, $value));
return $this;
}
public function in(array $values): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IN, $values));
return $this;
}
public function notIn(array $values): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::NOT_IN, $values));
return $this;
}
public function like(string $pattern): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $pattern));
return $this;
}
public function contains(string $needle): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, "%$needle%"));
return $this;
}
public function startsWith(string $prefix): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, $prefix . '%'));
return $this;
}
public function endsWith(string $suffix): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::LIKE, '%' . $suffix));
return $this;
}
public function isNull(): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NULL));
return $this;
}
public function isNotNull(): self
{
$this->currentGroup->add(new FilterCondition($this->consumeField(), FilterOperator::IS_NOT_NULL));
return $this;
}
public function or(?string $field = null): self
{
$newGroup = new FilterGroup('OR');
if (!empty($this->currentGroup->children)) {
$lastExpr = array_pop($this->currentGroup->children);
$newGroup->add($lastExpr);
}
$this->currentGroup->add($newGroup);
$this->groupStack[] = $this->currentGroup;
$this->currentGroup = $newGroup;
if ($field !== null) {
$this->field($field);
}
return $this;
}
public function and(?string $field = null): self
{
$newGroup = new FilterGroup('AND');
if (!empty($this->currentGroup->children)) {
$lastExpr = array_pop($this->currentGroup->children);
$newGroup->add($lastExpr);
}
$this->currentGroup->add($newGroup);
$this->groupStack[] = $this->currentGroup;
$this->currentGroup = $newGroup;
if ($field !== null) {
$this->field($field);
}
return $this;
}
public function close(): self
{
if (empty($this->groupStack)) {
throw new \LogicException('No group to close.');
}
$this->currentGroup = array_pop($this->groupStack);
return $this;
}
public function getExpression(): FilterExpr
{
return $this->rootGroup;
}
public function matches(array $data): bool
{
return $this->evaluateExpression($this->rootGroup, $data);
}
private function evaluateExpression(FilterExpr $expr, array $data): bool
{
if ($expr instanceof FilterCondition) {
return $this->evaluateCondition($expr, $data);
}
if ($expr instanceof FilterGroup) {
return $this->evaluateGroup($expr, $data);
}
throw new \LogicException('Unknown filter expression type.');
}
private function evaluateCondition(FilterCondition $condition, array $data): bool
{
if (!array_key_exists($condition->field, $data)) {
return false;
}
$value = $data[$condition->field];
switch ($condition->operator) {
case FilterOperator::EQUALS:
return $value == $condition->value;
case FilterOperator::NOT_EQUALS:
return $value != $condition->value;
case FilterOperator::GREATER_THAN:
return $value > $condition->value;
case FilterOperator::LESS_THAN:
return $value < $condition->value;
case FilterOperator::GREATER_EQ:
return $value >= $condition->value;
case FilterOperator::LESS_EQ:
return $value <= $condition->value;
case FilterOperator::IN:
return in_array($value, $condition->value);
case FilterOperator::NOT_IN:
return !in_array($value, $condition->value);
case FilterOperator::LIKE:
return str_contains((string)$value, str_replace('%', '', $condition->value));
case FilterOperator::IS_NULL:
return $value === null;
case FilterOperator::IS_NOT_NULL:
return $value !== null;
default:
throw new \LogicException("Unknown operator: {$condition->operator}");
}
}
private function evaluateGroup(FilterGroup $group, array $data): bool
{
if (empty($group->children)) {
return true;
}
$results = [];
foreach ($group->children as $child) {
$results[] = $this->evaluateExpression($child, $data);
}
return $group->type === 'AND' ? !in_array(false, $results) : in_array(true, $results);
}
private function consumeField(): string
{
if ($this->lastField === null) {
throw new \LogicException("No field specified.");
}
$field = $this->lastField;
$this->lastField = null;
return $field;
}
}

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

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Policies;
use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Filters\FilterSpecification;
use DateTimeImmutable;
class ItemExpirationPolicy
{
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool
{
return $item->getExpirationDate() <= $currentTime;
}
public function getExpirationSpec(DateTimeImmutable $currentTime): FilterSpecification
{
$specification = new FilterSpecification('expirationDate');
$specification->lessEq($currentTime->format('Y-m-d H:i:s'));
return $specification;
}
}

24
php8/src/Domain/Specifications/ItemExpirationSpec.php

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace AutoStore\Domain\Specifications;
use AutoStore\Domain\Entities\Item;
use DateTimeImmutable;
class ItemExpirationSpec
{
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool
{
// return $item->getExpirationDate() <= $currentTime;
return $this->getSpec($currentTime)->match($item);
}
public function getSpec(DateTimeImmutable $currentTime): Specification
{
return new Specification(
Spec::lte('expirationDate', $currentTime->format('Y-m-d H:i:s'))
);
}
}

266
php8/src/Domain/Specifications/Specification.php

@ -0,0 +1,266 @@
<?php
namespace AutoStore\Domain\Specifications;
class Specification
{
private array $spec;
public function __construct(array $spec)
{
$this->spec = $spec;
}
public function getSpec(): array
{
return $this->spec;
}
public function match($object): bool
{
return $this->evaluateSpec($this->spec, $object);
}
private function evaluateSpec(array $spec, $object): bool
{
// Handle logical groups
if (isset($spec[Spec::GROUP_AND])) {
foreach ($spec[Spec::GROUP_AND] as $subSpec) {
if (!$this->evaluateSpec($subSpec, $object)) {
return false;
}
}
return true;
}
if (isset($spec[Spec::GROUP_OR])) {
foreach ($spec[Spec::GROUP_OR] as $subSpec) {
if ($this->evaluateSpec($subSpec, $object)) {
return true;
}
}
return false;
}
if (isset($spec[Spec::GROUP_NOT])) {
return !$this->evaluateSpec($spec[Spec::GROUP_NOT], $object);
}
// Handle simple conditions [field, op, value]
[$field, $op, $value] = $spec;
// Check if field exists in the object
$getterMethod = 'get' . ucfirst($field);
if (method_exists($object, $getterMethod)) {
$fieldValue = $object->$getterMethod();
} elseif (property_exists($object, $field)) {
$fieldValue = $object->$field;
} elseif (isset($object->$field)) {
$fieldValue = $object->$field;
} else {
return false;
}
// Handle DateTimeImmutable comparison
if ($fieldValue instanceof \DateTimeInterface && is_string($value)) {
try {
$valueDate = new \DateTimeImmutable($value);
return $this->compareDateTime($fieldValue, $op, $valueDate);
} catch (\Exception $e) {
// If value is not a valid date string, fall back to regular comparison
}
} elseif (is_string($fieldValue) && $value instanceof \DateTimeInterface) {
try {
$fieldValueDate = new \DateTimeImmutable($fieldValue);
return $this->compareDateTime($fieldValueDate, $op, $value);
} catch (\Exception $e) {
// If fieldValue is not a valid date string, fall back to regular comparison
}
} elseif ($fieldValue instanceof \DateTimeInterface && $value instanceof \DateTimeInterface) {
return $this->compareDateTime($fieldValue, $op, $value);
}
// Evaluate based on operator
switch ($op) {
case Spec::OP_EQ:
return $fieldValue == $value;
case Spec::OP_NEQ:
return $fieldValue != $value;
case Spec::OP_GT:
return $fieldValue > $value;
case Spec::OP_GTE:
return $fieldValue >= $value;
case Spec::OP_LT:
return $fieldValue < $value;
case Spec::OP_LTE:
return $fieldValue <= $value;
case Spec::OP_IN:
return is_array($value) && in_array($fieldValue, $value);
case Spec::OP_NIN:
return is_array($value) && !in_array($fieldValue, $value);
default:
return false; // Unknown operator
}
}
private function compareDateTime(\DateTimeInterface $fieldValue, string $op, \DateTimeInterface $value): bool
{
switch ($op) {
case Spec::OP_EQ:
return $fieldValue->format('Y-m-d H:i:s') === $value->format('Y-m-d H:i:s');
case Spec::OP_NEQ:
return $fieldValue->format('Y-m-d H:i:s') !== $value->format('Y-m-d H:i:s');
case Spec::OP_GT:
return $fieldValue > $value;
case Spec::OP_GTE:
return $fieldValue >= $value;
case Spec::OP_LT:
return $fieldValue < $value;
case Spec::OP_LTE:
return $fieldValue <= $value;
default:
return false;
}
}
}
class Spec
{
// Logical group operators
public const GROUP_AND = 'AND';
public const GROUP_OR = 'OR';
public const GROUP_NOT = 'NOT';
// Comparison operators
public const OP_EQ = '=';
public const OP_NEQ = '!=';
public const OP_GT = '>';
public const OP_GTE = '>=';
public const OP_LT = '<';
public const OP_LTE = '<=';
public const OP_IN = 'IN';
public const OP_NIN = 'NOT IN';
// Logical group helpers
public static function and(array $conditions): array {
return [self::GROUP_AND => $conditions];
}
public static function or(array $conditions): array {
return [self::GROUP_OR => $conditions];
}
public static function not(array $condition): array {
return [self::GROUP_NOT => $condition];
}
// Condition helpers
public static function eq(string $field, $value): array {
return [$field, self::OP_EQ, $value];
}
public static function neq(string $field, $value): array {
return [$field, self::OP_NEQ, $value];
}
public static function gt(string $field, $value): array {
return [$field, self::OP_GT, $value];
}
public static function gte(string $field, $value): array {
return [$field, self::OP_GTE, $value];
}
public static function lt(string $field, $value): array {
return [$field, self::OP_LT, $value];
}
public static function lte(string $field, $value): array {
return [$field, self::OP_LTE, $value];
}
public static function in(string $field, array $values): array {
return [$field, self::OP_IN, $values];
}
public static function nin(string $field, array $values): array {
return [$field, self::OP_NIN, $values];
}
}
/*
// -----------------
// Example usage
$spec = Spec::and([
Spec::eq('status', 'active'),
Spec::or([
Spec::gt('score', 80),
Spec::in('role', ['admin', 'moderator'])
]),
Spec::not(Spec::eq('deleted', true))
]);
$params = [];
$sqlRenderer = new SqlRenderer();
$whereClause = $sqlRenderer->render($spec, $params);
echo "SQL: " . $whereClause . "\n";
echo "Params: " . json_encode($params, JSON_PRETTY_PRINT) . "\n";
// -----------------
// Example SQL renderer implementation in infrastructure layer
class SqlRenderer
{
private $operatorMap = [
Spec::OP_EQ => '=',
Spec::OP_NEQ => '<>',
Spec::OP_GT => '>',
Spec::OP_GTE => '>=',
Spec::OP_LT => '<',
Spec::OP_LTE => '<=',
Spec::OP_IN => 'IN',
Spec::OP_NIN => 'NOT IN',
];
public function render($spec, array &$params = []): string
{
// Logical groups
if (isset($spec[Spec::GROUP_AND])) {
$parts = array_map(fn($s) => $this->render($s, $params), $spec[Spec::GROUP_AND]);
return '(' . implode(' AND ', $parts) . ')';
}
if (isset($spec[Spec::GROUP_OR])) {
$parts = array_map(fn($s) => $this->render($s, $params), $spec[Spec::GROUP_OR]);
return '(' . implode(' OR ', $parts) . ')';
}
if (isset($spec[Spec::GROUP_NOT])) {
return 'NOT (' . $this->render($spec[Spec::GROUP_NOT], $params) . ')';
}
// It's a simple [field, op, value]
[$field, $op, $value] = $spec;
$renderOp = $this->operatorMap[$op] ?? $op;
if ($op === Spec::OP_IN || $op === Spec::OP_NIN) {
if (!$value) {
// Empty IN list: render a false condition
return ($op === Spec::OP_IN) ? '0=1' : '1=1';
}
$placeholders = array_fill(0, count($value), '?');
foreach ($value as $v) {
$params[] = $v;
}
return sprintf("%s %s (%s)", $field, $renderOp, implode(',', $placeholders));
}
$params[] = $value;
return sprintf("%s %s ?", $field, $renderOp);
}
}
*/

39
php8/src/Infrastructure/Mappers/ItemMapper.php

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace AutoStore\Infrastructure\Mappers;
use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException;
use DateTimeImmutable;
class ItemMapper
{
public static function toArray(Item $item): array
{
return [
'id' => $item->getId(),
'name' => $item->getName(),
'expirationDate' => $item->getExpirationDate()->format('Y-m-d H:i:s'),
'orderUrl' => $item->getOrderUrl(),
'userId' => $item->getUserId(),
'createdAt' => $item->getCreatedAt()->format('Y-m-d H:i:s')
];
}
public static function fromArray(array $data): Item
{
if (!isset($data['id'], $data['name'], $data['expirationDate'], $data['orderUrl'], $data['userId'])) {
throw new DomainException('Invalid item data');
}
return new Item(
$data['id'],
$data['name'],
new DateTimeImmutable($data['expirationDate']),
$data['orderUrl'],
$data['userId']
);
}
}

36
php8/src/Infrastructure/Mappers/UserMapper.php

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace AutoStore\Infrastructure\Mappers;
use AutoStore\Domain\Entities\User;
use AutoStore\Domain\Exceptions\DomainException;
use DateTimeImmutable;
class UserMapper
{
public static function toArray(User $user): array
{
return [
'id' => $user->getId(),
'username' => $user->getUsername(),
'passwordHash' => $user->getPasswordHash(),
'createdAt' => $user->getCreatedAt()->format('Y-m-d\TH:i:s.uP'),
'updatedAt' => $user->getUpdatedAt()?->format('Y-m-d\TH:i:s.uP'),
];
}
public static function fromArray(array $data): User
{
if (!isset($data['id'], $data['username'], $data['passwordHash'])) {
throw new DomainException('Invalid user data');
}
return new User(
$data['id'],
$data['username'],
$data['passwordHash']
);
}
}

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

@ -7,8 +7,9 @@ namespace AutoStore\Infrastructure\Repositories;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Application\Interfaces\IItemRepository; use AutoStore\Application\Interfaces\IItemRepository;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Specifications\Specification;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use AutoStore\Domain\Filters\FilterSpecification; use AutoStore\Infrastructure\Mappers\ItemMapper;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class FileItemRepository implements IItemRepository class FileItemRepository implements IItemRepository
@ -28,7 +29,7 @@ class FileItemRepository implements IItemRepository
public function save(Item $item): void public function save(Item $item): void
{ {
$this->items[$item->getId()] = $item->toArray(); $this->items[$item->getId()] = $item;
$this->persistItems(); $this->persistItems();
$this->logger->info("Item saved: {$item->getId()}"); $this->logger->info("Item saved: {$item->getId()}");
@ -36,29 +37,16 @@ class FileItemRepository implements IItemRepository
public function findById(string $id): ?Item public function findById(string $id): ?Item
{ {
if (!isset($this->items[$id])) { return $this->items[$id] ?? null;
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 public function findByUserId(string $userId): array
{ {
$result = []; $result = [];
foreach ($this->items as $itemData) { foreach ($this->items as $item) {
if ($itemData['userId'] === $userId) { if ($item->getUserId() === $userId) {
try { $result[] = $item;
$result[] = Item::fromArray($itemData);
} catch (DomainException $e) {
$this->logger->error("Failed to create item from data: " . $e->getMessage());
}
} }
} }
@ -67,17 +55,7 @@ class FileItemRepository implements IItemRepository
public function findAll(): array public function findAll(): array
{ {
$result = []; return array_values($this->items);
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;
} }
@ -93,17 +71,13 @@ class FileItemRepository implements IItemRepository
$this->logger->info("Item deleted: {$id}"); $this->logger->info("Item deleted: {$id}");
} }
public function findWhere(FilterSpecification $specification): array public function findWhere(Specification $specification): array
{ {
$result = []; $result = [];
foreach ($this->items as $itemData) { foreach ($this->items as $item) {
if ($specification->matches($itemData)) { if ($specification->match($item)) {
try { $result[] = $item;
$result[] = Item::fromArray($itemData);
} catch (DomainException $e) {
$this->logger->error("Failed to create item from data: " . $e->getMessage());
}
} }
} }
@ -144,14 +118,31 @@ class FileItemRepository implements IItemRepository
throw new ApplicationException("Failed to decode items JSON: " . json_last_error_msg()); throw new ApplicationException("Failed to decode items JSON: " . json_last_error_msg());
} }
$this->items = $data; // Convert arrays back to Item objects
$this->items = [];
foreach ($data as $itemData) {
try {
$item = ItemMapper::fromArray($itemData);
$this->items[$item->getId()] = $item;
} catch (DomainException $e) {
$this->logger->error("Failed to create item from data: " . $e->getMessage());
}
}
$this->logger->info("Loaded " . count($this->items) . " items from storage"); $this->logger->info("Loaded " . count($this->items) . " items from storage");
} }
private function persistItems(): void private function persistItems(): void
{ {
$filename = $this->storagePath . '/items.json'; $filename = $this->storagePath . '/items.json';
$content = json_encode($this->items, JSON_PRETTY_PRINT);
// Convert items to arrays for JSON storage
$itemsArray = [];
foreach ($this->items as $item) {
$itemsArray[$item->getId()] = ItemMapper::toArray($item);
}
$content = json_encode($itemsArray, JSON_PRETTY_PRINT);
if ($content === false) { if ($content === false) {
throw new ApplicationException("Failed to encode items to JSON"); throw new ApplicationException("Failed to encode items to JSON");

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

@ -8,6 +8,7 @@ use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Application\Interfaces\IUserRepository; use AutoStore\Application\Interfaces\IUserRepository;
use AutoStore\Domain\Entities\User; use AutoStore\Domain\Entities\User;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use AutoStore\Infrastructure\Mappers\UserMapper;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class FileUserRepository implements IUserRepository class FileUserRepository implements IUserRepository
@ -27,7 +28,7 @@ class FileUserRepository implements IUserRepository
public function save(User $user): void public function save(User $user): void
{ {
$this->users[$user->getId()] = $user->toArray(); $this->users[$user->getId()] = UserMapper::toArray($user);
$this->persistUsers(); $this->persistUsers();
$this->logger->info("User saved: {$user->getId()}"); $this->logger->info("User saved: {$user->getId()}");
@ -40,7 +41,7 @@ class FileUserRepository implements IUserRepository
} }
try { try {
return User::fromArray($this->users[$id]); return UserMapper::fromArray($this->users[$id]);
} catch (DomainException $e) { } catch (DomainException $e) {
$this->logger->error("Failed to create user from data: " . $e->getMessage()); $this->logger->error("Failed to create user from data: " . $e->getMessage());
return null; return null;
@ -52,7 +53,7 @@ class FileUserRepository implements IUserRepository
foreach ($this->users as $userData) { foreach ($this->users as $userData) {
if ($userData['username'] === $username) { if ($userData['username'] === $username) {
try { try {
return User::fromArray($userData); return UserMapper::fromArray($userData);
} catch (DomainException $e) { } catch (DomainException $e) {
$this->logger->error("Failed to create user from data: " . $e->getMessage()); $this->logger->error("Failed to create user from data: " . $e->getMessage());
return null; return null;
@ -82,7 +83,6 @@ class FileUserRepository implements IUserRepository
$filename = $this->storagePath . '/users.json'; $filename = $this->storagePath . '/users.json';
if (!file_exists($filename)) { if (!file_exists($filename)) {
$this->createDefaultUsers();
return; return;
} }
@ -117,29 +117,4 @@ class FileUserRepository implements IUserRepository
throw new ApplicationException("Failed to write users file: {$filename}"); 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");
}
} }

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

@ -9,6 +9,7 @@ use AutoStore\Application\Commands\AddItem;
use AutoStore\Application\Commands\DeleteItem; use AutoStore\Application\Commands\DeleteItem;
use AutoStore\Application\Queries\GetItem; use AutoStore\Application\Queries\GetItem;
use AutoStore\Application\Queries\ListItems; use AutoStore\Application\Queries\ListItems;
use AutoStore\Infrastructure\Mappers\ItemMapper;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
@ -66,7 +67,8 @@ class StoreController extends BaseController
return $this->createErrorResponse($response, 'Item ID is required', 400); return $this->createErrorResponse($response, 'Item ID is required', 400);
} }
$itemData = $this->getItem->execute($itemId, $userId); $item = $this->getItem->execute($itemId, $userId);
$itemData = ItemMapper::toArray($item);
return $this->createSuccessResponse($response, $itemData); return $this->createSuccessResponse($response, $itemData);
} catch (ApplicationException $e) { } catch (ApplicationException $e) {
@ -81,8 +83,11 @@ class StoreController extends BaseController
try { try {
$userId = $request->getAttribute('userId'); $userId = $request->getAttribute('userId');
$items = $this->listItems->execute($userId); $items = $this->listItems->execute($userId);
$itemsArray = array_map(static function ($item) {
return ItemMapper::toArray($item);
}, $items);
return $this->createSuccessResponse($response, $items); return $this->createSuccessResponse($response, $itemsArray);
} catch (ApplicationException $e) { } catch (ApplicationException $e) {
return $this->createErrorResponse($response, $e->getMessage(), 400); return $this->createErrorResponse($response, $e->getMessage(), 400);
} catch (\Exception $e) { } catch (\Exception $e) {

45
php8/tests/Integration/FileItemRepositoryTest.php

@ -6,7 +6,8 @@ namespace AutoStore\Tests\Infrastructure\Repositories;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Filters\FilterSpecification; use AutoStore\Domain\Specifications\Specification;
use AutoStore\Domain\Specifications\Spec;
use AutoStore\Infrastructure\Repositories\FileItemRepository; use AutoStore\Infrastructure\Repositories\FileItemRepository;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -34,6 +35,8 @@ class FileItemRepositoryTest extends TestCase
private const USER_ID_2 = 'user-id-2'; private const USER_ID_2 = 'user-id-2';
private const USER_ID = 'user-id'; private const USER_ID = 'user-id';
private const DATE_FORMAT = 'Y-m-d H:i:s'; private const DATE_FORMAT = 'Y-m-d H:i:s';
private const MOCKED_NOW = '2023-01-01 12:00:00';
private FileItemRepository $repository; private FileItemRepository $repository;
private string $testStoragePath; private string $testStoragePath;
@ -54,6 +57,8 @@ class FileItemRepositoryTest extends TestCase
{ {
// Clean up test files // Clean up test files
array_map('unlink', glob("$this->testStoragePath/*.json")); array_map('unlink', glob("$this->testStoragePath/*.json"));
// Remove storage dir only if empty
rmdir($this->testStoragePath);
} }
private function createTestItem1(): Item private function createTestItem1(): Item
@ -72,7 +77,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::ITEM_ID_2, self::ITEM_ID_2,
self::ITEM_NAME_2, self::ITEM_NAME_2,
new DateTimeImmutable('+2 days'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('+2 days'),
self::ORDER_URL_2, self::ORDER_URL_2,
self::USER_ID_2 self::USER_ID_2
); );
@ -83,7 +88,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::ITEM_ID_3, self::ITEM_ID_3,
self::ITEM_NAME_3, self::ITEM_NAME_3,
new DateTimeImmutable('+3 days'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('+3 days'),
self::ORDER_URL_3, self::ORDER_URL_3,
self::USER_ID_1 self::USER_ID_1
); );
@ -94,7 +99,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::EXPIRED_ID, self::EXPIRED_ID,
self::EXPIRED_NAME, self::EXPIRED_NAME,
new DateTimeImmutable('-1 day'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('-1 day'),
self::EXPIRED_ORDER_URL, self::EXPIRED_ORDER_URL,
self::USER_ID self::USER_ID
); );
@ -105,7 +110,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::VALID_ID, self::VALID_ID,
self::VALID_NAME, self::VALID_NAME,
new DateTimeImmutable('+1 day'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('+1 day'),
self::VALID_ORDER_URL, self::VALID_ORDER_URL,
self::USER_ID self::USER_ID
); );
@ -116,7 +121,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::ITEM_ID_1, self::ITEM_ID_1,
self::ITEM_NAME_1, self::ITEM_NAME_1,
new DateTimeImmutable('-1 day'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('-1 day'),
self::ORDER_URL_1, self::ORDER_URL_1,
self::USER_ID_1 self::USER_ID_1
); );
@ -127,7 +132,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::ITEM_ID_2, self::ITEM_ID_2,
self::ITEM_NAME_2, self::ITEM_NAME_2,
new DateTimeImmutable('+1 day'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('+1 day'),
self::ORDER_URL_2, self::ORDER_URL_2,
self::USER_ID_1 self::USER_ID_1
); );
@ -138,7 +143,7 @@ class FileItemRepositoryTest extends TestCase
return new Item( return new Item(
self::ITEM_ID_3, self::ITEM_ID_3,
self::ITEM_NAME_3, self::ITEM_NAME_3,
new DateTimeImmutable('-1 day'), (new DateTimeImmutable(self::MOCKED_NOW))->modify('-1 day'),
self::ORDER_URL_3, self::ORDER_URL_3,
self::USER_ID_2 self::USER_ID_2
); );
@ -264,9 +269,10 @@ class FileItemRepositoryTest extends TestCase
$this->repository->save($validItem); $this->repository->save($validItem);
// When // When
$now = new DateTimeImmutable(); $now = new DateTimeImmutable(self::MOCKED_NOW);
$specification = new FilterSpecification('expirationDate'); $specification = new Specification(
$specification->lessEq($now->format(self::DATE_FORMAT)); Spec::lte('expirationDate', $now->format(self::DATE_FORMAT))
);
$expiredItems = $this->repository->findWhere($specification); $expiredItems = $this->repository->findWhere($specification);
@ -287,8 +293,9 @@ class FileItemRepositoryTest extends TestCase
$this->repository->save($item3); $this->repository->save($item3);
// When // When
$specification = new FilterSpecification('userId'); $specification = new Specification(
$specification->equals(self::USER_ID_1); Spec::eq('userId', self::USER_ID_1)
);
$userItems = $this->repository->findWhere($specification); $userItems = $this->repository->findWhere($specification);
@ -310,11 +317,13 @@ class FileItemRepositoryTest extends TestCase
$this->repository->save($item3); $this->repository->save($item3);
// When // When
$now = new DateTimeImmutable(); $now = new DateTimeImmutable(self::MOCKED_NOW);
$specification = new FilterSpecification('userId'); $specification = new Specification(
$specification->equals(self::USER_ID_1) Spec::and([
->and('expirationDate') Spec::eq('userId', self::USER_ID_1),
->lessEq($now->format(self::DATE_FORMAT)); Spec::lte('expirationDate', $now->format(self::DATE_FORMAT))
])
);
$filteredItems = $this->repository->findWhere($specification); $filteredItems = $this->repository->findWhere($specification);

30
php8/tests/Unit/AddItemTest.php

@ -9,6 +9,7 @@ use AutoStore\Application\Interfaces\IOrderService;
use AutoStore\Application\Interfaces\ITimeProvider; use AutoStore\Application\Interfaces\ITimeProvider;
use AutoStore\Application\Commands\AddItem; use AutoStore\Application\Commands\AddItem;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Specifications\ItemExpirationSpec;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -18,7 +19,6 @@ class AddItemTest extends TestCase
private const MOCKED_NOW = '2023-01-01 12:00:00'; private const MOCKED_NOW = '2023-01-01 12:00:00';
private const NOT_EXPIRED_DATE = '2023-01-02 12:00:00'; private const NOT_EXPIRED_DATE = '2023-01-02 12:00:00';
private const EXPIRED_DATE = '2022-12-31 12:00:00'; private const EXPIRED_DATE = '2022-12-31 12:00:00';
private const ITEM_ID = 'test-item-id';
private const ITEM_NAME = 'Test Item'; private const ITEM_NAME = 'Test Item';
private const ORDER_URL = 'http://example.com/order'; private const ORDER_URL = 'http://example.com/order';
private const USER_ID = 'test-user-id'; private const USER_ID = 'test-user-id';
@ -28,6 +28,7 @@ class AddItemTest extends TestCase
private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository; private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository;
private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService; private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService;
private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider; private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider;
private ItemExpirationSpec&\PHPUnit\Framework\MockObject\MockObject $expirationPolicy;
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger;
private DateTimeImmutable $fixedCurrentTime; private DateTimeImmutable $fixedCurrentTime;
@ -36,6 +37,7 @@ class AddItemTest extends TestCase
$this->itemRepository = $this->createMock(IItemRepository::class); $this->itemRepository = $this->createMock(IItemRepository::class);
$this->orderService = $this->createMock(IOrderService::class); $this->orderService = $this->createMock(IOrderService::class);
$this->timeProvider = $this->createMock(ITimeProvider::class); $this->timeProvider = $this->createMock(ITimeProvider::class);
$this->expirationPolicy = $this->createMock(ItemExpirationSpec::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->fixedCurrentTime = new DateTimeImmutable(self::MOCKED_NOW); $this->fixedCurrentTime = new DateTimeImmutable(self::MOCKED_NOW);
@ -45,6 +47,7 @@ class AddItemTest extends TestCase
$this->itemRepository, $this->itemRepository,
$this->orderService, $this->orderService,
$this->timeProvider, $this->timeProvider,
$this->expirationPolicy,
$this->logger $this->logger
); );
} }
@ -92,6 +95,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('save') ->method('save')
->with($this->callback($this->getItemMatcher($testItem))); ->with($this->callback($this->getItemMatcher($testItem)));
@ -109,7 +113,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$this->orderService->expects($this->never())->method('orderItem'); $this->orderService->expects($this->never())->method('orderItem');
// When & Then // When & Then
@ -125,6 +129,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$this->orderService->expects($this->never())->method('orderItem'); $this->orderService->expects($this->never())->method('orderItem');
// When // When
@ -144,6 +149,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createExpiredTestItem(); $testItem = $this->createExpiredTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(true);
$orderedItem = null; $orderedItem = null;
$this->itemRepository->expects($this->never())->method('save'); $this->itemRepository->expects($this->never())->method('save');
@ -227,6 +233,9 @@ class AddItemTest extends TestCase
$expirationDate = new DateTimeImmutable(self::EXPIRED_DATE); $expirationDate = new DateTimeImmutable(self::EXPIRED_DATE);
$orderUrl = self::ORDER_URL; $orderUrl = self::ORDER_URL;
// Given: Item is expired
$this->expirationPolicy->method('isExpired')->willReturn(true);
// Mock the repository to return a saved item // Mock the repository to return a saved item
$this->itemRepository->expects($this->never())->method('save'); $this->itemRepository->expects($this->never())->method('save');
@ -246,6 +255,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createExpiredTestItem(); $testItem = $this->createExpiredTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(true);
$this->orderService->expects($this->once())->method('orderItem'); $this->orderService->expects($this->once())->method('orderItem');
@ -262,7 +272,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createExpiredTestItem(); $testItem = $this->createExpiredTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(true);
$this->itemRepository->expects($this->never())->method('save'); $this->itemRepository->expects($this->never())->method('save');
// When & Then // When & Then
@ -278,6 +288,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createExpiredTestItem(); $testItem = $this->createExpiredTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(true);
// When // When
$resultId = $this->addItem->execute( $resultId = $this->addItem->execute(
@ -295,7 +306,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT));
$this->expirationPolicy->method('isExpired')->willReturn(true);
$this->itemRepository->expects($this->never())->method('save'); $this->itemRepository->expects($this->never())->method('save');
// When & Then // When & Then
@ -311,7 +322,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT));
$this->expirationPolicy->method('isExpired')->willReturn(true);
$this->orderService->expects($this->once())->method('orderItem'); $this->orderService->expects($this->once())->method('orderItem');
// When & Then // When & Then
@ -327,6 +338,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT)); $testItem = $this->createItemWithExpiration($this->fixedCurrentTime->format(self::DATE_FORMAT));
$this->expirationPolicy->method('isExpired')->willReturn(true);
// When // When
$resultId = $this->addItem->execute( $resultId = $this->addItem->execute(
@ -344,7 +356,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$this->itemRepository->expects($this->once())->method('save'); $this->itemRepository->expects($this->once())->method('save');
// When // When
@ -363,6 +375,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$expectedException = new \RuntimeException('Repository error'); $expectedException = new \RuntimeException('Repository error');
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
@ -384,6 +397,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$expectedException = new \RuntimeException('Repository error'); $expectedException = new \RuntimeException('Repository error');
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
@ -407,6 +421,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createExpiredTestItem(); $testItem = $this->createExpiredTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(true);
$expectedException = new \RuntimeException('Order service error'); $expectedException = new \RuntimeException('Order service error');
$this->itemRepository->expects($this->never())->method('save'); $this->itemRepository->expects($this->never())->method('save');
@ -429,6 +444,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$expectedException = new \RuntimeException('Clock error'); $expectedException = new \RuntimeException('Clock error');
$this->timeProvider->method('now') $this->timeProvider->method('now')
@ -452,6 +468,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$expectedException = new \RuntimeException('Clock error'); $expectedException = new \RuntimeException('Clock error');
$this->timeProvider->method('now') $this->timeProvider->method('now')
@ -474,6 +491,7 @@ class AddItemTest extends TestCase
{ {
// Given // Given
$testItem = $this->createTestItem(); $testItem = $this->createTestItem();
$this->expirationPolicy->method('isExpired')->willReturn(false);
$expectedException = new \RuntimeException('Clock error'); $expectedException = new \RuntimeException('Clock error');
$this->timeProvider->method('now') $this->timeProvider->method('now')

106
php8/tests/Unit/HandleExpiredItemsTest.php

@ -11,6 +11,8 @@ use AutoStore\Application\Interfaces\ITimeProvider;
use AutoStore\Application\Exceptions\ApplicationException; use AutoStore\Application\Exceptions\ApplicationException;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Exceptions\DomainException; use AutoStore\Domain\Exceptions\DomainException;
use AutoStore\Domain\Specifications\ItemExpirationSpec;
use AutoStore\Domain\Specifications\Specification;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -33,6 +35,7 @@ class HandleExpiredItemsTest extends TestCase
private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository; private IItemRepository&\PHPUnit\Framework\MockObject\MockObject $itemRepository;
private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService; private IOrderService&\PHPUnit\Framework\MockObject\MockObject $orderService;
private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider; private ITimeProvider&\PHPUnit\Framework\MockObject\MockObject $timeProvider;
private ItemExpirationSpec&\PHPUnit\Framework\MockObject\MockObject $expirationSpec;
private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger; private LoggerInterface&\PHPUnit\Framework\MockObject\MockObject $logger;
private DateTimeImmutable $fixedCurrentTime; private DateTimeImmutable $fixedCurrentTime;
@ -41,6 +44,7 @@ class HandleExpiredItemsTest extends TestCase
$this->itemRepository = $this->createMock(IItemRepository::class); $this->itemRepository = $this->createMock(IItemRepository::class);
$this->orderService = $this->createMock(IOrderService::class); $this->orderService = $this->createMock(IOrderService::class);
$this->timeProvider = $this->createMock(ITimeProvider::class); $this->timeProvider = $this->createMock(ITimeProvider::class);
$this->expirationSpec = $this->createMock(ItemExpirationSpec::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->fixedCurrentTime = new DateTimeImmutable(self::MOCKED_NOW); $this->fixedCurrentTime = new DateTimeImmutable(self::MOCKED_NOW);
@ -50,6 +54,7 @@ class HandleExpiredItemsTest extends TestCase
$this->itemRepository, $this->itemRepository,
$this->orderService, $this->orderService,
$this->timeProvider, $this->timeProvider,
$this->expirationSpec,
$this->logger $this->logger
); );
} }
@ -79,6 +84,10 @@ class HandleExpiredItemsTest extends TestCase
public function testWhenNoExpiredItemsExistThenNoOrdersPlaced(): void public function testWhenNoExpiredItemsExistThenNoOrdersPlaced(): void
{ {
// Given // Given
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn(new Specification([]));
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn([]); ->willReturn([]);
@ -98,6 +107,13 @@ class HandleExpiredItemsTest extends TestCase
$expiredItem2 = $this->createExpiredItem2(); $expiredItem2 = $this->createExpiredItem2();
$expiredItems = [$expiredItem1, $expiredItem2]; $expiredItems = [$expiredItem1, $expiredItem2];
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($expiredItems); ->willReturn($expiredItems);
@ -107,7 +123,6 @@ class HandleExpiredItemsTest extends TestCase
$this->itemRepository->expects($this->exactly(2)) $this->itemRepository->expects($this->exactly(2))
->method('delete'); ->method('delete');
$this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('error');
// When & Then // When & Then
@ -121,6 +136,13 @@ class HandleExpiredItemsTest extends TestCase
$expiredItem2 = $this->createExpiredItem2(); $expiredItem2 = $this->createExpiredItem2();
$expiredItems = [$expiredItem1, $expiredItem2]; $expiredItems = [$expiredItem1, $expiredItem2];
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($expiredItems); ->willReturn($expiredItems);
@ -152,6 +174,13 @@ class HandleExpiredItemsTest extends TestCase
// Given // Given
$domainException = new DomainException('Repository error'); $domainException = new DomainException('Repository error');
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willThrowException($domainException); ->willThrowException($domainException);
@ -172,6 +201,13 @@ class HandleExpiredItemsTest extends TestCase
$expiredItem = $this->createExpiredItem1(); $expiredItem = $this->createExpiredItem1();
$expiredItems = [$expiredItem]; $expiredItems = [$expiredItem];
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($expiredItems); ->willReturn($expiredItems);
@ -201,6 +237,7 @@ class HandleExpiredItemsTest extends TestCase
$this->timeProvider->method('now') $this->timeProvider->method('now')
->willThrowException($domainException); ->willThrowException($domainException);
$this->expirationSpec->expects($this->never())->method('getSpec');
$this->itemRepository->expects($this->never())->method('findWhere'); $this->itemRepository->expects($this->never())->method('findWhere');
$this->orderService->expects($this->never())->method('orderItem'); $this->orderService->expects($this->never())->method('orderItem');
$this->itemRepository->expects($this->never())->method('delete'); $this->itemRepository->expects($this->never())->method('delete');
@ -212,24 +249,19 @@ class HandleExpiredItemsTest extends TestCase
$this->handleExpiredItems->execute(); $this->handleExpiredItems->execute();
} }
public function testWhenItemIsNotExpiredThenOrderNotPlacedAndItemNotDeleted(): void public function testWhenNoItemsFoundThenNoProcessingOccurs(): void
{ {
// Given // Given
// Create an item that's not actually expired despite being returned by the repository $spec = new Specification([
// This tests the double-check with isExpired() 'expirationDate', '<=', '2023-01-01 12:00:00'
$notExpiredItem = new Item( ]);
self::ITEM_ID_1, $this->expirationSpec->expects($this->once())
self::ITEM_NAME_1, ->method('getSpec')
new DateTimeImmutable(self::NOT_EXPIRED_DATE), ->willReturn($spec);
self::ORDER_URL_1,
self::USER_ID_1
);
$items = [$notExpiredItem];
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($items); ->willReturn([]);
$this->orderService->expects($this->never())->method('orderItem'); $this->orderService->expects($this->never())->method('orderItem');
$this->itemRepository->expects($this->never())->method('delete'); $this->itemRepository->expects($this->never())->method('delete');
@ -239,31 +271,29 @@ class HandleExpiredItemsTest extends TestCase
$this->handleExpiredItems->execute(); $this->handleExpiredItems->execute();
} }
public function testWhenMixedExpiredAndNonExpiredItemsThenOnlyExpiredItemsProcessed(): void public function testWhenExpiredItemsFoundThenTheyAreProcessed(): void
{ {
// Given // Given
$expiredItem = $this->createExpiredItem1(); $expiredItem1 = $this->createExpiredItem1();
$notExpiredItem = new Item( $expiredItem2 = $this->createExpiredItem2();
self::ITEM_ID_2, $expiredItems = [$expiredItem1, $expiredItem2];
self::ITEM_NAME_2,
new DateTimeImmutable(self::NOT_EXPIRED_DATE),
self::ORDER_URL_2,
self::USER_ID_2
);
$items = [$expiredItem, $notExpiredItem]; $spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($items); ->willReturn($expiredItems);
$this->orderService->expects($this->once()) $this->orderService->expects($this->exactly(2))
->method('orderItem') ->method('orderItem');
->with($expiredItem);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->exactly(2))
->method('delete') ->method('delete');
->with(self::ITEM_ID_1);
$this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('error');
@ -278,6 +308,13 @@ class HandleExpiredItemsTest extends TestCase
$expiredItem2 = $this->createExpiredItem2(); $expiredItem2 = $this->createExpiredItem2();
$expiredItems = [$expiredItem1, $expiredItem2]; $expiredItems = [$expiredItem1, $expiredItem2];
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn($expiredItems); ->willReturn($expiredItems);
@ -298,6 +335,13 @@ class HandleExpiredItemsTest extends TestCase
public function testWhenRepositoryFindReturnsEmptyArrayThenNoProcessingOccurs(): void public function testWhenRepositoryFindReturnsEmptyArrayThenNoProcessingOccurs(): void
{ {
// Given // Given
$spec = new Specification([
'expirationDate', '<=', '2023-01-01 12:00:00'
]);
$this->expirationSpec->expects($this->once())
->method('getSpec')
->willReturn($spec);
$this->itemRepository->expects($this->once()) $this->itemRepository->expects($this->once())
->method('findWhere') ->method('findWhere')
->willReturn([]); ->willReturn([]);

38
php8/tests/Unit/ItemExpirationPolicyTest.php → php8/tests/Unit/ItemExpirationSpecTest.php

@ -5,12 +5,13 @@ declare(strict_types=1);
namespace AutoStore\Tests\Unit\Domain\Policies; namespace AutoStore\Tests\Unit\Domain\Policies;
use AutoStore\Domain\Entities\Item; use AutoStore\Domain\Entities\Item;
use AutoStore\Domain\Filters\FilterSpecification; use AutoStore\Domain\Specifications\ItemExpirationSpec;
use AutoStore\Domain\Policies\ItemExpirationPolicy; use AutoStore\Domain\Specifications\Specification;
use AutoStore\Infrastructure\Mappers\ItemMapper;
use DateTimeImmutable; use DateTimeImmutable;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class ItemExpirationPolicyTest extends TestCase class ItemExpirationSpecTest extends TestCase
{ {
private const ITEM_ID = 'test-id'; private const ITEM_ID = 'test-id';
private const ITEM_ID_2 = 'test-id-2'; private const ITEM_ID_2 = 'test-id-2';
@ -24,11 +25,11 @@ class ItemExpirationPolicyTest extends TestCase
private const EXPIRED_TIME = '2023-01-01 11:00:00'; private const EXPIRED_TIME = '2023-01-01 11:00:00';
private const VALID_TIME = '2023-01-01 13:00:00'; private const VALID_TIME = '2023-01-01 13:00:00';
private ItemExpirationPolicy $policy; private ItemExpirationSpec $spec;
protected function setUp(): void protected function setUp(): void
{ {
$this->policy = new ItemExpirationPolicy(); $this->spec = new ItemExpirationSpec();
} }
private function createExpiredItem(): Item private function createExpiredItem(): Item
@ -82,7 +83,7 @@ class ItemExpirationPolicyTest extends TestCase
$currentTime = new DateTimeImmutable(); $currentTime = new DateTimeImmutable();
// When // When
$result = $this->policy->isExpired($item, $currentTime); $result = $this->spec->isExpired($item, $currentTime);
// Then // Then
$this->assertTrue($result); $this->assertTrue($result);
@ -95,24 +96,23 @@ class ItemExpirationPolicyTest extends TestCase
$currentTime = new DateTimeImmutable(); $currentTime = new DateTimeImmutable();
// When // When
$result = $this->policy->isExpired($item, $currentTime); $result = $this->spec->isExpired($item, $currentTime);
// Then // Then
$this->assertFalse($result); $this->assertFalse($result);
} }
public function testGetExpirationSpecShouldReturnCorrectFilter(): void public function testGetExpirationSpecShouldReturnMatchingSpecification(): void
{ {
// Given // Given
$currentTime = new DateTimeImmutable(self::CURRENT_TIME); $currentTime = new DateTimeImmutable(self::CURRENT_TIME);
// When // When
$specification = $this->policy->getExpirationSpec($currentTime); $specification = $this->spec->getSpec($currentTime);
// Then // Then
$this->assertInstanceOf(FilterSpecification::class, $specification); $this->assertInstanceOf(Specification::class, $specification);
// Test the filter with mock data using the same format as Item::toArray()
$expiredData = [ $expiredData = [
'id' => self::ITEM_ID, 'id' => self::ITEM_ID,
'name' => self::ITEM_NAME, 'name' => self::ITEM_NAME,
@ -129,8 +129,8 @@ class ItemExpirationPolicyTest extends TestCase
'userId' => self::USER_ID_2 'userId' => self::USER_ID_2
]; ];
$this->assertTrue($specification->matches($expiredData)); $this->assertTrue($specification->match((object)$expiredData));
$this->assertFalse($specification->matches($validData)); $this->assertFalse($specification->match((object)$validData));
} }
public function testGetExpirationSpecShouldWorkWithItemObjects(): void public function testGetExpirationSpecShouldWorkWithItemObjects(): void
@ -141,15 +141,15 @@ class ItemExpirationPolicyTest extends TestCase
$validItem = $this->createSecondItemWithExpiration(self::VALID_TIME); $validItem = $this->createSecondItemWithExpiration(self::VALID_TIME);
// When // When
$specification = $this->policy->getExpirationSpec($currentTime); $specification = $this->spec->getSpec($currentTime);
// Then // Then
// Test with array representation // Test with array representation using the mapper
$expiredArray = $expiredItem->toArray(); $expiredArray = ItemMapper::toArray($expiredItem);
$validArray = $validItem->toArray(); $validArray = ItemMapper::toArray($validItem);
// Check that the specification matches the array representation // Check that the specification matches the array representation
$this->assertTrue($specification->matches($expiredArray)); $this->assertTrue($specification->match((object)$expiredArray));
$this->assertFalse($specification->matches($validArray)); $this->assertFalse($specification->match((object)$validArray));
} }
} }

688
php8/tests/Unit/SpecificationTest.php

@ -0,0 +1,688 @@
<?php
declare(strict_types=1);
namespace AutoStore\Tests\Unit\Domain\Specifications;
use AutoStore\Domain\Specifications\Specification;
use AutoStore\Domain\Specifications\Spec;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use stdClass;
class SpecificationTest extends TestCase
{
// Test data constants
private const TEST_STRING = 'test-value';
private const TEST_INT = 42;
private const TEST_FLOAT = 3.14;
private const TEST_DATE = '2023-01-15 10:30:00';
private const TEST_DATE_2 = '2023-02-15 10:30:00';
private function createSimpleObject(array $data): stdClass
{
$object = new stdClass();
foreach ($data as $key => $value) {
$object->$key = $value;
}
return $object;
}
private function createComplexObject(array $data): TestObject
{
return new TestObject($data);
}
// EQ Operator Tests
public function testWhenUsingEqWithStringThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::eq('name', self::TEST_STRING));
$matchingObject = $this->createSimpleObject(['name' => self::TEST_STRING]);
$nonMatchingObject = $this->createSimpleObject(['name' => 'different']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingEqWithIntegerThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::eq('age', self::TEST_INT));
$matchingObject = $this->createSimpleObject(['age' => self::TEST_INT]);
$nonMatchingObject = $this->createSimpleObject(['age' => 100]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingEqWithFloatThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::eq('price', self::TEST_FLOAT));
$matchingObject = $this->createSimpleObject(['price' => self::TEST_FLOAT]);
$nonMatchingObject = $this->createSimpleObject(['price' => 1.0]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingEqWithComplexObjectGetterThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::eq('name', self::TEST_STRING));
$matchingObject = $this->createComplexObject(['name' => self::TEST_STRING]);
$nonMatchingObject = $this->createComplexObject(['name' => 'different']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
// NEQ Operator Tests
public function testWhenUsingNeqThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::neq('status', 'inactive'));
$matchingObject = $this->createSimpleObject(['status' => 'active']);
$nonMatchingObject = $this->createSimpleObject(['status' => 'inactive']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
// Comparison Operators Tests
public function testWhenUsingGtThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::gt('score', 80));
$matchingObject = $this->createSimpleObject(['score' => 90]);
$nonMatchingObject = $this->createSimpleObject(['score' => 70]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingGteThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::gte('score', 80));
$matchingObject1 = $this->createSimpleObject(['score' => 80]);
$matchingObject2 = $this->createSimpleObject(['score' => 90]);
$nonMatchingObject = $this->createSimpleObject(['score' => 70]);
// When
$matchResult1 = $spec->match($matchingObject1);
$matchResult2 = $spec->match($matchingObject2);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult1);
$this->assertTrue($matchResult2);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingLtThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::lt('score', 80));
$matchingObject = $this->createSimpleObject(['score' => 70]);
$nonMatchingObject = $this->createSimpleObject(['score' => 90]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingLteThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::lte('score', 80));
$matchingObject1 = $this->createSimpleObject(['score' => 80]);
$matchingObject2 = $this->createSimpleObject(['score' => 70]);
$nonMatchingObject = $this->createSimpleObject(['score' => 90]);
// When
$matchResult1 = $spec->match($matchingObject1);
$matchResult2 = $spec->match($matchingObject2);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult1);
$this->assertTrue($matchResult2);
$this->assertFalse($noMatchResult);
}
// IN Operator Tests
public function testWhenUsingInThenMatchesCorrectly(): void
{
// Given
$validValues = ['admin', 'moderator', 'editor'];
$spec = new Specification(Spec::in('role', $validValues));
$matchingObject = $this->createSimpleObject(['role' => 'admin']);
$nonMatchingObject = $this->createSimpleObject(['role' => 'user']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingInWithEmptyArrayThenNeverMatches(): void
{
// Given
$spec = new Specification(Spec::in('role', []));
$testObject = $this->createSimpleObject(['role' => 'admin']);
// When
$result = $spec->match($testObject);
// Then
$this->assertFalse($result);
}
// NOT IN Operator Tests
public function testWhenUsingNotInThenMatchesCorrectly(): void
{
// Given
$invalidValues = ['banned', 'suspended'];
$spec = new Specification(Spec::nin('status', $invalidValues));
$matchingObject = $this->createSimpleObject(['status' => 'active']);
$nonMatchingObject = $this->createSimpleObject(['status' => 'banned']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
// DateTime Tests
public function testWhenUsingDateTimeComparisonWithStringsThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::lt('createdAt', self::TEST_DATE_2));
$matchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE]);
$nonMatchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE_2]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingDateTimeComparisonWithDateTimeObjectsThenMatchesCorrectly(): void
{
// Given
$testDate = new DateTimeImmutable(self::TEST_DATE);
$spec = new Specification(Spec::lte('expirationDate', $testDate));
$matchingObject = $this->createComplexObject(['expirationDate' => new DateTimeImmutable(self::TEST_DATE)]);
$nonMatchingObject = $this->createComplexObject(['expirationDate' => new DateTimeImmutable(self::TEST_DATE_2)]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingDateTimeComparisonWithMixedTypesThenMatchesCorrectly(): void
{
// Given
$testDate = new DateTimeImmutable(self::TEST_DATE);
$spec = new Specification(Spec::eq('createdAt', $testDate));
$matchingObject = $this->createSimpleObject(['createdAt' => self::TEST_DATE]);
// When
$result = $spec->match($matchingObject);
// Then
$this->assertTrue($result);
}
// AND Group Tests
public function testWhenUsingAndGroupThenMatchesOnlyWhenAllConditionsMet(): void
{
// Given
$spec = new Specification(Spec::and([
Spec::eq('status', 'active'),
Spec::gte('score', 80),
Spec::in('role', ['admin', 'moderator'])
]));
$matchingObject = $this->createSimpleObject([
'status' => 'active',
'score' => 85,
'role' => 'admin'
]);
$nonMatchingObject1 = $this->createSimpleObject([
'status' => 'inactive',
'score' => 85,
'role' => 'admin'
]);
$nonMatchingObject2 = $this->createSimpleObject([
'status' => 'active',
'score' => 70,
'role' => 'admin'
]);
$nonMatchingObject3 = $this->createSimpleObject([
'status' => 'active',
'score' => 85,
'role' => 'user'
]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult1 = $spec->match($nonMatchingObject1);
$noMatchResult2 = $spec->match($nonMatchingObject2);
$noMatchResult3 = $spec->match($nonMatchingObject3);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult1);
$this->assertFalse($noMatchResult2);
$this->assertFalse($noMatchResult3);
}
public function testWhenUsingEmptyAndGroupThenAlwaysMatches(): void
{
// Given
$spec = new Specification(Spec::and([]));
$testObject = $this->createSimpleObject(['any' => 'value']);
// When
$result = $spec->match($testObject);
// Then
$this->assertTrue($result);
}
// OR Group Tests
public function testWhenUsingOrGroupThenMatchesWhenAnyConditionMet(): void
{
// Given
$spec = new Specification(Spec::or([
Spec::eq('role', 'admin'),
Spec::gte('score', 90),
Spec::in('department', ['IT', 'HR'])
]));
$matchingObject1 = $this->createSimpleObject([
'role' => 'admin',
'score' => 70,
'department' => 'Finance'
]);
$matchingObject2 = $this->createSimpleObject([
'role' => 'user',
'score' => 95,
'department' => 'Finance'
]);
$matchingObject3 = $this->createSimpleObject([
'role' => 'user',
'score' => 70,
'department' => 'IT'
]);
$nonMatchingObject = $this->createSimpleObject([
'role' => 'user',
'score' => 70,
'department' => 'Finance'
]);
// When
$matchResult1 = $spec->match($matchingObject1);
$matchResult2 = $spec->match($matchingObject2);
$matchResult3 = $spec->match($matchingObject3);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult1);
$this->assertTrue($matchResult2);
$this->assertTrue($matchResult3);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingEmptyOrGroupThenNeverMatches(): void
{
// Given
$spec = new Specification(Spec::or([]));
$testObject = $this->createSimpleObject(['any' => 'value']);
// When
$result = $spec->match($testObject);
// Then
$this->assertFalse($result);
}
// NOT Group Tests
public function testWhenUsingNotGroupThenInvertsCondition(): void
{
// Given
$spec = new Specification(Spec::not(Spec::eq('status', 'banned')));
$matchingObject = $this->createSimpleObject(['status' => 'active']);
$nonMatchingObject = $this->createSimpleObject(['status' => 'banned']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
// Complex Nested Groups Tests
public function testWhenUsingNestedAndOrGroupsThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::and([
Spec::eq('status', 'active'),
Spec::or([
Spec::gte('score', 80),
Spec::in('role', ['admin', 'moderator'])
])
]));
$matchingObject1 = $this->createSimpleObject([
'status' => 'active',
'score' => 85,
'role' => 'user'
]);
$matchingObject2 = $this->createSimpleObject([
'status' => 'active',
'score' => 70,
'role' => 'admin'
]);
$nonMatchingObject = $this->createSimpleObject([
'status' => 'inactive',
'score' => 85,
'role' => 'user'
]);
// When
$matchResult1 = $spec->match($matchingObject1);
$matchResult2 = $spec->match($matchingObject2);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult1);
$this->assertTrue($matchResult2);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingTripleNestedGroupsThenMatchesCorrectly(): void
{
// Given
$spec = new Specification(Spec::and([
Spec::eq('active', true),
Spec::not(Spec::or([
Spec::eq('role', 'banned'),
Spec::eq('status', 'suspended')
]))
]));
$matchingObject = $this->createSimpleObject([
'active' => true,
'role' => 'user',
'status' => 'active'
]);
$nonMatchingObject1 = $this->createSimpleObject([
'active' => false,
'role' => 'user',
'status' => 'active'
]);
$nonMatchingObject2 = $this->createSimpleObject([
'active' => true,
'role' => 'banned',
'status' => 'active'
]);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult1 = $spec->match($nonMatchingObject1);
$noMatchResult2 = $spec->match($nonMatchingObject2);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult1);
$this->assertFalse($noMatchResult2);
}
// Edge Case Tests
public function testWhenFieldDoesNotExistThenReturnsFalse(): void
{
// Given
$spec = new Specification(Spec::eq('nonExistentField', 'value'));
$testObject = $this->createSimpleObject(['existingField' => 'value']);
// When
$result = $spec->match($testObject);
// Then
$this->assertFalse($result);
}
public function testWhenFieldIsNullThenComparesCorrectly(): void
{
// Given
$spec = new Specification(Spec::eq('optionalField', null));
$matchingObject = $this->createSimpleObject(['optionalField' => null]);
$nonMatchingObject = $this->createSimpleObject(['optionalField' => 'value']);
// When
$matchResult = $spec->match($matchingObject);
$noMatchResult = $spec->match($nonMatchingObject);
// Then
$this->assertTrue($matchResult);
$this->assertFalse($noMatchResult);
}
public function testWhenUsingInvalidOperatorThenReturnsFalse(): void
{
// Given
$spec = new Specification(['field', 'INVALID_OP', 'value']);
$testObject = $this->createSimpleObject(['field' => 'value']);
// When
$result = $spec->match($testObject);
// Then
$this->assertFalse($result);
}
public function testWhenUsingInvalidDateStringThenFallsBackToRegularComparison(): void
{
// Given
$spec = new Specification(Spec::eq('dateField', 'invalid-date'));
$testObject = $this->createSimpleObject(['dateField' => 'invalid-date']);
// When
$result = $spec->match($testObject);
// Then
$this->assertTrue($result);
}
// Spec Helper Method Tests
public function testWhenUsingSpecHelpersThenCreatesCorrectSpecificationArray(): void
{
// Given
$eqSpec = Spec::eq('field', 'value');
$neqSpec = Spec::neq('field', 'value');
$gtSpec = Spec::gt('field', 10);
$gteSpec = Spec::gte('field', 10);
$ltSpec = Spec::lt('field', 10);
$lteSpec = Spec::lte('field', 10);
$inSpec = Spec::in('field', ['a', 'b']);
$ninSpec = Spec::nin('field', ['a', 'b']);
// When & Then
$this->assertEquals(['field', '=', 'value'], $eqSpec);
$this->assertEquals(['field', '!=', 'value'], $neqSpec);
$this->assertEquals(['field', '>', 10], $gtSpec);
$this->assertEquals(['field', '>=', 10], $gteSpec);
$this->assertEquals(['field', '<', 10], $ltSpec);
$this->assertEquals(['field', '<=', 10], $lteSpec);
$this->assertEquals(['field', 'IN', ['a', 'b']], $inSpec);
$this->assertEquals(['field', 'NOT IN', ['a', 'b']], $ninSpec);
}
public function testWhenUsingLogicalGroupHelpersThenCreatesCorrectSpecificationArray(): void
{
// Given
$andSpec = Spec::and([Spec::eq('a', 1), Spec::eq('b', 2)]);
$orSpec = Spec::or([Spec::eq('a', 1), Spec::eq('b', 2)]);
$notSpec = Spec::not(Spec::eq('a', 1));
// When & Then
$this->assertEquals(['AND' => [['a', '=', 1], ['b', '=', 2]]], $andSpec);
$this->assertEquals(['OR' => [['a', '=', 1], ['b', '=', 2]]], $orSpec);
$this->assertEquals(['NOT' => ['a', '=', 1]], $notSpec);
}
public function testGetSpecReturnsOriginalSpecification(): void
{
// Given
$originalSpec = Spec::eq('field', 'value');
$specification = new Specification($originalSpec);
// When
$retrievedSpec = $specification->getSpec();
// Then
$this->assertEquals($originalSpec, $retrievedSpec);
}
}
// Test helper class with getters for complex object testing
class TestObject
{
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function __get($name)
{
return $this->data[$name] ?? null;
}
public function __isset($name)
{
return isset($this->data[$name]);
}
public function getName()
{
return $this->data['name'] ?? null;
}
public function getExpirationDate()
{
return $this->data['expirationDate'] ?? null;
}
public function getCreatedAt()
{
return $this->data['createdAt'] ?? null;
}
public function getScore()
{
return $this->data['score'] ?? null;
}
public function getStatus()
{
return $this->data['status'] ?? null;
}
public function getRole()
{
return $this->data['role'] ?? null;
}
public function getAge()
{
return $this->data['age'] ?? null;
}
public function getPrice()
{
return $this->data['price'] ?? null;
}
public function getDepartment()
{
return $this->data['department'] ?? null;
}
public function getActive()
{
return $this->data['active'] ?? null;
}
public function getOptionalField()
{
return $this->data['optionalField'] ?? null;
}
public function getDateField()
{
return $this->data['dateField'] ?? null;
}
}
Loading…
Cancel
Save