From 9cac0d1eebb40e14f080dc0a87227a027fd084f4 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Thu, 19 Feb 2026 20:25:07 +0100 Subject: [PATCH] =?UTF-8?q?ver.=200.296:=20REST=20API=20for=20ordersPRO=20?= =?UTF-8?q?=E2=80=94=20orders=20management,=20dictionaries,=20API=20key=20?= =?UTF-8?q?auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New API layer: ApiRouter, OrdersApiController, DictionariesApiController - Orders API: list (with filters/pagination/updated_since), details, change status, set paid/unpaid - Dictionaries API: order statuses, transport methods, payment methods - X-Api-Key authentication via pp_settings.api_key - OrderRepository: listForApi(), findForApi(), touchUpdatedAt() - updated_at column on pp_shop_orders for polling support - api.php: skip session for API requests, route to ApiRouter - SettingsController: api_key field in system tab - 30 new tests (666 total, 1930 assertions) Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 38 +-- CLAUDE.md | 18 +- api.php | 42 ++- autoload/Domain/Order/OrderRepository.php | 151 ++++++++- .../admin/Controllers/SettingsController.php | 4 + autoload/api/ApiRouter.php | 144 +++++++++ .../Controllers/DictionariesApiController.php | 82 +++++ .../api/Controllers/OrdersApiController.php | 154 ++++++++++ docs/API.md | 198 ++++++++++++ docs/CHANGELOG.md | 20 ++ docs/DATABASE_STRUCTURE.md | 3 +- docs/PROJECT_STRUCTURE.md | 16 +- docs/TESTING.md | 11 +- docs/UPDATE_INSTRUCTIONS.md | 11 +- tests/Unit/api/ApiRouterTest.php | 177 +++++++++++ .../DictionariesApiControllerTest.php | 139 +++++++++ .../Controllers/OrdersApiControllerTest.php | 290 ++++++++++++++++++ tests/bootstrap.php | 1 + updates/0.20/ver_0.296.zip | Bin 0 -> 15596 bytes updates/0.20/ver_0.296_sql.txt | 4 + updates/changelog.php | 6 + updates/versions.php | 2 +- 22 files changed, 1457 insertions(+), 54 deletions(-) create mode 100644 autoload/api/ApiRouter.php create mode 100644 autoload/api/Controllers/DictionariesApiController.php create mode 100644 autoload/api/Controllers/OrdersApiController.php create mode 100644 docs/API.md create mode 100644 tests/Unit/api/ApiRouterTest.php create mode 100644 tests/Unit/api/Controllers/DictionariesApiControllerTest.php create mode 100644 tests/Unit/api/Controllers/OrdersApiControllerTest.php create mode 100644 updates/0.20/ver_0.296.zip create mode 100644 updates/0.20/ver_0.296_sql.txt 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 0000000000000000000000000000000000000000..68764badfad1db827cc872eef3e959527b623dd9 GIT binary patch literal 15596 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 literal 0 HcmV?d00001 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 @@