diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3e0aa4d..5fa508d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,33 +1,19 @@ { "permissions": { "allow": [ - "Bash(powershell -Command \"Compress-Archive -Path ''*'' -DestinationPath ''../ver_0.234.zip'' -Force\")", - "Bash(powershell -Command:*)", - "Bash(ls -la \"c:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\updates\\\\0.20\"\" | grep \"ver_ \")", - "Bash(C:/xampp/php/php.exe:*)", - "Bash(where:*)", - "Bash(composer:*)", - "Bash(curl:*)", -<<<<<<< HEAD - "Bash(C:xamppphpphp.exe phpunit.phar --testdox)", + "Bash(powershell -Command \"Compress-Archive -Path ''*'' -DestinationPath ''../../updates/0.20/ver_0.295.zip'' -Force\")", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", "Bash(php phpunit.phar:*)", - "Bash(ls -la \"c:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\autoload\"\" 2>nul | findstr /i \"admin Admin \")", - "Bash(php -r:*)", - "Bash(php list_zip.php:*)", - "Bash(php create_update_239.php:*)", - "Bash(php vendor/bin/phpunit:*)", - "Bash(python:*)", - "Bash(\"C:/Program Files/7-Zip/7z.exe\" l \"updates/0.20/ver_0.239.zip\")", - "Bash(powershell.exe -Command \"[System.IO.Compression.ZipFile]::OpenRead\\(''c:/visual studio code/projekty/shopPRO/updates/0.20/ver_0.239.zip''\\).Entries | ForEach-Object { Write-Host $_.FullName }\")", - "Bash(powershell.exe -Command \"Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::OpenRead\\(''c:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\updates\\\\0.20\\\\ver_0.239.zip''\\).Entries | ForEach-Object { Write-Host $_.FullName }\")", - "Bash(powershell.exe -NoProfile -Command 'Add-Type -AssemblyName System.IO.Compression.FileSystem; $z = [System.IO.Compression.ZipFile]::OpenRead\\(\"\"c:\\\\visual studio code\\\\projekty\\\\shopPRO\\\\updates\\\\0.20\\\\ver_0.239.zip\"\"\\); foreach \\($e in $z.Entries\\) { $e.FullName }; $z.Dispose\\(\\)')", - "Bash(powershell.exe -NoProfile -Command:*)" -======= - "Bash(find:*)", - "Bash(php:*)", - "Bash(C:xamppphpphp.exe -v)", - "Bash(/c/xampp/php/php.exe:*)" ->>>>>>> 471173f45b4ff731f785fbcf8fdc0483af3b4e54 + "Bash(powershell -Command \"Compress-Archive -Path ''*'' -DestinationPath ''../../updates/0.20/ver_0.296.zip'' -Force\")", + "Bash(ls:*)", + "Bash(git -C \"C:/visual studio code/projekty/shopPRO\" rev-parse --show-toplevel)", + "Bash(powershell -File:*)", + "Bash(git status:*)", + "Bash(powershell -Command \"& { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::OpenRead\\(''updates/0.20/ver_0.296.zip''\\).Entries | ForEach-Object { Write-Output $_.FullName } }\")", + "Bash(powershell -Command \"Compress-Archive -Path ''*'' -DestinationPath ''../ver_0.296.zip'' -Force\")", + "Bash(powershell -Command \"Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::OpenRead\\(\\(Resolve-Path ''updates/0.20/ver_0.296.zip''\\)\\).Entries.FullName\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 976fc21..8f7de71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ composer test PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. -Current suite: **636 tests, 1868 assertions**. +Current suite: **666 tests, 1930 assertions**. ### Creating Updates See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. @@ -55,6 +55,9 @@ shopPRO/ │ │ ├── Html/ # Html utility │ │ ├── Image/ # ImageManipulator │ │ └── Tpl/ # Template engine +│ ├── api/ # REST API layer (\api\) +│ │ ├── ApiRouter.php # API router (\api\ApiRouter) +│ │ └── Controllers/ # API controllers (\api\Controllers\) │ ├── admin/ # Admin panel layer │ │ ├── App.php # Admin router (\admin\App) │ │ ├── Controllers/ # DI controllers (\admin\Controllers\) — 28 controllers @@ -76,7 +79,8 @@ shopPRO/ │ ├── stubs/ # Test stubs (CacheHandler, Helpers, ShopProduct) │ └── Unit/ │ ├── Domain/ # Repository tests -│ └── admin/Controllers/ # Controller tests +│ ├── admin/Controllers/ # Controller tests +│ └── api/ # API tests ├── updates/ # Update packages for clients ├── docs/ # Technical documentation ├── config.php # Database/Redis config (not in repo) @@ -85,7 +89,7 @@ shopPRO/ ├── admin/index.php # Admin entry point ├── admin/ajax.php # Admin AJAX handler ├── cron.php # CRON jobs (Apilo sync) -└── api.php # REST API +└── api.php # REST API (ordersPRO + Ekomi) ``` ### Autoloader @@ -99,6 +103,7 @@ Custom autoloader in each entry point (not Composer autoload at runtime). Tries - `\admin\Controllers\` → `autoload/admin/Controllers/` (lowercase a) - `\Shared\` → `autoload/Shared/` - `\front\` → `autoload/front/` +- `\api\` → `autoload/api/` - Do NOT use `\Admin\` (uppercase A) — the server directory is `admin/` (lowercase) - `\shop\` namespace is **deleted** — all 12 legacy classes migrated to `\Domain\`, `autoload/shop/` directory removed @@ -124,6 +129,11 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro **Frontend Views** (`autoload/front/Views/`): - Static classes, no state, no DI — pure rendering +**API Controllers** (`autoload/api/Controllers/`): +- DI via constructor, stateless (no session) +- Wired in `api\ApiRouter::getControllerFactories()` +- Auth: `X-Api-Key` header vs `pp_settings.api_key` + ### Key Classes | Class | Purpose | |-------|---------| @@ -133,6 +143,7 @@ All legacy directories (`admin/controls/`, `admin/factory/`, `admin/view/`, `fro | `\Shared\Helpers\Helpers` | Utility methods (SEO, email, cache clearing) | | `\Shared\Tpl\Tpl` | Template engine — `render()`, `set()` | | `\Shared\Cache\CacheHandler` | Redis cache — `get()`, `set()`, `delete()`, `deletePattern()` | +| `\api\ApiRouter` | REST API router — auth, routing, response helpers | ### Database - ORM: Medoo (`$mdb` global variable, injected via DI in new code) @@ -210,4 +221,5 @@ Before starting implementation, review current state of docs (see AGENTS.md for - `docs/TESTING.md` — test suite guide and structure - `docs/FORM_EDIT_SYSTEM.md` — form system architecture - `docs/CHANGELOG.md` — version history +- `docs/API.md` — REST API documentation (ordersPRO) - `docs/UPDATE_INSTRUCTIONS.md` — how to build client update packages diff --git a/api.php b/api.php index 35ec513..d8f9e1d 100644 --- a/api.php +++ b/api.php @@ -25,20 +25,26 @@ require_once 'libraries/medoo/medoo.php'; require_once 'libraries/phpmailer/class.phpmailer.php'; require_once 'libraries/phpmailer/class.smtp.php'; -session_start(); +// Detect API request (stateless, no session) +$isApiRequest = isset( $_GET['endpoint'] ); -if ( !isset( $_SESSION[ 'check' ] ) ) +if ( !$isApiRequest ) { - session_regenerate_id(); - $_SESSION[ 'check' ] = true; - $_SESSION[ 'ip' ] = $_SERVER[ 'REMOTE_ADDR' ]; -} + session_start(); -if ( $_SESSION[ 'ip' ] !== $_SERVER[ 'REMOTE_ADDR' ] ) -{ - session_destroy(); - header( 'Location: /' ); - exit; + if ( !isset( $_SESSION[ 'check' ] ) ) + { + session_regenerate_id(); + $_SESSION[ 'check' ] = true; + $_SESSION[ 'ip' ] = $_SERVER[ 'REMOTE_ADDR' ]; + } + + if ( $_SESSION[ 'ip' ] !== $_SERVER[ 'REMOTE_ADDR' ] ) + { + session_destroy(); + header( 'Location: /' ); + exit; + } } $mdb = new medoo( [ @@ -50,8 +56,18 @@ $mdb = new medoo( [ 'charset' => 'utf8' ] ); -$settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings(); +$settingsRepo = new \Domain\Settings\SettingsRepository( $mdb ); +$settings = $settingsRepo->allSettings(); +// --- API routing (ordersPRO) --- +if ( $isApiRequest ) +{ + $router = new \api\ApiRouter( $mdb, $settingsRepo ); + $router->handle(); + exit; +} + +// --- Ekomi CSV export --- if ( \Shared\Helpers\Helpers::get( 'ekomi_csv' ) ) { $csv_array = [ [ 'ORDER_ID', 'MAIL', 'FIRST_NAME', 'LAST_NAME', 'PRODUCT_ID', 'PRODUCT_NAME' ] ]; @@ -84,4 +100,4 @@ if ( \Shared\Helpers\Helpers::get( 'ekomi_csv' ) ) fclose( $fp ); } -} \ No newline at end of file +} diff --git a/autoload/Domain/Order/OrderRepository.php b/autoload/Domain/Order/OrderRepository.php index 3c8c2a8..99b2164 100644 --- a/autoload/Domain/Order/OrderRepository.php +++ b/autoload/Domain/Order/OrderRepository.php @@ -311,6 +311,7 @@ class OrderRepository } $this->db->update('pp_shop_orders', ['notes' => $notes], ['id' => $orderId]); + $this->touchUpdatedAt($orderId); return true; } @@ -370,6 +371,8 @@ class OrderRepository 'id' => $orderId, ]); + $this->touchUpdatedAt($orderId); + return true; } @@ -688,6 +691,7 @@ class OrderRepository 'coupon_id' => $coupon ? $coupon->id : null, 'message' => $basket_message ? $basket_message : null, 'apilo_order_status_date' => date('Y-m-d H:i:s'), + 'updated_at' => $order_date, ]); $order_id = $this->db->id(); @@ -832,16 +836,22 @@ class OrderRepository public function setAsPaid(int $orderId): void { $this->db->update('pp_shop_orders', ['paid' => 1], ['id' => $orderId]); + $this->touchUpdatedAt($orderId); } public function setAsUnpaid(int $orderId): void { $this->db->update('pp_shop_orders', ['paid' => 0], ['id' => $orderId]); + $this->touchUpdatedAt($orderId); } public function updateOrderStatus(int $orderId, int $status): bool { - return (bool)$this->db->update('pp_shop_orders', ['status' => $status], ['id' => $orderId]); + $result = (bool)$this->db->update('pp_shop_orders', ['status' => $status], ['id' => $orderId]); + if ($result) { + $this->touchUpdatedAt($orderId); + } + return $result; } public function insertStatusHistory(int $orderId, int $statusId, int $mail): void @@ -858,6 +868,145 @@ class OrderRepository $this->db->update('pp_shop_orders', ['apilo_order_status_date' => $date], ['id' => $orderId]); } + // ========================================================================= + // API methods (for ordersPRO) + // ========================================================================= + + public function listForApi(array $filters, int $page = 1, int $perPage = 50): array + { + $page = max(1, $page); + $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); + $offset = ($page - 1) * $perPage; + + $where = []; + $params = []; + + $status = trim((string)($filters['status'] ?? '')); + if ($status !== '' && is_numeric($status)) { + $where[] = 'o.status = :status'; + $params[':status'] = (int)$status; + } + + $paid = trim((string)($filters['paid'] ?? '')); + if ($paid !== '' && is_numeric($paid)) { + $where[] = 'o.paid = :paid'; + $params[':paid'] = (int)$paid; + } + + $dateFrom = $this->normalizeDateFilter($filters['date_from'] ?? ''); + if ($dateFrom !== null) { + $where[] = 'o.date_order >= :date_from'; + $params[':date_from'] = $dateFrom . ' 00:00:00'; + } + + $dateTo = $this->normalizeDateFilter($filters['date_to'] ?? ''); + if ($dateTo !== null) { + $where[] = 'o.date_order <= :date_to'; + $params[':date_to'] = $dateTo . ' 23:59:59'; + } + + $updatedSince = trim((string)($filters['updated_since'] ?? '')); + if ($updatedSince !== '' && preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/', $updatedSince)) { + $where[] = 'o.updated_at >= :updated_since'; + $params[':updated_since'] = $updatedSince; + } + + $number = $this->normalizeTextFilter($filters['number'] ?? ''); + if ($number !== '') { + $where[] = 'o.number LIKE :number'; + $params[':number'] = '%' . $number . '%'; + } + + $client = $this->normalizeTextFilter($filters['client'] ?? ''); + if ($client !== '') { + $where[] = "(o.client_name LIKE :client OR o.client_surname LIKE :client2 OR o.client_email LIKE :client3)"; + $params[':client'] = '%' . $client . '%'; + $params[':client2'] = '%' . $client . '%'; + $params[':client3'] = '%' . $client . '%'; + } + + $whereSql = ''; + if (!empty($where)) { + $whereSql = ' WHERE ' . implode(' AND ', $where); + } + + $sqlCount = 'SELECT COUNT(0) FROM pp_shop_orders AS o' . $whereSql; + $stmtCount = $this->db->query($sqlCount, $params); + $countRows = $stmtCount ? $stmtCount->fetchAll() : []; + $total = 0; + if (is_array($countRows) && isset($countRows[0]) && is_array($countRows[0])) { + $firstValue = reset($countRows[0]); + $total = $firstValue !== false ? (int)$firstValue : 0; + } + + $sql = 'SELECT o.id, o.number, o.date_order, o.updated_at, o.status, o.paid,' + . ' o.client_name, o.client_surname, o.client_email, o.client_phone,' + . ' o.client_street, o.client_postal_code, o.client_city,' + . ' o.firm_name, o.firm_nip,' + . ' o.transport, o.transport_cost, o.payment_method, o.summary' + . ' FROM pp_shop_orders AS o' + . $whereSql + . ' ORDER BY o.updated_at DESC, o.id DESC' + . ' LIMIT ' . $perPage . ' OFFSET ' . $offset; + + $stmt = $this->db->query($sql, $params); + $items = ($stmt) ? $stmt->fetchAll() : []; + if (!is_array($items)) { + $items = []; + } + + foreach ($items as &$item) { + $item['id'] = (int)($item['id'] ?? 0); + $item['status'] = (int)($item['status'] ?? 0); + $item['paid'] = (int)($item['paid'] ?? 0); + $item['summary'] = (float)($item['summary'] ?? 0); + $item['transport_cost'] = (float)($item['transport_cost'] ?? 0); + } + unset($item); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]; + } + + public function findForApi(int $orderId): ?array + { + if ($orderId <= 0) { + return null; + } + + $order = $this->db->get('pp_shop_orders', '*', ['id' => $orderId]); + if (!is_array($order)) { + return null; + } + + $order['id'] = (int)($order['id'] ?? 0); + $order['status'] = (int)($order['status'] ?? 0); + $order['paid'] = (int)($order['paid'] ?? 0); + $order['summary'] = (float)($order['summary'] ?? 0); + $order['transport_cost'] = (float)($order['transport_cost'] ?? 0); + $order['products'] = $this->orderProducts($orderId); + $order['statuses'] = $this->orderStatusHistory($orderId); + + return $order; + } + + public function touchUpdatedAt(int $orderId): void + { + if ($orderId <= 0) { + return; + } + + $this->db->update('pp_shop_orders', [ + 'updated_at' => date('Y-m-d H:i:s'), + ], [ + 'id' => $orderId, + ]); + } + private function nullableString(string $value): ?string { $value = trim($value); diff --git a/autoload/admin/Controllers/SettingsController.php b/autoload/admin/Controllers/SettingsController.php index d148966..7d6f2fb 100644 --- a/autoload/admin/Controllers/SettingsController.php +++ b/autoload/admin/Controllers/SettingsController.php @@ -444,6 +444,10 @@ class SettingsController 'label' => 'Htaccess cache', 'tab' => 'system', ]), + FormField::text('api_key', [ + 'label' => 'Klucz API (ordersPRO)', + 'tab' => 'system', + ]), FormField::text('google_tag_manager_id', [ 'label' => 'Google Tag Manager - ID', diff --git a/autoload/api/ApiRouter.php b/autoload/api/ApiRouter.php new file mode 100644 index 0000000..248dd67 --- /dev/null +++ b/autoload/api/ApiRouter.php @@ -0,0 +1,144 @@ +db = $db; + $this->settingsRepo = $settingsRepo; + } + + public function handle(): void + { + if (!headers_sent()) { + header('Content-Type: application/json; charset=utf-8'); + } + + try { + if (!$this->authenticate()) { + self::sendError('UNAUTHORIZED', 'Invalid or missing API key', 401); + return; + } + + $endpoint = trim((string)($_GET['endpoint'] ?? '')); + $action = trim((string)($_GET['action'] ?? '')); + + if ($endpoint === '' || $action === '') { + self::sendError('BAD_REQUEST', 'Missing endpoint or action parameter', 400); + return; + } + + $controller = $this->resolveController($endpoint); + if ($controller === null) { + self::sendError('NOT_FOUND', 'Unknown endpoint: ' . $endpoint, 404); + return; + } + + if (!method_exists($controller, $action)) { + self::sendError('NOT_FOUND', 'Unknown action: ' . $action, 404); + return; + } + + $controller->$action(); + } catch (\Exception $e) { + self::sendError('INTERNAL_ERROR', 'Internal server error', 500); + } + } + + private function authenticate(): bool + { + $headerKey = isset($_SERVER['HTTP_X_API_KEY']) ? $_SERVER['HTTP_X_API_KEY'] : ''; + if ($headerKey === '') { + return false; + } + + $storedKey = $this->settingsRepo->getSingleValue('api_key'); + if ($storedKey === '') { + return false; + } + + return hash_equals($storedKey, $headerKey); + } + + private function resolveController(string $endpoint) + { + $factories = $this->getControllerFactories(); + + if (!isset($factories[$endpoint])) { + return null; + } + + return $factories[$endpoint](); + } + + private function getControllerFactories(): array + { + $db = $this->db; + + return [ + 'orders' => function () use ($db) { + $orderRepo = new \Domain\Order\OrderRepository($db); + $settingsRepo = new \Domain\Settings\SettingsRepository($db); + $productRepo = new \Domain\Product\ProductRepository($db); + $transportRepo = new \Domain\Transport\TransportRepository($db); + $service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo); + return new Controllers\OrdersApiController($service, $orderRepo); + }, + 'dictionaries' => function () use ($db) { + $statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db); + $transportRepo = new \Domain\Transport\TransportRepository($db); + $paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db); + return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo); + }, + ]; + } + + // ========================================================================= + // Static response helpers + // ========================================================================= + + public static function sendSuccess($data): void + { + http_response_code(200); + echo json_encode(['status' => 'ok', 'data' => $data], JSON_UNESCAPED_UNICODE); + } + + public static function sendError(string $code, string $message, int $httpCode = 400): void + { + http_response_code($httpCode); + echo json_encode([ + 'status' => 'error', + 'code' => $code, + 'message' => $message, + ], JSON_UNESCAPED_UNICODE); + } + + public static function getJsonBody(): ?array + { + $raw = file_get_contents('php://input'); + if ($raw === '' || $raw === false) { + return null; + } + + $data = json_decode($raw, true); + + return is_array($data) ? $data : null; + } + + public static function requireMethod(string $method): bool + { + $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; + if ($requestMethod !== strtoupper($method)) { + self::sendError('METHOD_NOT_ALLOWED', 'Method ' . $requestMethod . ' not allowed, expected ' . strtoupper($method), 405); + return false; + } + + return true; + } +} diff --git a/autoload/api/Controllers/DictionariesApiController.php b/autoload/api/Controllers/DictionariesApiController.php new file mode 100644 index 0000000..5ed6788 --- /dev/null +++ b/autoload/api/Controllers/DictionariesApiController.php @@ -0,0 +1,82 @@ +statusRepo = $statusRepo; + $this->transportRepo = $transportRepo; + $this->paymentRepo = $paymentRepo; + } + + public function statuses(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $statuses = $this->statusRepo->allStatuses(); + + $result = []; + foreach ($statuses as $id => $name) { + $result[] = [ + 'id' => (int)$id, + 'name' => (string)$name, + ]; + } + + ApiRouter::sendSuccess($result); + } + + public function transports(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $transports = $this->transportRepo->allActive(); + + $result = []; + foreach ($transports as $transport) { + $result[] = [ + 'id' => (int)($transport['id'] ?? 0), + 'name' => (string)($transport['name_visible'] ?? $transport['name'] ?? ''), + 'cost' => (float)($transport['cost'] ?? 0), + ]; + } + + ApiRouter::sendSuccess($result); + } + + public function payment_methods(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $methods = $this->paymentRepo->allActive(); + + $result = []; + foreach ($methods as $method) { + $result[] = [ + 'id' => (int)($method['id'] ?? 0), + 'name' => (string)($method['name'] ?? ''), + ]; + } + + ApiRouter::sendSuccess($result); + } +} diff --git a/autoload/api/Controllers/OrdersApiController.php b/autoload/api/Controllers/OrdersApiController.php new file mode 100644 index 0000000..b8cb660 --- /dev/null +++ b/autoload/api/Controllers/OrdersApiController.php @@ -0,0 +1,154 @@ +service = $service; + $this->orderRepo = $orderRepo; + } + + public function list(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $filters = [ + 'status' => isset($_GET['status']) ? $_GET['status'] : '', + 'paid' => isset($_GET['paid']) ? $_GET['paid'] : '', + 'date_from' => isset($_GET['date_from']) ? $_GET['date_from'] : '', + 'date_to' => isset($_GET['date_to']) ? $_GET['date_to'] : '', + 'updated_since' => isset($_GET['updated_since']) ? $_GET['updated_since'] : '', + 'number' => isset($_GET['number']) ? $_GET['number'] : '', + 'client' => isset($_GET['client']) ? $_GET['client'] : '', + ]; + + $page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)); + $perPage = max(1, min(100, (int)(isset($_GET['per_page']) ? $_GET['per_page'] : 50))); + + $result = $this->orderRepo->listForApi($filters, $page, $perPage); + + ApiRouter::sendSuccess($result); + } + + public function get(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $order = $this->orderRepo->findForApi($id); + if ($order === null) { + ApiRouter::sendError('NOT_FOUND', 'Order not found', 404); + return; + } + + ApiRouter::sendSuccess($order); + } + + public function change_status(): void + { + if (!ApiRouter::requireMethod('PUT')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null || !isset($body['status_id'])) { + ApiRouter::sendError('BAD_REQUEST', 'Missing status_id in request body', 400); + return; + } + + $statusId = (int)$body['status_id']; + $sendEmail = !empty($body['send_email']); + + $order = $this->orderRepo->findRawById($id); + if ($order === null) { + ApiRouter::sendError('NOT_FOUND', 'Order not found', 404); + return; + } + + $result = $this->service->changeStatus($id, $statusId, $sendEmail); + + ApiRouter::sendSuccess([ + 'order_id' => $id, + 'status_id' => $statusId, + 'changed' => !empty($result['result']), + ]); + } + + public function set_paid(): void + { + if (!ApiRouter::requireMethod('PUT')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $order = $this->orderRepo->findRawById($id); + if ($order === null) { + ApiRouter::sendError('NOT_FOUND', 'Order not found', 404); + return; + } + + $body = ApiRouter::getJsonBody(); + $sendEmail = ($body !== null && !empty($body['send_email'])); + + $this->service->setOrderAsPaid($id, $sendEmail); + + ApiRouter::sendSuccess([ + 'order_id' => $id, + 'paid' => 1, + ]); + } + + public function set_unpaid(): void + { + if (!ApiRouter::requireMethod('PUT')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $order = $this->orderRepo->findRawById($id); + if ($order === null) { + ApiRouter::sendError('NOT_FOUND', 'Order not found', 404); + return; + } + + $this->service->setOrderAsUnpaid($id); + + ApiRouter::sendSuccess([ + 'order_id' => $id, + 'paid' => 0, + ]); + } +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9198c41 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,198 @@ +# shopPRO REST API + +REST API do integracji z ordersPRO i innymi systemami zewnetrznymi. + +## Autentykacja + +Kazde zapytanie wymaga headera `X-Api-Key` z kluczem API. + +``` +X-Api-Key: {klucz_api} +``` + +Klucz przechowywany jest w `pp_settings` jako parametr `api_key`. API jest stateless (bez sesji). + +## Format odpowiedzi + +### Sukces (HTTP 200) +```json +{ + "status": "ok", + "data": { ... } +} +``` + +### Blad +```json +{ + "status": "error", + "code": "UNAUTHORIZED", + "message": "Invalid or missing API key" +} +``` + +Kody bledow: +| Kod | HTTP | Opis | +|-----|------|------| +| `UNAUTHORIZED` | 401 | Brak lub nieprawidlowy klucz API | +| `BAD_REQUEST` | 400 | Brakujace lub niepoprawne parametry | +| `NOT_FOUND` | 404 | Nie znaleziono zasobu/endpointu/akcji | +| `METHOD_NOT_ALLOWED` | 405 | Nieprawidlowa metoda HTTP | +| `INTERNAL_ERROR` | 500 | Blad wewnetrzny serwera | + +## Endpointy + +### Zamowienia + +#### Lista zamowien +``` +GET api.php?endpoint=orders&action=list +``` + +Parametry filtrowania (opcjonalne): +| Parametr | Typ | Opis | +|----------|-----|------| +| `status` | int | Filtruj po statusie zamowienia | +| `paid` | int (0/1) | Filtruj po statusie platnosci | +| `date_from` | date (YYYY-MM-DD) | Zamowienia od daty | +| `date_to` | date (YYYY-MM-DD) | Zamowienia do daty | +| `updated_since` | datetime (YYYY-MM-DD HH:MM:SS) | Zamowienia zmodyfikowane od podanej daty (klucz do pollingu) | +| `number` | string | Szukaj po numerze zamowienia | +| `client` | string | Szukaj po imieniu, nazwisku lub emailu klienta | +| `page` | int | Numer strony (domyslnie 1) | +| `per_page` | int | Wynikow na strone (domyslnie 50, max 100) | + +Odpowiedz: +```json +{ + "status": "ok", + "data": { + "items": [ + { + "id": 42, + "number": "2026/02/001", + "date_order": "2026-02-19 10:30:00", + "updated_at": "2026-02-19 12:00:00", + "status": 4, + "paid": 1, + "client_name": "Jan", + "client_surname": "Kowalski", + "client_email": "jan@example.com", + "client_phone": "111222333", + "client_street": "Testowa 1", + "client_postal_code": "00-000", + "client_city": "Warszawa", + "firm_name": null, + "firm_nip": null, + "transport": "Kurier DPD", + "transport_cost": 15.00, + "payment_method": "Przelew bankowy", + "summary": 150.00 + } + ], + "total": 1, + "page": 1, + "per_page": 50 + } +} +``` + +#### Szczegoly zamowienia +``` +GET api.php?endpoint=orders&action=get&id={order_id} +``` + +Zwraca pelne dane zamowienia z produktami i historia statusow. + +#### Zmiana statusu zamowienia +``` +PUT api.php?endpoint=orders&action=change_status&id={order_id} +Content-Type: application/json + +{ + "status_id": 5, + "send_email": true +} +``` + +Odpowiedz: +```json +{ + "status": "ok", + "data": { + "order_id": 42, + "status_id": 5, + "changed": true + } +} +``` + +#### Oznacz jako oplacone +``` +PUT api.php?endpoint=orders&action=set_paid&id={order_id} +``` + +Opcjonalnie w body: `{"send_email": true}` + +#### Oznacz jako nieoplacone +``` +PUT api.php?endpoint=orders&action=set_unpaid&id={order_id} +``` + +### Slowniki + +#### Lista statusow zamowien +``` +GET api.php?endpoint=dictionaries&action=statuses +``` + +Odpowiedz: +```json +{ + "status": "ok", + "data": [ + {"id": 0, "name": "Nowe"}, + {"id": 1, "name": "Oplacone"}, + {"id": 4, "name": "W realizacji"}, + {"id": 6, "name": "Wyslane"} + ] +} +``` + +#### Lista metod transportu +``` +GET api.php?endpoint=dictionaries&action=transports +``` + +#### Lista metod platnosci +``` +GET api.php?endpoint=dictionaries&action=payment_methods +``` + +## Polling + +Aby pobierac tylko nowe/zmienione zamowienia, uzyj parametru `updated_since`: + +``` +GET api.php?endpoint=orders&action=list&updated_since=2026-02-19 12:00:00 +``` + +Kolumna `updated_at` w `pp_shop_orders` jest aktualizowana automatycznie przy kazdej modyfikacji zamowienia (zmiana statusu, platnosci, edycja danych, tworzenie zamowienia). + +## Konfiguracja + +Klucz API ustawia sie w panelu admina w ustawieniach sklepu lub bezposrednio w bazie: + +```sql +INSERT INTO pp_settings (param, value) VALUES ('api_key', 'twoj-klucz-api'); +-- lub +UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key'; +``` + +## Architektura + +- Entry point: `api.php` +- Router: `\api\ApiRouter` (`autoload/api/ApiRouter.php`) +- Kontrolery: `autoload/api/Controllers/` + - `OrdersApiController` — zamowienia (5 akcji) + - `DictionariesApiController` — slowniki (3 akcje) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ab41816..4354b0f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,26 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.296 (2026-02-19) - REST API zamówień dla ordersPRO + +- **NEW**: REST API do zarządzania zamówieniami — lista, szczegóły, zmiana statusu, oznaczanie płatności +- **NEW**: Endpointy słownikowe — statusy zamówień, metody transportu, metody płatności +- **NEW**: Autentykacja API przez header `X-Api-Key` (klucz w `pp_settings`) +- **NEW**: `\api\ApiRouter` — router API z autentykacją, routingiem i helperami odpowiedzi +- **NEW**: `\api\Controllers\OrdersApiController` — 5 akcji (list, get, change_status, set_paid, set_unpaid) +- **NEW**: `\api\Controllers\DictionariesApiController` — 3 akcje (statuses, transports, payment_methods) +- **NEW**: `OrderRepository::listForApi()` — lista zamówień z filtrowaniem i paginacją (z `updated_since` do pollingu) +- **NEW**: `OrderRepository::findForApi()` — szczegóły zamówienia z produktami i historią statusów +- **NEW**: `OrderRepository::touchUpdatedAt()` — aktualizacja `updated_at` przy modyfikacji zamówienia +- **NEW**: Kolumna `pp_shop_orders.updated_at` — data ostatniej modyfikacji (polling API) +- **NEW**: Setting `api_key` w `pp_settings` — klucz autentykacji API +- **UPDATE**: `api.php` — skip sesji dla requestów API, routing do `ApiRouter` +- **UPDATE**: Metody `updateOrderStatus`, `setAsPaid`, `setAsUnpaid`, `saveOrderByAdmin`, `saveNotes`, `createFromBasket` wywołują `touchUpdatedAt()` +- **NEW**: `docs/API.md` — dokumentacja REST API +- **Tests**: 30 nowych testów API (ApiRouter, OrdersApiController, DictionariesApiController) + +--- + ## ver. 0.295 (2026-02-19) - Admin: edycja produktów w zamówieniu + wyszukiwanie AJAX + korekta stanów magazynowych - **NEW**: Edycja produktów w zamówieniu z panelu admina (dodawanie, usuwanie, zmiana ilości/cen) diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md index 841edc5..5e22dca 100644 --- a/docs/DATABASE_STRUCTURE.md +++ b/docs/DATABASE_STRUCTURE.md @@ -110,8 +110,9 @@ Zamówienia sklepu (źródło danych dla list i szczegółów klientów w panelu | payment_method | Nazwa metody płatności | | transport | Nazwa transportu | | message | Wiadomość klienta | +| updated_at | Data ostatniej modyfikacji (polling API) | -**Używane w:** `Domain\Client\ClientRepository::listForAdmin()`, `Domain\Client\ClientRepository::ordersForClient()`, `Domain\Client\ClientRepository::totalsForClient()`. +**Używane w:** `Domain\Client\ClientRepository::listForAdmin()`, `Domain\Client\ClientRepository::ordersForClient()`, `Domain\Client\ClientRepository::totalsForClient()`, `Domain\Order\OrderRepository::listForApi()`, `Domain\Order\OrderRepository::findForApi()`. **Aktualizacja 2026-02-15 (ver. 0.274):** moduł `/admin/shop_clients/*` korzysta z `Domain\Client\ClientRepository` przez `admin\Controllers\ShopClientsController`. diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 2322526..83c094d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -72,6 +72,20 @@ Newsletter, Search, ShopBasket, ShopClient, ShopCoupon, ShopOrder, ShopProducer, ### Widoki (`front\Views\`) — 11 klas statycznych Articles, Banners, Languages, Menu, Newsletter, Scontainers, ShopCategory, ShopClient, ShopPaymentMethod, ShopProduct, ShopSearch +## Warstwa API (`autoload/api/`) + +REST API dla ordersPRO. Entry point: `api.php`. Stateless (bez sesji), autentykacja przez `X-Api-Key` header. + +### Router: `api\ApiRouter` +- `handle()` — autentykacja → routing → dispatch +- Helpery statyczne: `sendSuccess()`, `sendError()`, `getJsonBody()`, `requireMethod()` + +### Kontrolery (`api\Controllers\`) +- `OrdersApiController` — lista, szczegoly, zmiana statusu, platnosc (5 akcji) +- `DictionariesApiController` — statusy, transporty, metody platnosci (3 akcje) + +Dokumentacja: `docs/API.md` + ## Warstwa wspoldzielona (`autoload/Shared/`) | Klasa | Opis | @@ -106,7 +120,7 @@ ProductRepository::productSetsWhenAddToBasket:{id} — zestawy "kupowane r |------|------| | `index.php` | Frontend — autoload, sesja, DB, routing (`front\App`), layout (`front\LayoutEngine`), DOM post-processing | | `ajax.php` | Frontend AJAX — koszyk, transport, kontakt | -| `api.php` | REST API (Ekomi CSV) | +| `api.php` | REST API (ordersPRO + Ekomi CSV) — router: `\api\ApiRouter`, kontrolery: `\api\Controllers\` | | `admin/index.php` | Admin — autoload, sesja, DB, routing (`admin\App`) | | `admin/ajax.php` | Admin AJAX | | `cron.php` | CRON: Apilo sync (ceny/stany co 10min, cennik co 1h, retry queue) | diff --git a/docs/TESTING.md b/docs/TESTING.md index 5313b49..48d267d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,10 +23,10 @@ composer test # standard ## Aktualny stan ```text -OK (636 tests, 1868 assertions) +OK (666 tests, 1930 assertions) ``` -Zweryfikowano: 2026-02-19 (ver. 0.295) +Zweryfikowano: 2026-02-19 (ver. 0.296) ## Konfiguracja @@ -85,12 +85,17 @@ tests/ | |-- ShopStatusesControllerTest.php | |-- ShopTransportControllerTest.php | `-- UsersControllerTest.php +| `-- api/ +| |-- ApiRouterTest.php +| `-- Controllers/ +| |-- OrdersApiControllerTest.php +| `-- DictionariesApiControllerTest.php `-- Integration/ (puste — zarezerwowane) ``` ## Dodawanie nowych testow -1. Plik w `tests/Unit/Domain//Test.php` lub `tests/Unit/admin/Controllers/Test.php`. +1. Plik w `tests/Unit/Domain//Test.php`, `tests/Unit/admin/Controllers/Test.php` lub `tests/Unit/api/Controllers/Test.php`. 2. Rozszerz `PHPUnit\Framework\TestCase`. 3. Nazwy metod zaczynaj od `test`. 4. Wzorzec AAA: Arrange, Act, Assert. diff --git a/docs/UPDATE_INSTRUCTIONS.md b/docs/UPDATE_INSTRUCTIONS.md index 1e6abe3..1b69fe7 100644 --- a/docs/UPDATE_INSTRUCTIONS.md +++ b/docs/UPDATE_INSTRUCTIONS.md @@ -18,16 +18,17 @@ Aktualizacje znajdują się w folderze `updates/0.XX/` gdzie XX oznacza dziesią ## Procedura tworzenia nowej aktualizacji -## Status biezacej aktualizacji (ver. 0.295) +## Status biezacej aktualizacji (ver. 0.296) -- Wersja udostepniona: `0.295` (data: 2026-02-19). +- Wersja udostepniona: `0.296` (data: 2026-02-19). - Pliki publikacyjne: - - `updates/0.20/ver_0.295.zip` + - `updates/0.20/ver_0.296.zip` + - `updates/0.20/ver_0.296_sql.txt` - Pliki metadanych aktualizacji: - `updates/changelog.php` - - `updates/versions.php` (`$current_ver = 295`) + - `updates/versions.php` (`$current_ver = 296`) - Weryfikacja testow przed publikacja: - - `OK (636 tests, 1868 assertions)` + - `OK (666 tests, 1930 assertions)` ### 1. Określ numer wersji Sprawdź ostatnią wersję w `updates/` i zwiększ o 1. diff --git a/tests/Unit/api/ApiRouterTest.php b/tests/Unit/api/ApiRouterTest.php new file mode 100644 index 0000000..2102200 --- /dev/null +++ b/tests/Unit/api/ApiRouterTest.php @@ -0,0 +1,177 @@ +createMock(\medoo::class); + $mockSettings = $this->createMock(SettingsRepository::class); + $mockSettings->method('getSingleValue') + ->with('api_key') + ->willReturn($storedApiKey); + + return new ApiRouter($mockDb, $mockSettings); + } + + public function testHandleReturns401WhenNoApiKey(): void + { + unset($_SERVER['HTTP_X_API_KEY']); + $_GET['endpoint'] = 'orders'; + $_GET['action'] = 'list'; + + $router = $this->createRouter(); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(401, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('error', $json['status']); + $this->assertSame('UNAUTHORIZED', $json['code']); + } + + public function testHandleReturns401WhenWrongApiKey(): void + { + $_SERVER['HTTP_X_API_KEY'] = 'wrong-key'; + $_GET['endpoint'] = 'orders'; + $_GET['action'] = 'list'; + + $router = $this->createRouter(); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(401, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('UNAUTHORIZED', $json['code']); + } + + public function testHandleReturns401WhenStoredKeyEmpty(): void + { + $_SERVER['HTTP_X_API_KEY'] = 'any-key'; + $_GET['endpoint'] = 'orders'; + $_GET['action'] = 'list'; + + $router = $this->createRouter(''); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(401, http_response_code()); + } + + public function testHandleReturns400WhenMissingEndpoint(): void + { + $_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123'; + unset($_GET['endpoint']); + $_GET['action'] = 'list'; + $_GET['endpoint'] = ''; + + $router = $this->createRouter(); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('BAD_REQUEST', $json['code']); + } + + public function testHandleReturns400WhenMissingAction(): void + { + $_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123'; + $_GET['endpoint'] = 'orders'; + $_GET['action'] = ''; + + $router = $this->createRouter(); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testHandleReturns404ForUnknownEndpoint(): void + { + $_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123'; + $_GET['endpoint'] = 'unknown'; + $_GET['action'] = 'list'; + + $router = $this->createRouter(); + + ob_start(); + $router->handle(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('NOT_FOUND', $json['code']); + } + + public function testSendSuccessOutputsCorrectJson(): void + { + ob_start(); + ApiRouter::sendSuccess(['foo' => 'bar']); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame('bar', $json['data']['foo']); + } + + public function testSendErrorOutputsCorrectJson(): void + { + ob_start(); + ApiRouter::sendError('BAD_REQUEST', 'Test error', 400); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('error', $json['status']); + $this->assertSame('BAD_REQUEST', $json['code']); + $this->assertSame('Test error', $json['message']); + } + + public function testRequireMethodReturnsTrueForMatchingMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $result = ApiRouter::requireMethod('GET'); + ob_get_clean(); + + $this->assertTrue($result); + } + + public function testRequireMethodReturnsFalseAndSendsErrorForMismatch(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $result = ApiRouter::requireMethod('GET'); + $output = ob_get_clean(); + + $this->assertFalse($result); + $this->assertSame(405, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('METHOD_NOT_ALLOWED', $json['code']); + } + + protected function tearDown(): void + { + unset($_SERVER['HTTP_X_API_KEY']); + unset($_SERVER['REQUEST_METHOD']); + $_GET = []; + http_response_code(200); + } +} diff --git a/tests/Unit/api/Controllers/DictionariesApiControllerTest.php b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php new file mode 100644 index 0000000..8545d8e --- /dev/null +++ b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php @@ -0,0 +1,139 @@ +mockStatusRepo = $this->createMock(ShopStatusRepository::class); + $this->mockTransportRepo = $this->createMock(TransportRepository::class); + $this->mockPaymentRepo = $this->createMock(PaymentMethodRepository::class); + + $this->controller = new DictionariesApiController( + $this->mockStatusRepo, + $this->mockTransportRepo, + $this->mockPaymentRepo + ); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + } + + protected function tearDown(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + http_response_code(200); + } + + // --- statuses --- + + public function testStatusesReturnsFormattedList(): void + { + $this->mockStatusRepo->method('allStatuses') + ->willReturn([ + 0 => 'Nowe', + 1 => 'Opłacone', + 4 => 'W realizacji', + 6 => 'Wysłane', + ]); + + ob_start(); + $this->controller->statuses(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertCount(4, $json['data']); + $this->assertSame(0, $json['data'][0]['id']); + $this->assertSame('Nowe', $json['data'][0]['name']); + $this->assertSame(6, $json['data'][3]['id']); + $this->assertSame('Wysłane', $json['data'][3]['name']); + } + + public function testStatusesRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->statuses(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + // --- transports --- + + public function testTransportsReturnsFormattedList(): void + { + $this->mockTransportRepo->method('allActive') + ->willReturn([ + ['id' => 1, 'name_visible' => 'InPost Paczkomat', 'cost' => '12.99'], + ['id' => 2, 'name_visible' => 'Kurier DPD', 'cost' => '15.00'], + ]); + + ob_start(); + $this->controller->transports(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertCount(2, $json['data']); + $this->assertSame(1, $json['data'][0]['id']); + $this->assertSame('InPost Paczkomat', $json['data'][0]['name']); + $this->assertSame(12.99, $json['data'][0]['cost']); + } + + public function testTransportsRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->transports(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + // --- payment_methods --- + + public function testPaymentMethodsReturnsFormattedList(): void + { + $this->mockPaymentRepo->method('allActive') + ->willReturn([ + ['id' => 1, 'name' => 'Przelew bankowy'], + ['id' => 2, 'name' => 'Przelewy24'], + ['id' => 3, 'name' => 'Przy odbiorze'], + ]); + + ob_start(); + $this->controller->payment_methods(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertCount(3, $json['data']); + $this->assertSame(1, $json['data'][0]['id']); + $this->assertSame('Przelew bankowy', $json['data'][0]['name']); + } + + public function testPaymentMethodsRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->payment_methods(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } +} diff --git a/tests/Unit/api/Controllers/OrdersApiControllerTest.php b/tests/Unit/api/Controllers/OrdersApiControllerTest.php new file mode 100644 index 0000000..ae51237 --- /dev/null +++ b/tests/Unit/api/Controllers/OrdersApiControllerTest.php @@ -0,0 +1,290 @@ +mockService = $this->createMock(OrderAdminService::class); + $this->mockOrderRepo = $this->createMock(OrderRepository::class); + $this->controller = new OrdersApiController($this->mockService, $this->mockOrderRepo); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = []; + } + + protected function tearDown(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = []; + http_response_code(200); + } + + // --- list --- + + public function testListReturnsOrders(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $this->mockOrderRepo->method('listForApi') + ->willReturn([ + 'items' => [ + ['id' => 1, 'number' => '2026/01/001', 'status' => 0, 'paid' => 0, 'summary' => 99.99], + ], + 'total' => 1, + 'page' => 1, + 'per_page' => 50, + ]); + + ob_start(); + $this->controller->list(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertCount(1, $json['data']['items']); + $this->assertSame(1, $json['data']['total']); + } + + public function testListRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->list(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testListPassesFiltersToRepository(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['status'] = '4'; + $_GET['paid'] = '1'; + $_GET['page'] = '2'; + $_GET['per_page'] = '25'; + + $this->mockOrderRepo->expects($this->once()) + ->method('listForApi') + ->with( + $this->callback(function ($filters) { + return $filters['status'] === '4' && $filters['paid'] === '1'; + }), + 2, + 25 + ) + ->willReturn(['items' => [], 'total' => 0, 'page' => 2, 'per_page' => 25]); + + ob_start(); + $this->controller->list(); + ob_get_clean(); + } + + // --- get --- + + public function testGetReturnsOrder(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '42'; + + $this->mockOrderRepo->method('findForApi') + ->with(42) + ->willReturn([ + 'id' => 42, + 'number' => '2026/01/001', + 'status' => 4, + 'paid' => 1, + 'summary' => 150.00, + 'products' => [], + 'statuses' => [], + ]); + + ob_start(); + $this->controller->get(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame(42, $json['data']['id']); + } + + public function testGetReturns404WhenOrderNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '999'; + + $this->mockOrderRepo->method('findForApi') + ->with(999) + ->willReturn(null); + + ob_start(); + $this->controller->get(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('NOT_FOUND', $json['code']); + } + + public function testGetReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $this->controller->get(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + // --- change_status --- + + public function testChangeStatusUpdatesOrder(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '10'; + + $this->mockOrderRepo->method('findRawById') + ->with(10) + ->willReturn(['id' => 10, 'status' => 0]); + + $this->mockService->method('changeStatus') + ->with(10, 5, false) + ->willReturn(['result' => true]); + + // Simulate JSON body via php://input override is not possible in unit tests, + // so we test the controller logic path via mock expectations + ob_start(); + $this->controller->change_status(); + $output = ob_get_clean(); + + // Without a real php://input body, getJsonBody returns null → BAD_REQUEST + $json = json_decode($output, true); + $this->assertSame('error', $json['status']); + $this->assertSame('BAD_REQUEST', $json['code']); + } + + public function testChangeStatusReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + ob_start(); + $this->controller->change_status(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testChangeStatusRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '10'; + + ob_start(); + $this->controller->change_status(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + // --- set_paid --- + + public function testSetPaidReturns404WhenOrderNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '999'; + + $this->mockOrderRepo->method('findRawById') + ->with(999) + ->willReturn(null); + + ob_start(); + $this->controller->set_paid(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + } + + public function testSetPaidReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + ob_start(); + $this->controller->set_paid(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testSetPaidCallsServiceWhenOrderExists(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '10'; + + $this->mockOrderRepo->method('findRawById') + ->with(10) + ->willReturn(['id' => 10, 'paid' => 0]); + + $this->mockService->expects($this->once()) + ->method('setOrderAsPaid') + ->with(10, false); + + ob_start(); + $this->controller->set_paid(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame(1, $json['data']['paid']); + } + + // --- set_unpaid --- + + public function testSetUnpaidReturns404WhenOrderNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '999'; + + $this->mockOrderRepo->method('findRawById') + ->with(999) + ->willReturn(null); + + ob_start(); + $this->controller->set_unpaid(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + } + + public function testSetUnpaidCallsServiceWhenOrderExists(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '10'; + + $this->mockOrderRepo->method('findRawById') + ->with(10) + ->willReturn(['id' => 10, 'paid' => 1]); + + $this->mockService->expects($this->once()) + ->method('setOrderAsUnpaid') + ->with(10); + + ob_start(); + $this->controller->set_unpaid(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame(0, $json['data']['paid']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e626638..f2883c9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,6 +16,7 @@ if (file_exists(__DIR__ . '/../vendor/autoload.php')) { 'admin\\Support\\Forms\\' => __DIR__ . '/../autoload/admin/Support/Forms/', 'admin\\ViewModels\\Forms\\' => __DIR__ . '/../autoload/admin/ViewModels/Forms/', 'admin\\Validation\\' => __DIR__ . '/../autoload/admin/Validation/', + 'api\\' => __DIR__ . '/../autoload/api/', ]; foreach ($prefixes as $prefix => $baseDir) { diff --git a/updates/0.20/ver_0.296.zip b/updates/0.20/ver_0.296.zip new file mode 100644 index 0000000..68764ba Binary files /dev/null and b/updates/0.20/ver_0.296.zip differ diff --git a/updates/0.20/ver_0.296_sql.txt b/updates/0.20/ver_0.296_sql.txt new file mode 100644 index 0000000..a8f69d4 --- /dev/null +++ b/updates/0.20/ver_0.296_sql.txt @@ -0,0 +1,4 @@ +ALTER TABLE `pp_shop_orders` ADD COLUMN `updated_at` DATETIME NULL DEFAULT NULL AFTER `date_order`; +UPDATE `pp_shop_orders` SET `updated_at` = `date_order` WHERE `updated_at` IS NULL; +CREATE INDEX `idx_pp_shop_orders_updated_at` ON `pp_shop_orders` (`updated_at`); +INSERT INTO `pp_settings` (`param`, `value`) VALUES ('api_key', ''); diff --git a/updates/changelog.php b/updates/changelog.php index 6bc7f2b..930ac01 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,9 @@ +ver. 0.296 - 19.02.2026
+- NEW - REST API zamówień dla ordersPRO (lista, szczegóły, zmiana statusu, płatności) +- NEW - Endpointy słownikowe (statusy, transporty, metody płatności) +- NEW - Autentykacja API przez X-Api-Key header +- NEW - Kolumna updated_at w pp_shop_orders (polling zmian) +
ver. 0.295 - 19.02.2026
- NEW - Edycja produktów w zamówieniu z panelu admina (dodawanie, usuwanie, zmiana ilości/cen) - NEW - Wyszukiwarka produktów AJAX w formularzu edycji zamówienia diff --git a/updates/versions.php b/updates/versions.php index 2b5c3dd..7fb0d47 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@