Commit 60e1e70f authored by Mahmoud Aglan's avatar Mahmoud Aglan

Upgradbility update

parent 60bea953
<?php
declare(strict_types=1);
namespace App\Core;
use App\Core\Exceptions\ValidationException;
abstract class ApiController extends Controller
{
protected function successResponse($data, int $status = 200, array $meta = []): Response
{
return $this->json([
'success' => true,
'data' => $data,
'meta' => $meta ?: null,
'errors' => null,
], $status);
}
protected function errorResponse(string $message, int $status = 400, array $details = []): Response
{
return $this->json([
'success' => false,
'data' => null,
'meta' => null,
'errors' => [
'message' => $message,
'details' => $details ?: null,
],
], $status);
}
protected function paginatedResponse(array $items, int $total, int $page, int $perPage): Response
{
return $this->json([
'success' => true,
'data' => $items,
'meta' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'last_page' => (int) ceil($total / max($perPage, 1)),
],
'errors' => null,
]);
}
protected function validatedJson(array $rules): array
{
$request = $this->getRequest();
$input = $request->jsonBody();
$validator = new Validator();
$result = $validator->validate($input, $rules);
if ($result->fails()) {
throw new ValidationException($result->errors());
}
return $result->validated();
}
protected function getRequest(): Request
{
return $this->request;
}
protected function queryParam(string $key, $default = null)
{
return $this->request->get($key, $default);
}
protected function queryInt(string $key, int $default = 0): int
{
return (int) ($this->request->get($key) ?? $default);
}
}
...@@ -48,9 +48,11 @@ final class App ...@@ -48,9 +48,11 @@ final class App
$this->initSession(); $this->initSession();
$this->loadDbConfigOverrides(); $this->loadDbConfigOverrides();
$this->loadModuleBootstraps(); $this->loadModuleBootstraps();
$this->registerServiceProviders();
$this->router = new Router(); $this->router = new Router();
$this->loadModuleRoutes(); $this->loadModuleRoutes();
$this->loadModuleApiRoutes();
$this->booted = true; $this->booted = true;
return $this; return $this;
...@@ -169,6 +171,27 @@ final class App ...@@ -169,6 +171,27 @@ final class App
Logger::warning('Failed to load bootstrap: ' . $file . ' — ' . $e->getMessage()); Logger::warning('Failed to load bootstrap: ' . $file . ' — ' . $e->getMessage());
} }
} }
$this->loadModuleProviders($modulesDir);
}
private function loadModuleProviders(string $modulesDir): void
{
$pattern = $modulesDir . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'provides.php';
$providerFiles = glob($pattern);
if ($providerFiles === false) {
return;
}
sort($providerFiles);
foreach ($providerFiles as $file) {
try {
require_once $file;
} catch (\Throwable $e) {
Logger::warning('Failed to load provides: ' . $file . ' — ' . $e->getMessage());
}
}
} }
// Load all module route files // Load all module route files
...@@ -209,6 +232,59 @@ final class App ...@@ -209,6 +232,59 @@ final class App
} }
} }
private function loadModuleApiRoutes(): void
{
$modulesDir = $this->basePath . '/app/Modules';
if (!is_dir($modulesDir)) {
return;
}
$pattern = $modulesDir . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'ApiRoutes.php';
$routeFiles = glob($pattern);
if ($routeFiles === false) {
return;
}
sort($routeFiles);
foreach ($routeFiles as $file) {
try {
$routes = require $file;
if (is_array($routes)) {
foreach ($routes as $route) {
if (is_array($route) && count($route) >= 3) {
$this->router->addRoute(
$route[0],
$route[1],
$route[2],
$route[3] ?? [],
$route[4] ?? null
);
}
}
}
} catch (\Throwable $e) {
Logger::warning('Failed to load API routes: ' . $file . ' — ' . $e->getMessage());
}
}
}
private function registerServiceProviders(): void
{
$providers = [
\App\Core\Providers\CoreServiceProvider::class,
];
foreach ($providers as $providerClass) {
$provider = new $providerClass();
$provider->register($this);
}
foreach ($providers as $providerClass) {
(new $providerClass())->boot($this);
}
}
public function db(): Database public function db(): Database
{ {
return $this->db; return $this->db;
......
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface EventDispatcherInterface
{
public function dispatch(string $event, array $data = []): array;
public function listen(string $event, callable $handler, int $priority = 0): void;
public function hasListeners(string $event): bool;
}
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface JournalServiceInterface
{
public function createEntry(array $header, array $lines, bool $autoPost = false): array;
public function postEntry(int $entryId): array;
public function reverseEntry(int $entryId, string $reason, ?string $reversalDate = null): array;
}
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface PaymentProcessorInterface
{
public function processPayment(array $data): array;
public function voidPayment(int $paymentId, string $reason): array;
public function hasPaid(int $memberId, string $paymentType, ?string $relatedEntityType = null, ?int $relatedEntityId = null): bool;
public function totalPaid(int $memberId, ?string $paymentType = null): string;
public function generateReceiptNumber(): string;
public function getPaymentTypeLabel(string $type): string;
}
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface RuleEngineInterface
{
public function get(string $ruleCode, ?int $branchId = null, ?string $date = null): mixed;
public function getValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed;
public function getAll(string $category, ?int $branchId = null): array;
public function getEffective(string $ruleCode, string $entityType, int $entityId, ?int $branchId = null): mixed;
public function require(string $ruleCode, ?int $branchId = null): array;
public function requireValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed;
}
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface StockManagerInterface
{
public function moveStock(array $data): int;
public function getAvailableQuantity(int $itemId, int $warehouseId): string;
public function getTotalStock(int $itemId): string;
public function getMovements(int $itemId, ?int $warehouseId = null, int $limit = 50): array;
}
<?php
declare(strict_types=1);
namespace App\Core\Contracts;
interface WorkflowEngineInterface
{
public function createInstance(string $workflowCode, string $entityType, int $entityId, ?array $stateData = null): object;
public function transition(int $instanceId, string $transitionName, ?string $notes = null, string $triggerType = 'manual'): bool;
public function getAvailableTransitions(int $instanceId): array;
public function transitionByEntity(string $entityType, int $entityId, string $transitionName, ?string $notes = null): bool;
public function getCurrentState(string $entityType, int $entityId, ?string $workflowCode = null): ?string;
public function getHistory(int $instanceId): array;
}
<?php
declare(strict_types=1);
namespace App\Core\DTO;
final class JournalEntryRequest
{
public function __construct(
public readonly string $entryDate,
public readonly string $descriptionAr,
public readonly array $lines,
public readonly ?string $descriptionEn = null,
public readonly ?string $referenceType = null,
public readonly ?int $referenceId = null,
public readonly ?string $referenceNumber = null,
public readonly ?string $sourceModule = null,
public readonly bool $isAutoGenerated = false,
public readonly bool $autoPost = false,
) {}
public function toHeaderArray(): array
{
return array_filter([
'entry_date' => $this->entryDate,
'description_ar' => $this->descriptionAr,
'description_en' => $this->descriptionEn,
'reference_type' => $this->referenceType,
'reference_id' => $this->referenceId,
'reference_number' => $this->referenceNumber,
'source_module' => $this->sourceModule,
'is_auto_generated' => $this->isAutoGenerated ? 1 : 0,
], fn($v) => $v !== null);
}
public function toLines(): array
{
return $this->lines;
}
}
<?php
declare(strict_types=1);
namespace App\Core\DTO;
final class PaymentRequest
{
public function __construct(
public readonly ?int $memberId,
public readonly string $amount,
public readonly string $paymentType,
public readonly string $paymentMethod = 'cash',
public readonly ?string $description = null,
public readonly ?string $checkNumber = null,
public readonly ?string $checkBank = null,
public readonly ?string $checkDate = null,
public readonly ?string $visaReference = null,
public readonly ?string $transferReference = null,
public readonly ?string $relatedEntityType = null,
public readonly ?int $relatedEntityId = null,
public readonly ?string $notes = null,
public readonly ?string $guestName = null,
) {}
public function toArray(): array
{
return array_filter([
'member_id' => $this->memberId,
'amount' => $this->amount,
'payment_type' => $this->paymentType,
'payment_method' => $this->paymentMethod,
'description' => $this->description,
'check_number' => $this->checkNumber,
'check_bank' => $this->checkBank,
'check_date' => $this->checkDate,
'visa_reference' => $this->visaReference,
'transfer_reference' => $this->transferReference,
'related_entity_type' => $this->relatedEntityType,
'related_entity_id' => $this->relatedEntityId,
'notes' => $this->notes,
'guest_name' => $this->guestName,
], fn($v) => $v !== null);
}
}
<?php
declare(strict_types=1);
namespace App\Core\DTO;
final class PaymentResult
{
private function __construct(
public readonly bool $success,
public readonly ?int $paymentId,
public readonly ?int $receiptId,
public readonly ?string $receiptNumber,
public readonly ?string $amount,
public readonly ?string $error,
) {}
public static function ok(int $paymentId, int $receiptId, string $receiptNumber, string $amount): self
{
return new self(true, $paymentId, $receiptId, $receiptNumber, $amount, null);
}
public static function fail(string $error): self
{
return new self(false, null, null, null, null, $error);
}
public static function fromArray(array $data): self
{
if ($data['success'] ?? false) {
return self::ok(
(int) $data['payment_id'],
(int) $data['receipt_id'],
(string) $data['receipt_number'],
(string) ($data['amount'] ?? '0.00'),
);
}
return self::fail((string) ($data['error'] ?? 'Unknown error'));
}
public function toArray(): array
{
if ($this->success) {
return [
'success' => true,
'payment_id' => $this->paymentId,
'receipt_id' => $this->receiptId,
'receipt_number' => $this->receiptNumber,
'amount' => $this->amount,
];
}
return [
'success' => false,
'error' => $this->error,
];
}
}
<?php
declare(strict_types=1);
namespace App\Core\DTO;
final class StockMovementRequest
{
public function __construct(
public readonly int $itemId,
public readonly int $warehouseId,
public readonly string $movementType,
public readonly string $direction,
public readonly string $quantity,
public readonly ?string $unitCost = null,
public readonly ?int $batchId = null,
public readonly ?string $referenceType = null,
public readonly ?int $referenceId = null,
public readonly ?string $notes = null,
) {}
public function toArray(): array
{
return array_filter([
'item_id' => $this->itemId,
'warehouse_id' => $this->warehouseId,
'movement_type' => $this->movementType,
'direction' => $this->direction,
'quantity' => $this->quantity,
'unit_cost' => $this->unitCost,
'batch_id' => $this->batchId,
'reference_type' => $this->referenceType,
'reference_id' => $this->referenceId,
'notes' => $this->notes,
], fn($v) => $v !== null);
}
}
<?php
declare(strict_types=1);
namespace App\Core\DTO;
final class StockMovementResult
{
private function __construct(
public readonly bool $success,
public readonly ?int $movementId,
public readonly ?string $error,
) {}
public static function ok(int $movementId): self
{
return new self(true, $movementId, null);
}
public static function fail(string $error): self
{
return new self(false, null, $error);
}
}
...@@ -3,6 +3,8 @@ declare(strict_types=1); ...@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace App\Core; namespace App\Core;
use App\Core\Events\Event;
final class EventBus final class EventBus
{ {
private static array $listeners = []; private static array $listeners = [];
...@@ -42,6 +44,11 @@ final class EventBus ...@@ -42,6 +44,11 @@ final class EventBus
return $results; return $results;
} }
public static function dispatchEvent(Event $event): array
{
return self::dispatch($event::eventName(), $event->toArray());
}
public static function hasListeners(string $event): bool public static function hasListeners(string $event): bool
{ {
return !empty(self::$listeners[$event]); return !empty(self::$listeners[$event]);
......
<?php
declare(strict_types=1);
namespace App\Core\Events;
abstract class Event
{
abstract public static function eventName(): string;
abstract public function toArray(): array;
abstract public static function from(array $data): static;
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class FineImposed extends Event
{
public function __construct(
public readonly int $fineId,
public readonly int $memberId,
public readonly string $penaltyType,
public readonly string $amount,
) {}
public static function eventName(): string
{
return 'fine.imposed';
}
public function toArray(): array
{
return [
'fine_id' => $this->fineId,
'member_id' => $this->memberId,
'penalty_type' => $this->penaltyType,
'amount' => $this->amount,
];
}
public static function from(array $data): static
{
return new self(
fineId: (int) ($data['fine_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
penaltyType: (string) ($data['penalty_type'] ?? ''),
amount: (string) ($data['amount'] ?? '0.00'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class FinePaid extends Event
{
public function __construct(
public readonly int $fineId,
public readonly int $memberId,
public readonly string $amount,
) {}
public static function eventName(): string
{
return 'fine.paid';
}
public function toArray(): array
{
return [
'fine_id' => $this->fineId,
'member_id' => $this->memberId,
'amount' => $this->amount,
];
}
public static function from(array $data): static
{
return new self(
fineId: (int) ($data['fine_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
amount: (string) ($data['amount'] ?? '0.00'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class InstallmentPaid extends Event
{
public function __construct(
public readonly int $planId,
public readonly int $scheduleId,
public readonly int $memberId,
) {}
public static function eventName(): string
{
return 'installment.paid';
}
public function toArray(): array
{
return [
'plan_id' => $this->planId,
'schedule_id' => $this->scheduleId,
'member_id' => $this->memberId,
];
}
public static function from(array $data): static
{
return new self(
planId: (int) ($data['plan_id'] ?? 0),
scheduleId: (int) ($data['schedule_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class InstallmentPlanCreated extends Event
{
public function __construct(
public readonly int $planId,
public readonly int $memberId,
public readonly string $totalAmount,
) {}
public static function eventName(): string
{
return 'installment.plan_created';
}
public function toArray(): array
{
return [
'plan_id' => $this->planId,
'member_id' => $this->memberId,
'total_amount' => $this->totalAmount,
];
}
public static function from(array $data): static
{
return new self(
planId: (int) ($data['plan_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
totalAmount: (string) ($data['total_amount'] ?? '0.00'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class MemberActivated extends Event
{
public function __construct(
public readonly int $memberId,
public readonly ?string $membershipNumber,
) {}
public static function eventName(): string
{
return 'member.activated';
}
public function toArray(): array
{
return [
'member_id' => $this->memberId,
'membership_number' => $this->membershipNumber,
];
}
public static function from(array $data): static
{
return new self(
memberId: (int) ($data['member_id'] ?? 0),
membershipNumber: $data['membership_number'] ?? null,
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class MemberCreated extends Event
{
public function __construct(
public readonly int $memberId,
public readonly string $fullNameAr,
public readonly string $status,
public readonly int $branchId,
) {}
public static function eventName(): string
{
return 'member.created';
}
public function toArray(): array
{
return [
'id' => $this->memberId,
'member_id' => $this->memberId,
'full_name_ar' => $this->fullNameAr,
'status' => $this->status,
'branch_id' => $this->branchId,
];
}
public static function from(array $data): static
{
return new self(
memberId: (int) ($data['member_id'] ?? $data['id'] ?? 0),
fullNameAr: (string) ($data['full_name_ar'] ?? ''),
status: (string) ($data['status'] ?? 'potential'),
branchId: (int) ($data['branch_id'] ?? 0),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class PaymentCompleted extends Event
{
public function __construct(
public readonly int $paymentId,
public readonly int $receiptId,
public readonly string $receiptNumber,
public readonly int $memberId,
public readonly string $paymentType,
public readonly string $amount,
public readonly string $method,
) {}
public static function eventName(): string
{
return 'payment.completed';
}
public function toArray(): array
{
return [
'payment_id' => $this->paymentId,
'receipt_id' => $this->receiptId,
'receipt_number' => $this->receiptNumber,
'member_id' => $this->memberId,
'payment_type' => $this->paymentType,
'type' => $this->paymentType,
'amount' => $this->amount,
'method' => $this->method,
];
}
public static function from(array $data): static
{
return new self(
paymentId: (int) ($data['payment_id'] ?? 0),
receiptId: (int) ($data['receipt_id'] ?? 0),
receiptNumber: (string) ($data['receipt_number'] ?? ''),
memberId: (int) ($data['member_id'] ?? 0),
paymentType: (string) ($data['payment_type'] ?? $data['type'] ?? ''),
amount: (string) ($data['amount'] ?? '0.00'),
method: (string) ($data['method'] ?? 'cash'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class PaymentVoided extends Event
{
public function __construct(
public readonly int $paymentId,
public readonly int $memberId,
public readonly string $paymentType,
public readonly string $amount,
public readonly string $reason,
) {}
public static function eventName(): string
{
return 'payment.voided';
}
public function toArray(): array
{
return [
'payment_id' => $this->paymentId,
'member_id' => $this->memberId,
'payment_type' => $this->paymentType,
'amount' => $this->amount,
'reason' => $this->reason,
];
}
public static function from(array $data): static
{
return new self(
paymentId: (int) ($data['payment_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
paymentType: (string) ($data['payment_type'] ?? ''),
amount: (string) ($data['amount'] ?? '0.00'),
reason: (string) ($data['reason'] ?? ''),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class PayrollPaid extends Event
{
public function __construct(
public readonly int $payrollRunId,
public readonly int $periodId,
public readonly string $paymentDate,
) {}
public static function eventName(): string
{
return 'hr.payroll.paid';
}
public function toArray(): array
{
return [
'payroll_run_id' => $this->payrollRunId,
'period_id' => $this->periodId,
'payment_date' => $this->paymentDate,
];
}
public static function from(array $data): static
{
return new self(
payrollRunId: (int) ($data['payroll_run_id'] ?? 0),
periodId: (int) ($data['period_id'] ?? 0),
paymentDate: (string) ($data['payment_date'] ?? ''),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class SaleCompleted extends Event
{
public function __construct(
public readonly int $saleId,
public readonly string $invoiceNumber,
public readonly string $customerType,
public readonly string $totalAmount,
) {}
public static function eventName(): string
{
return 'sale.completed';
}
public function toArray(): array
{
return [
'sale_id' => $this->saleId,
'invoice_number' => $this->invoiceNumber,
'customer_type' => $this->customerType,
'total_amount' => $this->totalAmount,
];
}
public static function from(array $data): static
{
return new self(
saleId: (int) ($data['sale_id'] ?? 0),
invoiceNumber: (string) ($data['invoice_number'] ?? ''),
customerType: (string) ($data['customer_type'] ?? ''),
totalAmount: (string) ($data['total_amount'] ?? '0.00'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class SaleVoided extends Event
{
public function __construct(
public readonly int $saleId,
public readonly string $reason,
) {}
public static function eventName(): string
{
return 'sale.voided';
}
public function toArray(): array
{
return [
'sale_id' => $this->saleId,
'reason' => $this->reason,
];
}
public static function from(array $data): static
{
return new self(
saleId: (int) ($data['sale_id'] ?? 0),
reason: (string) ($data['reason'] ?? ''),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Events;
final class SubscriptionPaid extends Event
{
public function __construct(
public readonly int $subscriptionId,
public readonly int $memberId,
public readonly string $year,
public readonly string $amount,
) {}
public static function eventName(): string
{
return 'subscription.paid';
}
public function toArray(): array
{
return [
'subscription_id' => $this->subscriptionId,
'member_id' => $this->memberId,
'year' => $this->year,
'amount' => $this->amount,
];
}
public static function from(array $data): static
{
return new self(
subscriptionId: (int) ($data['subscription_id'] ?? 0),
memberId: (int) ($data['member_id'] ?? 0),
year: (string) ($data['year'] ?? ''),
amount: (string) ($data['amount'] ?? '0.00'),
);
}
}
<?php
declare(strict_types=1);
namespace App\Core;
abstract class Facade
{
abstract protected static function getContainerKey(): string;
public static function __callStatic(string $method, array $args): mixed
{
$instance = App::getInstance()->resolve(static::getContainerKey());
if ($instance === null) {
throw new \RuntimeException('Service not bound in container: ' . static::getContainerKey());
}
return $instance->$method(...$args);
}
public static function instance(): object
{
$instance = App::getInstance()->resolve(static::getContainerKey());
if ($instance === null) {
throw new \RuntimeException('Service not bound in container: ' . static::getContainerKey());
}
return $instance;
}
}
<?php
declare(strict_types=1);
namespace App\Core\Facades;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Facade;
/**
* @method static array processPayment(array $data)
* @method static array voidPayment(int $paymentId, string $reason)
* @method static bool hasPaid(int $memberId, string $paymentType, ?string $relatedEntityType = null, ?int $relatedEntityId = null)
* @method static string totalPaid(int $memberId, ?string $paymentType = null)
* @method static string generateReceiptNumber()
* @method static string getPaymentTypeLabel(string $type)
*/
final class Payment extends Facade
{
protected static function getContainerKey(): string
{
return PaymentProcessorInterface::class;
}
}
<?php
declare(strict_types=1);
namespace App\Core\Facades;
use App\Core\Contracts\RuleEngineInterface;
use App\Core\Facade;
/**
* @method static mixed get(string $ruleCode, ?int $branchId = null, ?string $date = null)
* @method static mixed getValue(string $ruleCode, string $key = 'value', ?int $branchId = null)
* @method static array getAll(string $category, ?int $branchId = null)
* @method static mixed getEffective(string $ruleCode, string $entityType, int $entityId, ?int $branchId = null)
* @method static array require(string $ruleCode, ?int $branchId = null)
* @method static mixed requireValue(string $ruleCode, string $key = 'value', ?int $branchId = null)
*/
final class Rules extends Facade
{
protected static function getContainerKey(): string
{
return RuleEngineInterface::class;
}
}
<?php
declare(strict_types=1);
namespace App\Core\Facades;
use App\Core\Contracts\StockManagerInterface;
use App\Core\Facade;
/**
* @method static int moveStock(array $data)
* @method static string getAvailableQuantity(int $itemId, int $warehouseId)
* @method static string getTotalStock(int $itemId)
* @method static array getMovements(int $itemId, ?int $warehouseId = null, int $limit = 50)
*/
final class Stock extends Facade
{
protected static function getContainerKey(): string
{
return StockManagerInterface::class;
}
}
<?php
declare(strict_types=1);
namespace App\Core\Facades;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Core\Facade;
/**
* @method static object createInstance(string $workflowCode, string $entityType, int $entityId, ?array $stateData = null)
* @method static bool transition(int $instanceId, string $transitionName, ?string $notes = null, string $triggerType = 'manual')
* @method static array getAvailableTransitions(int $instanceId)
* @method static bool transitionByEntity(string $entityType, int $entityId, string $transitionName, ?string $notes = null)
* @method static ?string getCurrentState(string $entityType, int $entityId, ?string $workflowCode = null)
* @method static array getHistory(int $instanceId)
*/
final class Workflow extends Facade
{
protected static function getContainerKey(): string
{
return WorkflowEngineInterface::class;
}
}
<?php
declare(strict_types=1);
namespace App\Core;
final class ModuleRegistry
{
private static array $modules = [];
public static function register(string $moduleName, array $config): void
{
self::$modules[$moduleName] = $config;
}
public static function provides(string $moduleName): array
{
return self::$modules[$moduleName] ?? [];
}
public static function getAll(): array
{
return self::$modules;
}
public static function has(string $moduleName): bool
{
return isset(self::$modules[$moduleName]);
}
}
<?php
declare(strict_types=1);
namespace App\Core\Providers;
use App\Core\App;
use App\Core\ServiceProvider;
use App\Core\Contracts\RuleEngineInterface;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Contracts\StockManagerInterface;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Core\Contracts\JournalServiceInterface;
use App\Modules\Rules\RuleEngineAdapter;
use App\Modules\Payments\PaymentProcessorAdapter;
use App\Modules\Inventory\StockManagerAdapter;
use App\Modules\Workflow\WorkflowEngineAdapter;
use App\Modules\Accounting\JournalServiceAdapter;
final class CoreServiceProvider extends ServiceProvider
{
public function register(App $app): void
{
$app->singleton(RuleEngineInterface::class, fn() => new RuleEngineAdapter());
$app->singleton(PaymentProcessorInterface::class, fn() => new PaymentProcessorAdapter());
$app->singleton(StockManagerInterface::class, fn() => new StockManagerAdapter());
$app->singleton(WorkflowEngineInterface::class, fn() => new WorkflowEngineAdapter());
$app->singleton(JournalServiceInterface::class, fn() => new JournalServiceAdapter());
}
}
...@@ -90,6 +90,14 @@ final class Response ...@@ -90,6 +90,14 @@ final class Response
return $this; return $this;
} }
public function withHeaders(array $headers): self
{
foreach ($headers as $key => $value) {
$this->headers[$key] = $value;
}
return $this;
}
public function download(string $filePath, string $filename): self public function download(string $filePath, string $filename): self
{ {
if (!file_exists($filePath)) { if (!file_exists($filePath)) {
......
...@@ -106,6 +106,7 @@ final class Router ...@@ -106,6 +106,7 @@ final class Router
'csrf' => \App\Middleware\CSRFMiddleware::class, 'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class, 'auth' => \App\Middleware\AuthMiddleware::class,
'api_auth' => \App\Middleware\ApiAuthMiddleware::class, 'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class, 'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class, 'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class, 'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
......
<?php
declare(strict_types=1);
namespace App\Core;
abstract class ServiceProvider
{
abstract public function register(App $app): void;
public function boot(App $app): void
{
}
}
<?php
declare(strict_types=1);
namespace App\Core\Testing;
use App\Core\App;
use App\Core\Database;
final class AppTestHarness
{
private static bool $active = false;
public static function activate(Database $db, ?object $employee = null): void
{
self::$active = true;
$app = App::getInstance();
$app->setDb($db);
if ($employee !== null) {
$app->setCurrentEmployee($employee);
}
}
public static function deactivate(): void
{
self::$active = false;
}
public static function isActive(): bool
{
return self::$active;
}
}
<?php
declare(strict_types=1);
namespace App\Core\Testing;
use App\Core\Database;
final class TestDatabase extends Database
{
private int $transactionDepth = 0;
public function beginTransaction(): void
{
$this->transactionDepth++;
if ($this->transactionDepth === 1) {
parent::beginTransaction();
}
}
public function commit(): void
{
if ($this->transactionDepth > 1) {
$this->transactionDepth--;
return;
}
$this->transactionDepth = 0;
parent::commit();
}
public function rollBack(): void
{
if ($this->transactionDepth > 1) {
$this->transactionDepth--;
return;
}
$this->transactionDepth = 0;
parent::rollBack();
}
public function forceRollBack(): void
{
$this->transactionDepth = 0;
parent::rollBack();
}
}
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
final class CorsMiddleware
{
public function handle(Request $request, callable $next): Response
{
$config = App::getInstance()->config('api.cors', []);
$origin = $request->header('Origin') ?? '';
$allowedOrigins = $config['allowed_origins'] ?? [];
$allowOrigin = in_array($origin, $allowedOrigins, true) ? $origin : '';
if ($request->method() === 'OPTIONS') {
$response = new Response();
return $response->json(null, 204)
->header('Access-Control-Allow-Origin', $allowOrigin)
->header('Access-Control-Allow-Methods', implode(', ', $config['allowed_methods'] ?? []))
->header('Access-Control-Allow-Headers', implode(', ', $config['allowed_headers'] ?? []))
->header('Access-Control-Max-Age', (string) ($config['max_age'] ?? 86400))
->header('Access-Control-Allow-Credentials', ($config['allow_credentials'] ?? false) ? 'true' : 'false');
}
$response = $next($request);
if ($allowOrigin !== '') {
$response->header('Access-Control-Allow-Origin', $allowOrigin);
$response->header('Access-Control-Allow-Credentials', ($config['allow_credentials'] ?? false) ? 'true' : 'false');
$response->header('Access-Control-Expose-Headers', implode(', ', $config['exposed_headers'] ?? []));
}
return $response;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting;
use App\Core\Contracts\JournalServiceInterface;
use App\Modules\Accounting\Services\JournalService;
final class JournalServiceAdapter implements JournalServiceInterface
{
public function createEntry(array $header, array $lines, bool $autoPost = false): array
{
return JournalService::createEntry($header, $lines, $autoPost);
}
public function postEntry(int $entryId): array
{
return JournalService::postEntry($entryId);
}
public function reverseEntry(int $entryId, string $reason, ?string $reversalDate = null): array
{
return JournalService::reverseEntry($entryId, $reason, $reversalDate);
}
}
<?php
declare(strict_types=1);
return [
['POST', '/api/v1/auth/token', 'Auth\Controllers\Api\TokenController@store', ['cors'], null],
['GET', '/api/v1/auth/tokens', 'Auth\Controllers\Api\TokenController@index', ['cors', 'api_auth'], null],
['POST', '/api/v1/auth/tokens/{id:\d+}/revoke', 'Auth\Controllers\Api\TokenController@revoke', ['cors', 'api_auth'], null],
];
<?php
declare(strict_types=1);
namespace App\Modules\Auth\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Users\Models\Employee;
final class TokenController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function store(Request $request): Response
{
$data = $this->validatedJson([
'username' => 'required|string',
'password' => 'required|string',
'token_name' => 'nullable|string|max:100',
]);
$db = App::getInstance()->db();
$employee = $db->selectOne(
"SELECT id, password_hash, is_active FROM employees WHERE username = ?",
[$data['username']]
);
if (!$employee || !password_verify($data['password'], $employee['password_hash'])) {
return $this->errorResponse('بيانات الدخول غير صحيحة', 401);
}
if (!$employee['is_active']) {
return $this->errorResponse('الحساب غير نشط', 401);
}
$config = App::getInstance()->config('api.token', []);
$maxTokens = $config['max_per_employee'] ?? 5;
$activeCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM api_tokens WHERE employee_id = ? AND is_revoked = 0 AND (expires_at IS NULL OR expires_at > NOW())",
[$employee['id']]
);
if (($activeCount['cnt'] ?? 0) >= $maxTokens) {
return $this->errorResponse('تم الوصول للحد الأقصى من التوكنات النشطة', 422);
}
$token = bin2hex(random_bytes(32));
$ttlHours = $config['ttl_hours'] ?? 720;
$expiresAt = date('Y-m-d H:i:s', time() + ($ttlHours * 3600));
$tokenName = $data['token_name'] ?? 'default';
$db->insert('api_tokens', [
'employee_id' => $employee['id'],
'token' => $token,
'name' => $tokenName,
'expires_at' => $expiresAt,
]);
return $this->successResponse([
'token' => $token,
'name' => $tokenName,
'expires_at' => $expiresAt,
], 201);
}
public function index(Request $request): Response
{
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$tokens = $db->select(
"SELECT id, name, last_used_at, expires_at, created_at FROM api_tokens WHERE employee_id = ? AND is_revoked = 0 ORDER BY created_at DESC",
[$employee->id]
);
return $this->successResponse($tokens);
}
public function revoke(Request $request, string $id): Response
{
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$token = $db->selectOne(
"SELECT id FROM api_tokens WHERE id = ? AND employee_id = ? AND is_revoked = 0",
[(int) $id, $employee->id]
);
if (!$token) {
return $this->errorResponse('التوكن غير موجود', 404);
}
$db->update('api_tokens', ['is_revoked' => 1], 'id = ?', [(int) $id]);
return $this->successResponse(['revoked' => true]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory;
use App\Core\Contracts\StockManagerInterface;
use App\Modules\Inventory\Services\StockService;
final class StockManagerAdapter implements StockManagerInterface
{
public function moveStock(array $data): int
{
return StockService::moveStock($data);
}
public function getAvailableQuantity(int $itemId, int $warehouseId): string
{
return StockService::getAvailableQuantity($itemId, $warehouseId);
}
public function getTotalStock(int $itemId): string
{
return StockService::getTotalStock($itemId);
}
public function getMovements(int $itemId, ?int $warehouseId = null, int $limit = 50): array
{
return StockService::getMovements($itemId, $warehouseId, $limit);
}
}
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\ModuleRegistry;
use App\Core\Contracts\StockManagerInterface;
ModuleRegistry::register('inventory', [
'services' => [StockManagerInterface::class],
'description' => 'Stock management — movements, quantities, audits',
]);
<?php
declare(strict_types=1);
return [
['GET', '/api/v1/members', 'Members\Controllers\Api\MemberApiV1Controller@index', ['cors', 'api_auth'], 'member.view'],
['GET', '/api/v1/members/search', 'Members\Controllers\Api\MemberApiV1Controller@search', ['cors', 'api_auth'], 'member.view'],
['GET', '/api/v1/members/{id:\d+}', 'Members\Controllers\Api\MemberApiV1Controller@show', ['cors', 'api_auth'], 'member.view'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Members\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Members\Models\Member;
final class MemberApiV1Controller extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function index(Request $request): Response
{
$this->authorize('member.view');
$page = max(1, $this->queryInt('page', 1));
$perPage = min(100, max(1, $this->queryInt('per_page', 25)));
$status = $this->queryParam('status');
$search = $this->queryParam('q');
$branchId = $this->queryParam('branch_id');
$db = App::getInstance()->db();
$where = ['m.is_archived = 0'];
$params = [];
if ($status) {
$where[] = 'm.status = ?';
$params[] = $status;
}
if ($branchId) {
$where[] = 'm.branch_id = ?';
$params[] = (int) $branchId;
}
if ($search) {
$where[] = '(m.full_name_ar LIKE ? OR m.membership_number LIKE ? OR m.national_id LIKE ? OR m.phone_mobile LIKE ?)';
$term = "%{$search}%";
$params = array_merge($params, [$term, $term, $term, $term]);
}
$whereSql = implode(' AND ', $where);
$countRow = $db->selectOne("SELECT COUNT(*) as total FROM members m WHERE {$whereSql}", $params);
$total = (int) ($countRow['total'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT m.id, m.membership_number, m.full_name_ar, m.full_name_en, m.national_id, m.phone_mobile, m.status, m.membership_type, m.branch_id, m.created_at
FROM members m
WHERE {$whereSql}
ORDER BY m.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return $this->paginatedResponse($rows, $total, $page, $perPage);
}
public function show(Request $request, string $id): Response
{
$this->authorize('member.view');
$member = Member::find((int) $id);
if (!$member || $member->is_archived) {
return $this->errorResponse('العضو غير موجود', 404);
}
$data = [
'id' => $member->id,
'membership_number' => $member->membership_number,
'full_name_ar' => $member->full_name_ar,
'full_name_en' => $member->full_name_en,
'national_id' => $member->national_id,
'date_of_birth' => $member->date_of_birth,
'gender' => $member->gender,
'phone_mobile' => $member->phone_mobile,
'email' => $member->email,
'status' => $member->status,
'membership_type' => $member->membership_type,
'member_category' => $member->member_category,
'branch_id' => $member->branch_id,
'branch_name' => $member->getBranchName(),
'created_at' => $member->created_at,
];
return $this->successResponse($data);
}
public function search(Request $request): Response
{
$this->authorize('member.view');
$q = trim((string) $this->queryParam('q', ''));
if ($q === '' || mb_strlen($q) < 2) {
return $this->successResponse([]);
}
$db = App::getInstance()->db();
$term = "%{$q}%";
$rows = $db->select(
"SELECT id, membership_number, full_name_ar, phone_mobile, status
FROM members
WHERE is_archived = 0
AND (full_name_ar LIKE ? OR membership_number LIKE ? OR national_id LIKE ? OR phone_mobile LIKE ?)
ORDER BY full_name_ar ASC
LIMIT 20",
[$term, $term, $term, $term]
);
return $this->successResponse($rows);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/api/v1/payments', 'Payments\Controllers\Api\PaymentApiV1Controller@index', ['cors', 'api_auth'], 'payment.view'],
['GET', '/api/v1/payments/{id:\d+}', 'Payments\Controllers\Api\PaymentApiV1Controller@show', ['cors', 'api_auth'], 'payment.view'],
['POST', '/api/v1/payments', 'Payments\Controllers\Api\PaymentApiV1Controller@store', ['cors', 'api_auth'], 'payment.process_cash'],
['POST', '/api/v1/payments/{id:\d+}/void', 'Payments\Controllers\Api\PaymentApiV1Controller@void', ['cors', 'api_auth'], 'payment.void_receipt'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Exceptions\ValidationException;
final class PaymentApiV1Controller extends ApiController
{
private PaymentProcessorInterface $payments;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->payments = App::getInstance()->resolve(PaymentProcessorInterface::class);
}
public function index(Request $request): Response
{
$this->authorize('payment.view');
$page = max(1, $this->queryInt('page', 1));
$perPage = min(100, max(1, $this->queryInt('per_page', 25)));
$memberId = $this->queryParam('member_id');
$paymentType = $this->queryParam('payment_type');
$dateFrom = $this->queryParam('date_from');
$dateTo = $this->queryParam('date_to');
$db = App::getInstance()->db();
$where = ['p.is_archived = 0'];
$params = [];
if ($memberId) {
$where[] = 'p.member_id = ?';
$params[] = (int) $memberId;
}
if ($paymentType) {
$where[] = 'p.payment_type = ?';
$params[] = $paymentType;
}
if ($dateFrom) {
$where[] = 'p.payment_date >= ?';
$params[] = $dateFrom;
}
if ($dateTo) {
$where[] = 'p.payment_date <= ?';
$params[] = $dateTo;
}
$whereSql = implode(' AND ', $where);
$countRow = $db->selectOne("SELECT COUNT(*) as total FROM payments p WHERE {$whereSql}", $params);
$total = (int) ($countRow['total'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT p.id, p.member_id, p.payment_type, p.payment_method, p.amount, p.payment_date, p.receipt_number, p.status, p.created_at
FROM payments p
WHERE {$whereSql}
ORDER BY p.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return $this->paginatedResponse($rows, $total, $page, $perPage);
}
public function show(Request $request, string $id): Response
{
$this->authorize('payment.view');
$db = App::getInstance()->db();
$payment = $db->selectOne(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.id = ? AND p.is_archived = 0",
[(int) $id]
);
if (!$payment) {
return $this->errorResponse('الدفعة غير موجودة', 404);
}
return $this->successResponse($payment);
}
public function store(Request $request): Response
{
$this->authorize('payment.process_cash');
try {
$data = $this->validatedJson([
'member_id' => 'required|integer',
'amount' => 'required|numeric',
'payment_type' => 'required|string',
'payment_method' => 'nullable|in:cash,check,visa,transfer',
'check_number' => 'nullable|string',
'check_bank' => 'nullable|string',
'notes' => 'nullable|string',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$result = $this->payments->processPayment($data);
if (!($result['success'] ?? false)) {
return $this->errorResponse($result['error'] ?? 'فشل في معالجة الدفع', 422);
}
return $this->successResponse($result, 201);
}
public function void(Request $request, string $id): Response
{
$this->authorize('payment.void_receipt');
try {
$data = $this->validatedJson([
'reason' => 'required|string',
]);
} catch (ValidationException $e) {
return $this->errorResponse('سبب الإلغاء مطلوب', 422, $e->errors());
}
$result = $this->payments->voidPayment((int) $id, $data['reason']);
if (!($result['success'] ?? false)) {
return $this->errorResponse($result['error'] ?? 'فشل في إلغاء الدفعة', 422);
}
return $this->successResponse($result);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Payments;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Modules\Payments\Services\PaymentService;
final class PaymentProcessorAdapter implements PaymentProcessorInterface
{
public function processPayment(array $data): array
{
return PaymentService::processPayment($data);
}
public function voidPayment(int $paymentId, string $reason): array
{
return PaymentService::voidPayment($paymentId, $reason);
}
public function hasPaid(int $memberId, string $paymentType, ?string $relatedEntityType = null, ?int $relatedEntityId = null): bool
{
return PaymentService::hasPaid($memberId, $paymentType, $relatedEntityType, $relatedEntityId);
}
public function totalPaid(int $memberId, ?string $paymentType = null): string
{
return PaymentService::totalPaid($memberId, $paymentType);
}
public function generateReceiptNumber(): string
{
return PaymentService::generateReceiptNumber();
}
public function getPaymentTypeLabel(string $type): string
{
return PaymentService::getPaymentTypeLabel($type);
}
}
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\ModuleRegistry;
use App\Core\Contracts\PaymentProcessorInterface;
ModuleRegistry::register('payments', [
'services' => [PaymentProcessorInterface::class],
'description' => 'Central payment processing — receipts, voids, payment queries',
]);
<?php
declare(strict_types=1);
namespace App\Modules\Rules;
use App\Core\Contracts\RuleEngineInterface;
use App\Modules\Rules\Services\RuleEngine;
final class RuleEngineAdapter implements RuleEngineInterface
{
public function get(string $ruleCode, ?int $branchId = null, ?string $date = null): mixed
{
return RuleEngine::get($ruleCode, $branchId, $date);
}
public function getValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed
{
return RuleEngine::getValue($ruleCode, $key, $branchId);
}
public function getAll(string $category, ?int $branchId = null): array
{
return RuleEngine::getAll($category, $branchId);
}
public function getEffective(string $ruleCode, string $entityType, int $entityId, ?int $branchId = null): mixed
{
return RuleEngine::getEffective($ruleCode, $entityType, $entityId, $branchId);
}
public function require(string $ruleCode, ?int $branchId = null): array
{
return RuleEngine::require($ruleCode, $branchId);
}
public function requireValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed
{
return RuleEngine::requireValue($ruleCode, $key, $branchId);
}
}
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\ModuleRegistry;
use App\Core\Contracts\RuleEngineInterface;
ModuleRegistry::register('rules', [
'services' => [RuleEngineInterface::class],
'description' => 'Business rule engine — pricing, fees, limits, constraints',
]);
<?php
declare(strict_types=1);
namespace App\Modules\Workflow;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Modules\Workflow\Services\WorkflowEngine;
final class WorkflowEngineAdapter implements WorkflowEngineInterface
{
public function createInstance(string $workflowCode, string $entityType, int $entityId, ?array $stateData = null): object
{
return WorkflowEngine::createInstance($workflowCode, $entityType, $entityId, $stateData);
}
public function transition(int $instanceId, string $transitionName, ?string $notes = null, string $triggerType = 'manual'): bool
{
return WorkflowEngine::transition($instanceId, $transitionName, $notes, $triggerType);
}
public function getAvailableTransitions(int $instanceId): array
{
return WorkflowEngine::getAvailableTransitions($instanceId);
}
public function transitionByEntity(string $entityType, int $entityId, string $transitionName, ?string $notes = null): bool
{
return WorkflowEngine::transitionByEntity($entityType, $entityId, $transitionName, $notes);
}
public function getCurrentState(string $entityType, int $entityId, ?string $workflowCode = null): ?string
{
return WorkflowEngine::getCurrentState($entityType, $entityId, $workflowCode);
}
public function getHistory(int $instanceId): array
{
return WorkflowEngine::getHistory($instanceId);
}
}
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\ModuleRegistry;
use App\Core\Contracts\WorkflowEngineInterface;
ModuleRegistry::register('workflow', [
'services' => [WorkflowEngineInterface::class],
'description' => 'State machine — workflow instances, transitions, guards',
]);
<?php
declare(strict_types=1);
return [
'cors' => [
'allowed_origins' => array_filter(explode(',', env('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173'))),
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
'exposed_headers' => ['X-Request-Id'],
'max_age' => 86400,
'allow_credentials' => true,
],
'token' => [
'ttl_hours' => (int) env('API_TOKEN_TTL_HOURS', 720),
'max_per_employee' => 5,
],
'rate_limit' => [
'requests_per_minute' => (int) env('API_RATE_LIMIT', 60),
],
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS api_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
employee_id INT UNSIGNED NOT NULL,
token VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT 'default',
abilities JSON NULL,
last_used_at DATETIME NULL,
expires_at DATETIME NULL,
is_revoked TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_token (token),
KEY idx_employee (employee_id),
KEY idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS api_tokens",
];
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true"> <phpunit
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
stopOnFailure="false"
>
<testsuites> <testsuites>
<testsuite name="Unit"> <testsuite name="Unit">
<directory>tests/Unit</directory> <directory>tests/Unit</directory>
...@@ -9,7 +15,11 @@ ...@@ -9,7 +15,11 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<php> <php>
<env name="DB_NAME" value="club_erp_test"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_NAME" value="club_erp_test"/>
<env name="DB_USER" value="root"/>
<env name="DB_PASS" value=""/>
</php> </php>
</phpunit> </phpunit>
...@@ -3,28 +3,52 @@ declare(strict_types=1); ...@@ -3,28 +3,52 @@ declare(strict_types=1);
namespace Tests; namespace Tests;
use App\Core\Database; use App\Core\Testing\TestDatabase;
abstract class DatabaseTestCase extends TestCase abstract class DatabaseTestCase extends TestCase
{ {
protected Database $db; protected TestDatabase $db;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->db = new Database(
try {
$this->db = new TestDatabase(
$_ENV['DB_HOST'] ?? '127.0.0.1', $_ENV['DB_HOST'] ?? '127.0.0.1',
(int) ($_ENV['DB_PORT'] ?? 3306), (int) ($_ENV['DB_PORT'] ?? 3306),
$_ENV['DB_NAME'] ?? 'club_erp_test', $_ENV['DB_NAME'] ?? 'club_erp_test',
$_ENV['DB_USER'] ?? 'root', $_ENV['DB_USER'] ?? 'root',
$_ENV['DB_PASS'] ?? '' $_ENV['DB_PASS'] ?? ''
); );
} catch (\PDOException $e) {
$this->markTestSkipped('Database not available: ' . $e->getMessage());
}
$this->db->beginTransaction(); $this->db->beginTransaction();
AppTestHarness::activate($this->db, $this->createTestEmployee());
} }
protected function tearDown(): void protected function tearDown(): void
{ {
$this->db->rollBack(); $this->db->forceRollBack();
AppTestHarness::deactivate();
parent::tearDown(); parent::tearDown();
} }
protected function createTestEmployee(): object
{
return (object) [
'id' => 1,
'full_name_ar' => 'موظف اختبار',
'full_name_en' => 'Test Employee',
'is_active' => 1,
];
}
protected function factory(string $factoryClass): object
{
return new $factoryClass($this->db);
}
} }
<?php
declare(strict_types=1);
namespace Tests\Factories;
final class BranchFactory extends Factory
{
private static int $sequence = 0;
protected function table(): string
{
return 'branches';
}
protected function defaults(): array
{
self::$sequence++;
return [
'branch_code' => 'TEST-' . self::$sequence . '-' . uniqid(),
'name_ar' => 'فرع اختبار ' . self::$sequence,
'name_en' => 'Test Branch ' . self::$sequence,
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
}
<?php
declare(strict_types=1);
namespace Tests\Factories;
use App\Core\Database;
abstract class Factory
{
protected Database $db;
protected array $overrides = [];
public function __construct(Database $db)
{
$this->db = $db;
}
abstract protected function defaults(): array;
abstract protected function table(): string;
public function withOverrides(array $data): static
{
$clone = clone $this;
$clone->overrides = array_merge($clone->overrides, $data);
return $clone;
}
public function make(): array
{
return array_merge($this->defaults(), $this->overrides);
}
public function create(): array
{
$data = $this->make();
$id = $this->db->insert($this->table(), $data);
return array_merge($data, ['id' => $id]);
}
public function createMany(int $count): array
{
$records = [];
for ($i = 0; $i < $count; $i++) {
$records[] = $this->create();
}
return $records;
}
}
<?php
declare(strict_types=1);
namespace Tests\Factories;
final class MemberFactory extends Factory
{
private static int $sequence = 0;
protected function table(): string
{
return 'members';
}
protected function defaults(): array
{
self::$sequence++;
$nid = '2990101' . str_pad((string) self::$sequence, 7, '0', STR_PAD_LEFT);
return [
'branch_id' => $this->getOrCreateBranchId(),
'full_name_ar' => 'عضو اختبار ' . self::$sequence,
'full_name_en' => 'Test Member ' . self::$sequence,
'national_id' => $nid,
'date_of_birth' => '1990-01-01',
'gender' => 'male',
'phone_mobile' => '0100' . str_pad((string) self::$sequence, 7, '0', STR_PAD_LEFT),
'membership_type' => 'working',
'member_category' => 'working_member',
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
private function getOrCreateBranchId(): int
{
$branch = $this->db->selectOne("SELECT id FROM branches WHERE is_active = 1 LIMIT 1");
if ($branch) {
return (int) $branch['id'];
}
return (new BranchFactory($this->db))->create()['id'];
}
}
<?php
declare(strict_types=1);
namespace Tests\Factories;
final class PaymentFactory extends Factory
{
protected function table(): string
{
return 'payments';
}
protected function defaults(): array
{
return [
'member_id' => $this->getOrCreateMemberId(),
'payment_type' => 'annual_subscription',
'amount' => '527.00',
'currency' => 'EGP',
'payment_method' => 'cash',
'payment_date' => date('Y-m-d'),
'is_voided' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
private function getOrCreateMemberId(): int
{
$member = $this->db->selectOne(
"SELECT id FROM members WHERE is_archived = 0 LIMIT 1"
);
if ($member) {
return (int) $member['id'];
}
return (new MemberFactory($this->db))->create()['id'];
}
}
<?php
declare(strict_types=1);
namespace Tests\Integration\Members;
use Tests\DatabaseTestCase;
use Tests\Factories\MemberFactory;
use App\Modules\Members\Services\MemberSearchService;
final class MemberSearchTest extends DatabaseTestCase
{
public function testSearchByNameArabic(): void
{
$factory = new MemberFactory($this->db);
$factory->withOverrides(['full_name_ar' => 'أحمد محمود خالد'])->create();
$factory->withOverrides(['full_name_ar' => 'محمد علي حسن'])->create();
$results = MemberSearchService::search('أحمد');
$this->assertNotEmpty($results);
$this->assertCount(1, $results);
$this->assertEquals('أحمد محمود خالد', $results[0]['full_name_ar']);
}
public function testSearchByNationalId(): void
{
$factory = new MemberFactory($this->db);
$member = $factory->withOverrides(['national_id' => '28501011234567'])->create();
$results = MemberSearchService::search('28501011234567');
$this->assertNotEmpty($results);
$this->assertEquals($member['id'], $results[0]['id']);
}
public function testSearchByPhone(): void
{
$factory = new MemberFactory($this->db);
$member = $factory->withOverrides(['phone_mobile' => '01012345678'])->create();
$results = MemberSearchService::search('01012345678');
$this->assertNotEmpty($results);
$this->assertEquals($member['id'], $results[0]['id']);
}
public function testSearchByMembershipNumber(): void
{
$factory = new MemberFactory($this->db);
$member = $factory->withOverrides(['membership_number' => 'MEM-9999'])->create();
$results = MemberSearchService::search('MEM-9999');
$this->assertNotEmpty($results);
$this->assertEquals($member['id'], $results[0]['id']);
}
public function testSearchReturnsEmptyForNoMatch(): void
{
(new MemberFactory($this->db))->create();
$results = MemberSearchService::search('zzzznonexistentzzzz');
$this->assertEmpty($results);
}
public function testSearchExcludesArchivedMembers(): void
{
$factory = new MemberFactory($this->db);
$factory->withOverrides([
'full_name_ar' => 'عضو مؤرشف فريد',
'is_archived' => 1,
])->create();
$results = MemberSearchService::search('عضو مؤرشف فريد');
$this->assertEmpty($results);
}
public function testSearchReturnsEmptyForEmptyQuery(): void
{
(new MemberFactory($this->db))->create();
$results = MemberSearchService::search('');
$this->assertEmpty($results);
}
public function testSearchRespectsLimit(): void
{
$factory = new MemberFactory($this->db);
for ($i = 0; $i < 5; $i++) {
$factory->withOverrides([
'full_name_ar' => 'بحث مشترك ' . $i,
'national_id' => '2990101' . str_pad((string) (9000 + $i), 7, '0', STR_PAD_LEFT),
'phone_mobile' => '0109' . str_pad((string) (9000 + $i), 7, '0', STR_PAD_LEFT),
])->create();
}
$results = MemberSearchService::search('بحث مشترك', 3);
$this->assertCount(3, $results);
}
}
<?php
declare(strict_types=1);
namespace Tests\Integration\Payments;
use Tests\DatabaseTestCase;
use Tests\Factories\MemberFactory;
use App\Modules\Payments\Services\PaymentService;
final class PaymentServiceTest extends DatabaseTestCase
{
public function testProcessPaymentSuccess(): void
{
$member = (new MemberFactory($this->db))->create();
$result = PaymentService::processPayment([
'member_id' => $member['id'],
'amount' => '527.00',
'payment_type' => 'annual_subscription',
'payment_method' => 'cash',
]);
$this->assertTrue($result['success']);
$this->assertArrayHasKey('payment_id', $result);
$this->assertArrayHasKey('receipt_id', $result);
$this->assertArrayHasKey('receipt_number', $result);
$this->assertNotEmpty($result['receipt_number']);
$payment = $this->db->selectOne("SELECT * FROM payments WHERE id = ?", [$result['payment_id']]);
$this->assertNotNull($payment);
$this->assertEquals('527.00', $payment['amount']);
$this->assertEquals('annual_subscription', $payment['payment_type']);
$this->assertEquals('cash', $payment['payment_method']);
$this->assertEquals($member['id'], $payment['member_id']);
$receipt = $this->db->selectOne("SELECT * FROM receipts WHERE id = ?", [$result['receipt_id']]);
$this->assertNotNull($receipt);
$this->assertEquals($result['receipt_number'], $receipt['receipt_number']);
$this->assertEquals('527.00', $receipt['amount']);
$this->assertEventDispatched('payment.completed', function (array $data) use ($member) {
$this->assertEquals($member['id'], $data['member_id']);
$this->assertEquals('527.00', $data['amount']);
$this->assertEquals('annual_subscription', $data['payment_type']);
$this->assertEquals('cash', $data['method']);
});
}
public function testProcessPaymentRejectsZeroAmount(): void
{
$member = (new MemberFactory($this->db))->create();
$result = PaymentService::processPayment([
'member_id' => $member['id'],
'amount' => '0.00',
'payment_type' => 'annual_subscription',
]);
$this->assertFalse($result['success']);
$this->assertArrayHasKey('error', $result);
$this->assertEventNotDispatched('payment.completed');
}
public function testProcessPaymentRejectsEmptyType(): void
{
$member = (new MemberFactory($this->db))->create();
$result = PaymentService::processPayment([
'member_id' => $member['id'],
'amount' => '100.00',
'payment_type' => '',
]);
$this->assertFalse($result['success']);
$this->assertEventNotDispatched('payment.completed');
}
public function testProcessPaymentRejectsNonExistentMember(): void
{
$result = PaymentService::processPayment([
'member_id' => 999999,
'amount' => '100.00',
'payment_type' => 'fine',
]);
$this->assertFalse($result['success']);
$this->assertStringContainsString('غير موجود', $result['error']);
$this->assertEventNotDispatched('payment.completed');
}
public function testProcessPaymentWithCheckMethod(): void
{
$member = (new MemberFactory($this->db))->create();
$result = PaymentService::processPayment([
'member_id' => $member['id'],
'amount' => '10000.00',
'payment_type' => 'membership_fee',
'payment_method' => 'check',
'check_number' => 'CHK-001',
'check_bank' => 'بنك مصر',
'check_date' => '2026-06-01',
]);
$this->assertTrue($result['success']);
$payment = $this->db->selectOne("SELECT * FROM payments WHERE id = ?", [$result['payment_id']]);
$this->assertEquals('check', $payment['payment_method']);
$this->assertEquals('CHK-001', $payment['check_number']);
$this->assertEquals('بنك مصر', $payment['check_bank']);
$this->assertEquals('pending', $payment['check_status']);
}
}
...@@ -3,8 +3,54 @@ declare(strict_types=1); ...@@ -3,8 +3,54 @@ declare(strict_types=1);
namespace Tests; namespace Tests;
use App\Core\EventBus;
use PHPUnit\Framework\TestCase as BaseTestCase; use PHPUnit\Framework\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
protected function setUp(): void
{
parent::setUp();
EventBus::enableTestMode();
}
protected function tearDown(): void
{
EventBus::disableTestMode();
parent::tearDown();
}
protected function assertEventDispatched(string $eventName, ?callable $assertion = null): void
{
$dispatched = EventBus::getDispatched();
$matches = array_filter($dispatched, fn($e) => $e['event'] === $eventName);
$this->assertNotEmpty($matches, "Expected event '{$eventName}' was not dispatched.");
if ($assertion !== null) {
$first = array_values($matches)[0];
$assertion($first['data']);
}
}
protected function assertEventNotDispatched(string $eventName): void
{
$dispatched = EventBus::getDispatched();
$matches = array_filter($dispatched, fn($e) => $e['event'] === $eventName);
$this->assertEmpty($matches, "Event '{$eventName}' was unexpectedly dispatched.");
}
protected function assertEventDispatchedCount(string $eventName, int $count): void
{
$dispatched = EventBus::getDispatched();
$matches = array_filter($dispatched, fn($e) => $e['event'] === $eventName);
$this->assertCount($count, $matches, "Expected event '{$eventName}' to be dispatched {$count} time(s), got " . count($matches) . ".");
}
protected function getDispatchedEvents(): array
{
return EventBus::getDispatched();
}
} }
<?php
declare(strict_types=1);
namespace Tests\Unit\Api;
use Tests\TestCase;
use App\Core\ApiController;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class ApiControllerTest extends TestCase
{
private object $controller;
protected function setUp(): void
{
parent::setUp();
$this->controller = new class extends ApiController {
public function __construct()
{
parent::__construct();
}
public function callSuccess($data, int $status = 200, array $meta = []): Response
{
return $this->successResponse($data, $status, $meta);
}
public function callError(string $message, int $status = 400, array $details = []): Response
{
return $this->errorResponse($message, $status, $details);
}
public function callPaginated(array $items, int $total, int $page, int $perPage): Response
{
return $this->paginatedResponse($items, $total, $page, $perPage);
}
};
}
public function testSuccessResponseStructure(): void
{
$response = $this->controller->callSuccess(['id' => 1, 'name' => 'Test']);
$body = $this->decodeResponseBody($response);
$this->assertTrue($body['success']);
$this->assertEquals(['id' => 1, 'name' => 'Test'], $body['data']);
$this->assertNull($body['errors']);
}
public function testSuccessResponseWithMeta(): void
{
$response = $this->controller->callSuccess(['id' => 1], 200, ['version' => 'v1']);
$body = $this->decodeResponseBody($response);
$this->assertTrue($body['success']);
$this->assertEquals(['version' => 'v1'], $body['meta']);
}
public function testErrorResponseStructure(): void
{
$response = $this->controller->callError('Something went wrong', 422);
$body = $this->decodeResponseBody($response);
$this->assertFalse($body['success']);
$this->assertNull($body['data']);
$this->assertEquals('Something went wrong', $body['errors']['message']);
}
public function testErrorResponseWithDetails(): void
{
$details = ['field' => ['required']];
$response = $this->controller->callError('Validation failed', 422, $details);
$body = $this->decodeResponseBody($response);
$this->assertFalse($body['success']);
$this->assertEquals($details, $body['errors']['details']);
}
public function testPaginatedResponseStructure(): void
{
$items = [['id' => 1], ['id' => 2]];
$response = $this->controller->callPaginated($items, 50, 1, 25);
$body = $this->decodeResponseBody($response);
$this->assertTrue($body['success']);
$this->assertCount(2, $body['data']);
$this->assertEquals(50, $body['meta']['total']);
$this->assertEquals(1, $body['meta']['page']);
$this->assertEquals(25, $body['meta']['per_page']);
$this->assertEquals(2, $body['meta']['last_page']);
}
public function testPaginatedLastPageCalculation(): void
{
$response = $this->controller->callPaginated([], 101, 3, 10);
$body = $this->decodeResponseBody($response);
$this->assertEquals(11, $body['meta']['last_page']);
}
private function decodeResponseBody(Response $response): array
{
$ref = new \ReflectionClass($response);
$bodyProp = $ref->getProperty('body');
$bodyProp->setAccessible(true);
return json_decode($bodyProp->getValue($response), true);
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Api;
use Tests\TestCase;
use App\Core\App;
use App\Core\Router;
final class ApiRoutingTest extends TestCase
{
public function testApiRoutesFileStructure(): void
{
$basePath = dirname(__DIR__, 3);
$authRoutes = require $basePath . '/app/Modules/Auth/ApiRoutes.php';
$this->assertIsArray($authRoutes);
$this->assertNotEmpty($authRoutes);
foreach ($authRoutes as $route) {
$this->assertIsArray($route);
$this->assertGreaterThanOrEqual(3, count($route));
$this->assertContains($route[0], ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
$this->assertStringStartsWith('/api/v1/', $route[1]);
}
}
public function testMemberApiRoutesFile(): void
{
$basePath = dirname(__DIR__, 3);
$routes = require $basePath . '/app/Modules/Members/ApiRoutes.php';
$this->assertIsArray($routes);
$paths = array_column($routes, 1);
$this->assertContains('/api/v1/members', $paths);
$this->assertContains('/api/v1/members/{id:\d+}', $paths);
$this->assertContains('/api/v1/members/search', $paths);
}
public function testPaymentApiRoutesFile(): void
{
$basePath = dirname(__DIR__, 3);
$routes = require $basePath . '/app/Modules/Payments/ApiRoutes.php';
$this->assertIsArray($routes);
$paths = array_column($routes, 1);
$this->assertContains('/api/v1/payments', $paths);
$this->assertContains('/api/v1/payments/{id:\d+}', $paths);
}
public function testAllApiRoutesHaveCorsMiddleware(): void
{
$basePath = dirname(__DIR__, 3);
$files = glob($basePath . '/app/Modules/*/ApiRoutes.php');
foreach ($files as $file) {
$routes = require $file;
foreach ($routes as $route) {
$middleware = $route[3] ?? [];
$this->assertContains('cors', $middleware, "Route {$route[1]} in {$file} missing cors middleware");
}
}
}
public function testRouterRegistersApiRoutes(): void
{
$router = new Router();
$basePath = dirname(__DIR__, 3);
$files = glob($basePath . '/app/Modules/*/ApiRoutes.php');
$routeCount = 0;
foreach ($files as $file) {
$routes = require $file;
foreach ($routes as $route) {
$router->addRoute($route[0], $route[1], $route[2], $route[3] ?? [], $route[4] ?? null);
$routeCount++;
}
}
$this->assertGreaterThanOrEqual(7, $routeCount);
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Api;
use Tests\TestCase;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Middleware\CorsMiddleware;
final class CorsMiddlewareTest extends TestCase
{
private CorsMiddleware $middleware;
protected function setUp(): void
{
parent::setUp();
$this->middleware = new CorsMiddleware();
$app = App::getInstance();
$ref = new \ReflectionClass($app);
$configProp = $ref->getProperty('config');
$configProp->setAccessible(true);
$config = $configProp->getValue($app);
$config['api'] = [
'cors' => [
'allowed_origins' => ['http://localhost:3000', 'http://localhost:5173'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
'exposed_headers' => ['X-Request-Id'],
'max_age' => 86400,
'allow_credentials' => true,
],
];
$configProp->setValue($app, $config);
}
public function testPassthroughCallsNext(): void
{
$request = $this->createMockRequest('GET', null);
$called = false;
$response = $this->middleware->handle($request, function ($req) use (&$called) {
$called = true;
return (new Response())->json(['ok' => true]);
});
$this->assertTrue($called);
}
public function testOptionsRequestReturns204(): void
{
$request = $this->createMockRequest('OPTIONS', 'http://localhost:3000');
$response = $this->middleware->handle($request, function () {
return (new Response())->json(['should_not_reach' => true]);
});
$ref = new \ReflectionClass($response);
$statusProp = $ref->getProperty('statusCode');
$statusProp->setAccessible(true);
$this->assertEquals(204, $statusProp->getValue($response));
}
public function testCorsHeadersSetForAllowedOrigin(): void
{
$request = $this->createMockRequest('GET', 'http://localhost:3000');
$response = $this->middleware->handle($request, function () {
return (new Response())->json(['data' => 'test']);
});
$ref = new \ReflectionClass($response);
$headersProp = $ref->getProperty('headers');
$headersProp->setAccessible(true);
$headers = $headersProp->getValue($response);
$this->assertEquals('http://localhost:3000', $headers['Access-Control-Allow-Origin']);
$this->assertEquals('true', $headers['Access-Control-Allow-Credentials']);
}
public function testNoCorsHeadersForDisallowedOrigin(): void
{
$request = $this->createMockRequest('GET', 'http://evil.com');
$response = $this->middleware->handle($request, function () {
return (new Response())->json(['data' => 'test']);
});
$ref = new \ReflectionClass($response);
$headersProp = $ref->getProperty('headers');
$headersProp->setAccessible(true);
$headers = $headersProp->getValue($response);
$this->assertNotEquals('http://evil.com', $headers['Access-Control-Allow-Origin'] ?? '');
}
private function createMockRequest(string $method, ?string $origin): Request
{
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = '/api/v1/test';
$_SERVER['SCRIPT_NAME'] = '/index.php';
if ($origin !== null) {
$_SERVER['HTTP_ORIGIN'] = $origin;
} else {
unset($_SERVER['HTTP_ORIGIN']);
}
$_GET = [];
$_POST = [];
$_FILES = [];
$_COOKIE = [];
return new Request();
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Api;
use Tests\TestCase;
use App\Core\Response;
final class ResponseTest extends TestCase
{
public function testWithHeadersSetsMultipleHeaders(): void
{
$response = new Response();
$response->json(['ok' => true]);
$response->withHeaders([
'X-Custom-One' => 'value1',
'X-Custom-Two' => 'value2',
]);
$ref = new \ReflectionClass($response);
$headersProp = $ref->getProperty('headers');
$headersProp->setAccessible(true);
$headers = $headersProp->getValue($response);
$this->assertEquals('value1', $headers['X-Custom-One']);
$this->assertEquals('value2', $headers['X-Custom-Two']);
}
public function testHeaderMethodChaining(): void
{
$response = new Response();
$result = $response->header('X-Test', 'hello')->header('X-Other', 'world');
$this->assertSame($response, $result);
$ref = new \ReflectionClass($response);
$headersProp = $ref->getProperty('headers');
$headersProp->setAccessible(true);
$headers = $headersProp->getValue($response);
$this->assertEquals('hello', $headers['X-Test']);
$this->assertEquals('world', $headers['X-Other']);
}
public function testJsonSetsContentType(): void
{
$response = new Response();
$response->json(['success' => true], 201);
$ref = new \ReflectionClass($response);
$headersProp = $ref->getProperty('headers');
$headersProp->setAccessible(true);
$statusProp = $ref->getProperty('statusCode');
$statusProp->setAccessible(true);
$headers = $headersProp->getValue($response);
$this->assertEquals('application/json; charset=UTF-8', $headers['Content-Type']);
$this->assertEquals(201, $statusProp->getValue($response));
}
public function testStatusMethodSetsCode(): void
{
$response = new Response();
$response->status(404);
$ref = new \ReflectionClass($response);
$statusProp = $ref->getProperty('statusCode');
$statusProp->setAccessible(true);
$this->assertEquals(404, $statusProp->getValue($response));
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Container;
use Tests\TestCase;
use App\Core\App;
use App\Core\Contracts\RuleEngineInterface;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Contracts\StockManagerInterface;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Core\Contracts\JournalServiceInterface;
use App\Core\Facades\Rules;
use App\Core\Facades\Payment;
use App\Core\Facades\Stock;
use App\Core\Facades\Workflow;
use App\Modules\Rules\RuleEngineAdapter;
use App\Modules\Payments\PaymentProcessorAdapter;
use App\Modules\Inventory\StockManagerAdapter;
use App\Modules\Workflow\WorkflowEngineAdapter;
use App\Modules\Accounting\JournalServiceAdapter;
final class ContainerResolutionTest extends TestCase
{
private App $app;
protected function setUp(): void
{
parent::setUp();
$this->app = App::getInstance();
$this->app->singleton(RuleEngineInterface::class, fn() => new RuleEngineAdapter());
$this->app->singleton(PaymentProcessorInterface::class, fn() => new PaymentProcessorAdapter());
$this->app->singleton(StockManagerInterface::class, fn() => new StockManagerAdapter());
$this->app->singleton(WorkflowEngineInterface::class, fn() => new WorkflowEngineAdapter());
$this->app->singleton(JournalServiceInterface::class, fn() => new JournalServiceAdapter());
}
public function testResolvesRuleEngineInterface(): void
{
$service = $this->app->resolve(RuleEngineInterface::class);
$this->assertInstanceOf(RuleEngineInterface::class, $service);
$this->assertInstanceOf(RuleEngineAdapter::class, $service);
}
public function testResolvesPaymentProcessorInterface(): void
{
$service = $this->app->resolve(PaymentProcessorInterface::class);
$this->assertInstanceOf(PaymentProcessorInterface::class, $service);
$this->assertInstanceOf(PaymentProcessorAdapter::class, $service);
}
public function testResolvesStockManagerInterface(): void
{
$service = $this->app->resolve(StockManagerInterface::class);
$this->assertInstanceOf(StockManagerInterface::class, $service);
$this->assertInstanceOf(StockManagerAdapter::class, $service);
}
public function testResolvesWorkflowEngineInterface(): void
{
$service = $this->app->resolve(WorkflowEngineInterface::class);
$this->assertInstanceOf(WorkflowEngineInterface::class, $service);
$this->assertInstanceOf(WorkflowEngineAdapter::class, $service);
}
public function testResolvesJournalServiceInterface(): void
{
$service = $this->app->resolve(JournalServiceInterface::class);
$this->assertInstanceOf(JournalServiceInterface::class, $service);
$this->assertInstanceOf(JournalServiceAdapter::class, $service);
}
public function testSingletonReturnsSameInstance(): void
{
$first = $this->app->resolve(RuleEngineInterface::class);
$second = $this->app->resolve(RuleEngineInterface::class);
$this->assertSame($first, $second);
}
public function testFacadeResolvesFromContainer(): void
{
$instance = Rules::instance();
$this->assertInstanceOf(RuleEngineInterface::class, $instance);
}
public function testPaymentFacadeResolvesFromContainer(): void
{
$instance = Payment::instance();
$this->assertInstanceOf(PaymentProcessorInterface::class, $instance);
}
public function testStockFacadeResolvesFromContainer(): void
{
$instance = Stock::instance();
$this->assertInstanceOf(StockManagerInterface::class, $instance);
}
public function testWorkflowFacadeResolvesFromContainer(): void
{
$instance = Workflow::instance();
$this->assertInstanceOf(WorkflowEngineInterface::class, $instance);
}
public function testBindOverridesSingleton(): void
{
$mock = new class implements RuleEngineInterface {
public function get(string $ruleCode, ?int $branchId = null, ?string $date = null): mixed { return 'mocked'; }
public function getValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed { return 'mocked'; }
public function getAll(string $category, ?int $branchId = null): array { return []; }
public function getEffective(string $ruleCode, string $entityType, int $entityId, ?int $branchId = null): mixed { return 'mocked'; }
public function require(string $ruleCode, ?int $branchId = null): array { return []; }
public function requireValue(string $ruleCode, string $key = 'value', ?int $branchId = null): mixed { return 'mocked'; }
};
$this->app->bind(RuleEngineInterface::class, $mock);
$resolved = $this->app->resolve(RuleEngineInterface::class);
$this->assertSame($mock, $resolved);
}
public function testResolveReturnsDefaultWhenNotBound(): void
{
$result = $this->app->resolve('nonexistent.service', 'default_value');
$this->assertEquals('default_value', $result);
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Contracts;
use Tests\TestCase;
use App\Core\Contracts\RuleEngineInterface;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Contracts\StockManagerInterface;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Core\Contracts\JournalServiceInterface;
use App\Core\Contracts\EventDispatcherInterface;
use App\Modules\Rules\RuleEngineAdapter;
use App\Modules\Payments\PaymentProcessorAdapter;
use App\Modules\Inventory\StockManagerAdapter;
use App\Modules\Workflow\WorkflowEngineAdapter;
use App\Modules\Accounting\JournalServiceAdapter;
final class ContractComplianceTest extends TestCase
{
public function testRuleEngineAdapterImplementsInterface(): void
{
$adapter = new RuleEngineAdapter();
$this->assertInstanceOf(RuleEngineInterface::class, $adapter);
}
public function testPaymentProcessorAdapterImplementsInterface(): void
{
$adapter = new PaymentProcessorAdapter();
$this->assertInstanceOf(PaymentProcessorInterface::class, $adapter);
}
public function testStockManagerAdapterImplementsInterface(): void
{
$adapter = new StockManagerAdapter();
$this->assertInstanceOf(StockManagerInterface::class, $adapter);
}
public function testWorkflowEngineAdapterImplementsInterface(): void
{
$adapter = new WorkflowEngineAdapter();
$this->assertInstanceOf(WorkflowEngineInterface::class, $adapter);
}
public function testJournalServiceAdapterImplementsInterface(): void
{
$adapter = new JournalServiceAdapter();
$this->assertInstanceOf(JournalServiceInterface::class, $adapter);
}
public function testInterfacesDefineExpectedMethods(): void
{
$ruleEngine = new \ReflectionClass(RuleEngineInterface::class);
$this->assertTrue($ruleEngine->hasMethod('get'));
$this->assertTrue($ruleEngine->hasMethod('getValue'));
$this->assertTrue($ruleEngine->hasMethod('getAll'));
$this->assertTrue($ruleEngine->hasMethod('getEffective'));
$this->assertTrue($ruleEngine->hasMethod('require'));
$this->assertTrue($ruleEngine->hasMethod('requireValue'));
$payment = new \ReflectionClass(PaymentProcessorInterface::class);
$this->assertTrue($payment->hasMethod('processPayment'));
$this->assertTrue($payment->hasMethod('voidPayment'));
$this->assertTrue($payment->hasMethod('hasPaid'));
$this->assertTrue($payment->hasMethod('totalPaid'));
$stock = new \ReflectionClass(StockManagerInterface::class);
$this->assertTrue($stock->hasMethod('moveStock'));
$this->assertTrue($stock->hasMethod('getAvailableQuantity'));
$this->assertTrue($stock->hasMethod('getTotalStock'));
$workflow = new \ReflectionClass(WorkflowEngineInterface::class);
$this->assertTrue($workflow->hasMethod('createInstance'));
$this->assertTrue($workflow->hasMethod('transition'));
$this->assertTrue($workflow->hasMethod('getCurrentState'));
$journal = new \ReflectionClass(JournalServiceInterface::class);
$this->assertTrue($journal->hasMethod('createEntry'));
$this->assertTrue($journal->hasMethod('postEntry'));
$this->assertTrue($journal->hasMethod('reverseEntry'));
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Events;
use Tests\TestCase;
use App\Core\Events\PaymentCompleted;
use App\Core\Events\PaymentVoided;
use App\Core\Events\SaleCompleted;
use App\Core\Events\SaleVoided;
use App\Core\Events\FineImposed;
use App\Core\Events\FinePaid;
use App\Core\Events\SubscriptionPaid;
use App\Core\Events\InstallmentPaid;
use App\Core\Events\InstallmentPlanCreated;
use App\Core\Events\PayrollPaid;
use App\Core\Events\MemberCreated;
use App\Core\Events\MemberActivated;
use App\Core\EventBus;
final class EventSerializationTest extends TestCase
{
public function testPaymentCompletedRoundTrip(): void
{
$event = new PaymentCompleted(
paymentId: 42,
receiptId: 100,
receiptNumber: 'REC-2026-001',
memberId: 7,
paymentType: 'annual_subscription',
amount: '527.00',
method: 'cash',
);
$this->assertEquals('payment.completed', $event::eventName());
$array = $event->toArray();
$this->assertEquals(42, $array['payment_id']);
$this->assertEquals(100, $array['receipt_id']);
$this->assertEquals('REC-2026-001', $array['receipt_number']);
$this->assertEquals(7, $array['member_id']);
$this->assertEquals('annual_subscription', $array['payment_type']);
$this->assertEquals('annual_subscription', $array['type']);
$this->assertEquals('527.00', $array['amount']);
$this->assertEquals('cash', $array['method']);
$restored = PaymentCompleted::from($array);
$this->assertEquals($event->paymentId, $restored->paymentId);
$this->assertEquals($event->amount, $restored->amount);
$this->assertEquals($event->method, $restored->method);
}
public function testPaymentVoidedRoundTrip(): void
{
$event = new PaymentVoided(
paymentId: 10,
memberId: 5,
paymentType: 'fine',
amount: '1000.00',
reason: 'خطأ في التسجيل',
);
$this->assertEquals('payment.voided', $event::eventName());
$restored = PaymentVoided::from($event->toArray());
$this->assertEquals(10, $restored->paymentId);
$this->assertEquals('خطأ في التسجيل', $restored->reason);
}
public function testSaleCompletedRoundTrip(): void
{
$event = new SaleCompleted(
saleId: 33,
invoiceNumber: 'INV-2026-050',
customerType: 'member',
totalAmount: '250.00',
);
$this->assertEquals('sale.completed', $event::eventName());
$restored = SaleCompleted::from($event->toArray());
$this->assertEquals(33, $restored->saleId);
$this->assertEquals('INV-2026-050', $restored->invoiceNumber);
}
public function testSaleVoidedRoundTrip(): void
{
$event = new SaleVoided(saleId: 33, reason: 'إرجاع');
$this->assertEquals('sale.voided', $event::eventName());
$restored = SaleVoided::from($event->toArray());
$this->assertEquals(33, $restored->saleId);
$this->assertEquals('إرجاع', $restored->reason);
}
public function testFineImposedRoundTrip(): void
{
$event = new FineImposed(
fineId: 8,
memberId: 12,
penaltyType: 'financial',
amount: '5000.00',
);
$this->assertEquals('fine.imposed', $event::eventName());
$restored = FineImposed::from($event->toArray());
$this->assertEquals(8, $restored->fineId);
$this->assertEquals('financial', $restored->penaltyType);
}
public function testFinePaidRoundTrip(): void
{
$event = new FinePaid(fineId: 8, memberId: 12, amount: '5000.00');
$this->assertEquals('fine.paid', $event::eventName());
$restored = FinePaid::from($event->toArray());
$this->assertEquals(8, $restored->fineId);
}
public function testSubscriptionPaidRoundTrip(): void
{
$event = new SubscriptionPaid(
subscriptionId: 200,
memberId: 15,
year: '2026',
amount: '527.00',
);
$this->assertEquals('subscription.paid', $event::eventName());
$restored = SubscriptionPaid::from($event->toArray());
$this->assertEquals(200, $restored->subscriptionId);
$this->assertEquals('2026', $restored->year);
}
public function testInstallmentPaidRoundTrip(): void
{
$event = new InstallmentPaid(planId: 5, scheduleId: 12, memberId: 3);
$this->assertEquals('installment.paid', $event::eventName());
$restored = InstallmentPaid::from($event->toArray());
$this->assertEquals(5, $restored->planId);
$this->assertEquals(12, $restored->scheduleId);
}
public function testInstallmentPlanCreatedRoundTrip(): void
{
$event = new InstallmentPlanCreated(planId: 5, memberId: 3, totalAmount: '150000.00');
$this->assertEquals('installment.plan_created', $event::eventName());
$restored = InstallmentPlanCreated::from($event->toArray());
$this->assertEquals(5, $restored->planId);
$this->assertEquals('150000.00', $restored->totalAmount);
}
public function testPayrollPaidRoundTrip(): void
{
$event = new PayrollPaid(payrollRunId: 1, periodId: 6, paymentDate: '2026-05-01');
$this->assertEquals('hr.payroll.paid', $event::eventName());
$restored = PayrollPaid::from($event->toArray());
$this->assertEquals(1, $restored->payrollRunId);
$this->assertEquals('2026-05-01', $restored->paymentDate);
}
public function testMemberCreatedRoundTrip(): void
{
$event = new MemberCreated(
memberId: 99,
fullNameAr: 'أحمد محمد',
status: 'potential',
branchId: 1,
);
$this->assertEquals('member.created', $event::eventName());
$array = $event->toArray();
$this->assertEquals(99, $array['id']);
$this->assertEquals(99, $array['member_id']);
$restored = MemberCreated::from($array);
$this->assertEquals(99, $restored->memberId);
$this->assertEquals('أحمد محمد', $restored->fullNameAr);
}
public function testMemberActivatedRoundTrip(): void
{
$event = new MemberActivated(memberId: 99, membershipNumber: 'MEM-0050');
$this->assertEquals('member.activated', $event::eventName());
$restored = MemberActivated::from($event->toArray());
$this->assertEquals(99, $restored->memberId);
$this->assertEquals('MEM-0050', $restored->membershipNumber);
}
public function testDispatchEventIntegration(): void
{
$event = new PaymentCompleted(
paymentId: 1,
receiptId: 1,
receiptNumber: 'TEST-001',
memberId: 1,
paymentType: 'fine',
amount: '100.00',
method: 'cash',
);
EventBus::dispatchEvent($event);
$this->assertEventDispatched('payment.completed', function (array $data) {
$this->assertEquals(1, $data['payment_id']);
$this->assertEquals('100.00', $data['amount']);
$this->assertEquals('fine', $data['payment_type']);
$this->assertEquals('fine', $data['type']);
});
}
public function testFromHandlesMissingKeys(): void
{
$event = PaymentCompleted::from([]);
$this->assertEquals(0, $event->paymentId);
$this->assertEquals(0, $event->memberId);
$this->assertEquals('0.00', $event->amount);
$this->assertEquals('cash', $event->method);
}
}
<?php
declare(strict_types=1);
namespace Tests\Unit\Isolation;
use Tests\TestCase;
use App\Core\App;
use App\Core\ModuleRegistry;
use App\Core\Contracts\PaymentProcessorInterface;
use App\Core\Contracts\RuleEngineInterface;
use App\Core\Contracts\StockManagerInterface;
use App\Core\Contracts\WorkflowEngineInterface;
use App\Core\Facades\Payment;
use App\Core\Facades\Rules;
use App\Core\Facades\Stock;
use App\Core\Facades\Workflow;
use App\Core\DTO\PaymentRequest;
use App\Core\DTO\PaymentResult;
use App\Core\DTO\StockMovementRequest;
use App\Core\DTO\StockMovementResult;
use App\Core\DTO\JournalEntryRequest;
use App\Modules\Rules\RuleEngineAdapter;
use App\Modules\Payments\PaymentProcessorAdapter;
use App\Modules\Inventory\StockManagerAdapter;
use App\Modules\Workflow\WorkflowEngineAdapter;
final class CrossModuleTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$app = App::getInstance();
$app->singleton(RuleEngineInterface::class, fn() => new RuleEngineAdapter());
$app->singleton(PaymentProcessorInterface::class, fn() => new PaymentProcessorAdapter());
$app->singleton(StockManagerInterface::class, fn() => new StockManagerAdapter());
$app->singleton(WorkflowEngineInterface::class, fn() => new WorkflowEngineAdapter());
}
public function testModuleRegistryTracksModules(): void
{
ModuleRegistry::register('test_module', [
'services' => ['TestInterface'],
'description' => 'Test module',
]);
$this->assertTrue(ModuleRegistry::has('test_module'));
$this->assertEquals(['TestInterface'], ModuleRegistry::provides('test_module')['services']);
}
public function testPaymentRequestDto(): void
{
$request = new PaymentRequest(
memberId: 5,
amount: '527.00',
paymentType: 'annual_subscription',
paymentMethod: 'cash',
);
$array = $request->toArray();
$this->assertEquals(5, $array['member_id']);
$this->assertEquals('527.00', $array['amount']);
$this->assertEquals('annual_subscription', $array['payment_type']);
$this->assertEquals('cash', $array['payment_method']);
$this->assertArrayNotHasKey('check_number', $array);
}
public function testPaymentRequestDtoWithOptionalFields(): void
{
$request = new PaymentRequest(
memberId: 5,
amount: '10000.00',
paymentType: 'membership_fee',
paymentMethod: 'check',
checkNumber: 'CHK-999',
checkBank: 'بنك مصر',
);
$array = $request->toArray();
$this->assertEquals('CHK-999', $array['check_number']);
$this->assertEquals('بنك مصر', $array['check_bank']);
}
public function testPaymentResultSuccess(): void
{
$result = PaymentResult::ok(42, 100, 'REC-2026-001', '527.00');
$this->assertTrue($result->success);
$this->assertEquals(42, $result->paymentId);
$this->assertEquals(100, $result->receiptId);
$this->assertEquals('REC-2026-001', $result->receiptNumber);
$this->assertEquals('527.00', $result->amount);
$this->assertNull($result->error);
}
public function testPaymentResultFailure(): void
{
$result = PaymentResult::fail('المبلغ يجب أن يكون أكبر من صفر');
$this->assertFalse($result->success);
$this->assertNull($result->paymentId);
$this->assertEquals('المبلغ يجب أن يكون أكبر من صفر', $result->error);
}
public function testPaymentResultFromArray(): void
{
$successResult = PaymentResult::fromArray([
'success' => true,
'payment_id' => 10,
'receipt_id' => 20,
'receipt_number' => 'REC-001',
'amount' => '100.00',
]);
$this->assertTrue($successResult->success);
$this->assertEquals(10, $successResult->paymentId);
$failResult = PaymentResult::fromArray([
'success' => false,
'error' => 'some error',
]);
$this->assertFalse($failResult->success);
$this->assertEquals('some error', $failResult->error);
}
public function testStockMovementRequestDto(): void
{
$request = new StockMovementRequest(
itemId: 1,
warehouseId: 2,
movementType: 'purchase',
direction: 'in',
quantity: '50.00',
unitCost: '25.00',
referenceType: 'purchase_order',
referenceId: 33,
);
$array = $request->toArray();
$this->assertEquals(1, $array['item_id']);
$this->assertEquals(2, $array['warehouse_id']);
$this->assertEquals('purchase', $array['movement_type']);
$this->assertEquals('in', $array['direction']);
$this->assertEquals('50.00', $array['quantity']);
$this->assertEquals('25.00', $array['unit_cost']);
$this->assertArrayNotHasKey('notes', $array);
}
public function testStockMovementResultDto(): void
{
$ok = StockMovementResult::ok(77);
$this->assertTrue($ok->success);
$this->assertEquals(77, $ok->movementId);
$fail = StockMovementResult::fail('Insufficient stock');
$this->assertFalse($fail->success);
$this->assertEquals('Insufficient stock', $fail->error);
}
public function testJournalEntryRequestDto(): void
{
$request = new JournalEntryRequest(
entryDate: '2026-05-13',
descriptionAr: 'تحصيل اشتراك سنوي',
lines: [
['account_id' => 1, 'debit' => '527.00', 'credit' => '0.00'],
['account_id' => 2, 'debit' => '0.00', 'credit' => '527.00'],
],
sourceModule: 'payments',
isAutoGenerated: true,
autoPost: true,
);
$header = $request->toHeaderArray();
$this->assertEquals('2026-05-13', $header['entry_date']);
$this->assertEquals('تحصيل اشتراك سنوي', $header['description_ar']);
$this->assertEquals('payments', $header['source_module']);
$this->assertEquals(1, $header['is_auto_generated']);
$lines = $request->toLines();
$this->assertCount(2, $lines);
$this->assertEquals('527.00', $lines[0]['debit']);
}
public function testFacadesResolveCorrectTypes(): void
{
$this->assertInstanceOf(RuleEngineInterface::class, Rules::instance());
$this->assertInstanceOf(PaymentProcessorInterface::class, Payment::instance());
$this->assertInstanceOf(StockManagerInterface::class, Stock::instance());
$this->assertInstanceOf(WorkflowEngineInterface::class, Workflow::instance());
}
public function testMockCanReplaceRealService(): void
{
$mock = new class implements PaymentProcessorInterface {
public function processPayment(array $data): array { return ['success' => true, 'payment_id' => 999, 'receipt_id' => 888, 'receipt_number' => 'MOCK-001', 'amount' => $data['amount']]; }
public function voidPayment(int $paymentId, string $reason): array { return ['success' => true]; }
public function hasPaid(int $memberId, string $paymentType, ?string $relatedEntityType = null, ?int $relatedEntityId = null): bool { return true; }
public function totalPaid(int $memberId, ?string $paymentType = null): string { return '9999.00'; }
public function generateReceiptNumber(): string { return 'MOCK-REC'; }
public function getPaymentTypeLabel(string $type): string { return 'Mock Label'; }
};
App::getInstance()->bind(PaymentProcessorInterface::class, $mock);
$result = Payment::processPayment(['amount' => '100.00']);
$this->assertEquals(999, $result['payment_id']);
$this->assertEquals('100.00', $result['amount']);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment