From ebab220f7ed195198bc3a78f572e199d3ad127e5 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Thu, 19 Feb 2026 22:39:48 +0100 Subject: [PATCH] =?UTF-8?q?ver.=200.297:=20REST=20API=20products=20endpoin?= =?UTF-8?q?t=20=E2=80=94=20list,=20get,=20create,=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- autoload/Domain/Product/ProductRepository.php | 254 +++++++++++ autoload/api/ApiRouter.php | 4 + .../api/Controllers/ProductsApiController.php | 251 +++++++++++ docs/API.md | 168 ++++++++ docs/CHANGELOG.md | 14 + docs/PROJECT_STRUCTURE.md | 1 + docs/TESTING.md | 5 +- docs/UPDATE_INSTRUCTIONS.md | 11 +- .../Controllers/ProductsApiControllerTest.php | 408 ++++++++++++++++++ updates/0.20/ver_0.297.zip | Bin 0 -> 23453 bytes updates/changelog.php | 5 + updates/versions.php | 2 +- 13 files changed, 1115 insertions(+), 10 deletions(-) create mode 100644 autoload/api/Controllers/ProductsApiController.php create mode 100644 tests/Unit/api/Controllers/ProductsApiControllerTest.php create mode 100644 updates/0.20/ver_0.297.zip diff --git a/CLAUDE.md b/CLAUDE.md index 8f7de71..d13a879 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: **666 tests, 1930 assertions**. +Current suite: **687 tests, 1971 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. diff --git a/autoload/Domain/Product/ProductRepository.php b/autoload/Domain/Product/ProductRepository.php index e37de93..20098d7 100644 --- a/autoload/Domain/Product/ProductRepository.php +++ b/autoload/Domain/Product/ProductRepository.php @@ -442,6 +442,260 @@ class ProductRepository ]; } + /** + * Lista produktów dla REST API z filtrowaniem, sortowaniem i paginacją. + * + * @param array $filters Filtry: search, status, promoted + * @param string $sortColumn Kolumna sortowania + * @param string $sortDir Kierunek: ASC|DESC + * @param int $page Numer strony + * @param int $perPage Wyników na stronę (max 100) + * @return array{items: array, total: int, page: int, per_page: int} + */ + public function listForApi( + array $filters = [], + string $sortColumn = 'id', + string $sortDir = 'DESC', + int $page = 1, + int $perPage = 50 + ): array { + $allowedSortColumns = [ + 'id' => 'psp.id', + 'name' => 'name', + 'price_brutto' => 'psp.price_brutto', + 'status' => 'psp.status', + 'promoted' => 'psp.promoted', + 'quantity' => 'psp.quantity', + ]; + + $sortSql = isset($allowedSortColumns[$sortColumn]) ? $allowedSortColumns[$sortColumn] : 'psp.id'; + $sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC'; + $page = max(1, $page); + $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); + $offset = ($page - 1) * $perPage; + + $where = ['psp.archive = 0', 'psp.parent_id IS NULL']; + $params = []; + + $search = trim((string)($filters['search'] ?? '')); + if (strlen($search) > 255) { + $search = substr($search, 0, 255); + } + + if ($search !== '') { + $where[] = '( + psp.ean LIKE :search + OR psp.sku LIKE :search + OR EXISTS ( + SELECT 1 + FROM pp_shop_products_langs AS pspl2 + WHERE pspl2.product_id = psp.id + AND pspl2.name LIKE :search + ) + )'; + $params[':search'] = '%' . $search . '%'; + } + + $statusFilter = (string)($filters['status'] ?? ''); + if ($statusFilter === '1' || $statusFilter === '0') { + $where[] = 'psp.status = :status'; + $params[':status'] = (int)$statusFilter; + } + + $promotedFilter = (string)($filters['promoted'] ?? ''); + if ($promotedFilter === '1' || $promotedFilter === '0') { + $where[] = 'psp.promoted = :promoted'; + $params[':promoted'] = (int)$promotedFilter; + } + + $whereSql = implode(' AND ', $where); + + $sqlCount = " + SELECT COUNT(0) + FROM pp_shop_products AS psp + WHERE {$whereSql} + "; + + $stmtCount = $this->db->query($sqlCount, $params); + $countRows = $stmtCount ? $stmtCount->fetchAll() : []; + $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0; + + $sql = " + SELECT + psp.id, + psp.sku, + psp.ean, + psp.price_brutto, + psp.price_brutto_promo, + psp.price_netto, + psp.price_netto_promo, + psp.quantity, + psp.status, + psp.promoted, + psp.vat, + psp.weight, + psp.date_add, + psp.date_modify, + ( + SELECT pspl.name + FROM pp_shop_products_langs AS pspl + INNER JOIN pp_langs AS pl ON pl.id = pspl.lang_id + WHERE pspl.product_id = psp.id + AND pspl.name <> '' + ORDER BY pl.o ASC + LIMIT 1 + ) AS name, + ( + SELECT pspi.src + FROM pp_shop_products_images AS pspi + WHERE pspi.product_id = psp.id + ORDER BY pspi.o ASC, pspi.id ASC + LIMIT 1 + ) AS main_image + FROM pp_shop_products AS psp + WHERE {$whereSql} + ORDER BY {$sortSql} {$sortDir}, psp.id {$sortDir} + LIMIT {$perPage} OFFSET {$offset} + "; + + $stmt = $this->db->query($sql, $params); + $rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + + $items = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $items[] = [ + 'id' => (int)$row['id'], + 'sku' => $row['sku'], + 'ean' => $row['ean'], + 'name' => $row['name'], + 'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null, + 'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null, + 'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null, + 'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null, + 'quantity' => (int)$row['quantity'], + 'status' => (int)$row['status'], + 'promoted' => (int)$row['promoted'], + 'vat' => (int)$row['vat'], + 'weight' => $row['weight'] !== null ? (float)$row['weight'] : null, + 'main_image' => $row['main_image'], + 'date_add' => $row['date_add'], + 'date_modify' => $row['date_modify'], + ]; + } + } + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]; + } + + /** + * Szczegóły produktu dla REST API. + * + * @param int $id ID produktu + * @return array|null Dane produktu lub null + */ + public function findForApi(int $id): ?array + { + $product = $this->db->get('pp_shop_products', '*', ['id' => $id]); + if (!$product) { + return null; + } + + if (!empty($product['archive'])) { + return null; + } + + $result = [ + 'id' => (int)$product['id'], + 'sku' => $product['sku'], + 'ean' => $product['ean'], + 'price_brutto' => $product['price_brutto'] !== null ? (float)$product['price_brutto'] : null, + 'price_brutto_promo' => $product['price_brutto_promo'] !== null ? (float)$product['price_brutto_promo'] : null, + 'price_netto' => $product['price_netto'] !== null ? (float)$product['price_netto'] : null, + 'price_netto_promo' => $product['price_netto_promo'] !== null ? (float)$product['price_netto_promo'] : null, + 'quantity' => (int)$product['quantity'], + 'status' => (int)$product['status'], + 'promoted' => (int)$product['promoted'], + 'vat' => (int)$product['vat'], + 'weight' => $product['weight'] !== null ? (float)$product['weight'] : null, + 'stock_0_buy' => (int)($product['stock_0_buy'] ?? 0), + 'custom_label_0' => $product['custom_label_0'], + 'custom_label_1' => $product['custom_label_1'], + 'custom_label_2' => $product['custom_label_2'], + 'custom_label_3' => $product['custom_label_3'], + 'custom_label_4' => $product['custom_label_4'], + 'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null, + 'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null, + 'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null, + 'date_add' => $product['date_add'], + 'date_modify' => $product['date_modify'], + ]; + + // Languages + $langs = $this->db->select('pp_shop_products_langs', '*', ['product_id' => $id]); + $result['languages'] = []; + if (is_array($langs)) { + foreach ($langs as $lang) { + $result['languages'][$lang['lang_id']] = [ + 'name' => $lang['name'], + 'short_description' => $lang['short_description'], + 'description' => $lang['description'], + 'meta_description' => $lang['meta_description'], + 'meta_keywords' => $lang['meta_keywords'], + 'meta_title' => $lang['meta_title'], + 'seo_link' => $lang['seo_link'], + 'copy_from' => $lang['copy_from'], + 'warehouse_message_zero' => $lang['warehouse_message_zero'], + 'warehouse_message_nonzero' => $lang['warehouse_message_nonzero'], + 'tab_name_1' => $lang['tab_name_1'], + 'tab_description_1' => $lang['tab_description_1'], + 'tab_name_2' => $lang['tab_name_2'], + 'tab_description_2' => $lang['tab_description_2'], + 'canonical' => $lang['canonical'], + ]; + } + } + + // Images + $images = $this->db->select('pp_shop_products_images', ['id', 'src', 'alt'], [ + 'product_id' => $id, + 'ORDER' => ['o' => 'ASC', 'id' => 'ASC'], + ]); + $result['images'] = is_array($images) ? $images : []; + foreach ($result['images'] as &$img) { + $img['id'] = (int)$img['id']; + } + unset($img); + + // Categories + $categories = $this->db->select('pp_shop_products_categories', 'category_id', ['product_id' => $id]); + $result['categories'] = []; + if (is_array($categories)) { + foreach ($categories as $catId) { + $result['categories'][] = (int)$catId; + } + } + + // Attributes + $attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]); + $result['attributes'] = []; + if (is_array($attributes)) { + foreach ($attributes as $attr) { + $result['attributes'][] = [ + 'attribute_id' => (int)$attr['attribute_id'], + 'value_id' => (int)$attr['value_id'], + ]; + } + } + + return $result; + } + /** * Szczegóły produktu (admin) — zastępuje factory product_details(). */ diff --git a/autoload/api/ApiRouter.php b/autoload/api/ApiRouter.php index 248dd67..75f0ab0 100644 --- a/autoload/api/ApiRouter.php +++ b/autoload/api/ApiRouter.php @@ -90,6 +90,10 @@ class ApiRouter $service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo); return new Controllers\OrdersApiController($service, $orderRepo); }, + 'products' => function () use ($db) { + $productRepo = new \Domain\Product\ProductRepository($db); + return new Controllers\ProductsApiController($productRepo); + }, 'dictionaries' => function () use ($db) { $statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db); $transportRepo = new \Domain\Transport\TransportRepository($db); diff --git a/autoload/api/Controllers/ProductsApiController.php b/autoload/api/Controllers/ProductsApiController.php new file mode 100644 index 0000000..5af613c --- /dev/null +++ b/autoload/api/Controllers/ProductsApiController.php @@ -0,0 +1,251 @@ +productRepo = $productRepo; + } + + public function list(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $filters = [ + 'search' => isset($_GET['search']) ? $_GET['search'] : '', + 'status' => isset($_GET['status']) ? $_GET['status'] : '', + 'promoted' => isset($_GET['promoted']) ? $_GET['promoted'] : '', + ]; + + $sort = isset($_GET['sort']) ? $_GET['sort'] : 'id'; + $sortDir = isset($_GET['sort_dir']) ? $_GET['sort_dir'] : 'DESC'; + $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->productRepo->listForApi($filters, $sort, $sortDir, $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; + } + + $product = $this->productRepo->findForApi($id); + if ($product === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + ApiRouter::sendSuccess($product); + } + + public function create(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + if (empty($body['languages']) || !is_array($body['languages'])) { + ApiRouter::sendError('BAD_REQUEST', 'Missing languages (at least one language with name is required)', 400); + return; + } + + $hasName = false; + foreach ($body['languages'] as $lang) { + if (is_array($lang) && !empty($lang['name'])) { + $hasName = true; + break; + } + } + if (!$hasName) { + ApiRouter::sendError('BAD_REQUEST', 'At least one language must have a name', 400); + return; + } + + if (!isset($body['price_brutto'])) { + ApiRouter::sendError('BAD_REQUEST', 'Missing price_brutto', 400); + return; + } + + $formData = $this->mapApiToFormData($body); + + $productId = $this->productRepo->saveProduct($formData); + if ($productId === null) { + ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create product', 500); + return; + } + + http_response_code(201); + echo json_encode([ + 'status' => 'ok', + 'data' => ['id' => $productId], + ], JSON_UNESCAPED_UNICODE); + } + + public function update(): 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; + } + + $existing = $this->productRepo->find($id); + if ($existing === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $formData = $this->mapApiToFormData($body, $existing); + $formData['id'] = $id; + + $this->productRepo->saveProduct($formData); + + $updated = $this->productRepo->findForApi($id); + + ApiRouter::sendSuccess($updated); + } + + /** + * Mapuje dane z JSON API na format oczekiwany przez saveProduct(). + * + * @param array $body Dane z JSON body + * @param array|null $existing Istniejące dane produktu (partial update) + * @return array Dane w formacie formularza + */ + private function mapApiToFormData(array $body, ?array $existing = null): array + { + $d = []; + + // Status/promoted — saveProduct expects 'on' for checkboxes + if (isset($body['status'])) { + $d['status'] = $body['status'] ? 'on' : ''; + } elseif ($existing !== null) { + $d['status'] = !empty($existing['status']) ? 'on' : ''; + } + + if (isset($body['promoted'])) { + $d['promoted'] = $body['promoted'] ? 'on' : ''; + } elseif ($existing !== null) { + $d['promoted'] = !empty($existing['promoted']) ? 'on' : ''; + } + + if (isset($body['stock_0_buy'])) { + $d['stock_0_buy'] = $body['stock_0_buy'] ? 'on' : ''; + } elseif ($existing !== null) { + $d['stock_0_buy'] = !empty($existing['stock_0_buy']) ? 'on' : ''; + } + + // Numeric fields — direct mapping + $numericFields = [ + 'price_brutto', 'price_netto', 'price_brutto_promo', 'price_netto_promo', + 'vat', 'quantity', 'weight', + ]; + foreach ($numericFields as $field) { + if (isset($body[$field])) { + $d[$field] = $body[$field]; + } elseif ($existing !== null && isset($existing[$field])) { + $d[$field] = $existing[$field]; + } + } + + // String fields — direct mapping + $stringFields = [ + 'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2', + 'custom_label_3', 'custom_label_4', 'wp', + ]; + foreach ($stringFields as $field) { + if (isset($body[$field])) { + $d[$field] = $body[$field]; + } elseif ($existing !== null && isset($existing[$field])) { + $d[$field] = $existing[$field]; + } + } + + // Foreign keys + if (isset($body['set_id'])) { + $d['set'] = $body['set_id']; + } elseif ($existing !== null && isset($existing['set_id'])) { + $d['set'] = $existing['set_id']; + } + + if (isset($body['producer_id'])) { + $d['producer_id'] = $body['producer_id']; + } elseif ($existing !== null && isset($existing['producer_id'])) { + $d['producer_id'] = $existing['producer_id']; + } + + if (isset($body['product_unit_id'])) { + $d['product_unit'] = $body['product_unit_id']; + } elseif ($existing !== null && isset($existing['product_unit_id'])) { + $d['product_unit'] = $existing['product_unit_id']; + } + + // Languages: body.languages.pl.name → d['name']['pl'] + if (isset($body['languages']) && is_array($body['languages'])) { + $langFields = [ + 'name', 'short_description', 'description', 'meta_description', + 'meta_keywords', 'meta_title', 'seo_link', 'copy_from', + 'warehouse_message_zero', 'warehouse_message_nonzero', + 'tab_name_1', 'tab_description_1', 'tab_name_2', 'tab_description_2', + 'canonical', 'security_information', + ]; + + foreach ($body['languages'] as $langId => $langData) { + if (!is_array($langData)) { + continue; + } + foreach ($langFields as $field) { + if (isset($langData[$field])) { + $d[$field][$langId] = $langData[$field]; + } + } + } + } + + // Categories + if (isset($body['categories']) && is_array($body['categories'])) { + $d['categories'] = $body['categories']; + } + + // Related products + if (isset($body['products_related']) && is_array($body['products_related'])) { + $d['products_related'] = $body['products_related']; + } + + return $d; + } +} diff --git a/docs/API.md b/docs/API.md index 9198c41..ece23f4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -139,6 +139,173 @@ Opcjonalnie w body: `{"send_email": true}` PUT api.php?endpoint=orders&action=set_unpaid&id={order_id} ``` +### Produkty + +#### Lista produktow +``` +GET api.php?endpoint=products&action=list +``` + +Parametry filtrowania (opcjonalne): +| Parametr | Typ | Opis | +|----------|-----|------| +| `search` | string | Szukaj po nazwie, EAN lub SKU | +| `status` | int (0/1) | Filtruj po statusie (1 = aktywny, 0 = nieaktywny) | +| `promoted` | int (0/1) | Filtruj po promocji | +| `sort` | string | Sortuj po: id, name, price_brutto, status, promoted, quantity (domyslnie id) | +| `sort_dir` | string | Kierunek: ASC lub DESC (domyslnie DESC) | +| `page` | int | Numer strony (domyslnie 1) | +| `per_page` | int | Wynikow na strone (domyslnie 50, max 100) | + +Odpowiedz: +```json +{ + "status": "ok", + "data": { + "items": [ + { + "id": 1, + "sku": "PROD-001", + "ean": "5901234123457", + "name": "Produkt testowy", + "price_brutto": 99.99, + "price_brutto_promo": null, + "price_netto": 81.29, + "price_netto_promo": null, + "quantity": 10, + "status": 1, + "promoted": 0, + "vat": 23, + "weight": 0.5, + "main_image": "product1.jpg", + "date_add": "2026-01-15 10:00:00", + "date_modify": "2026-02-19 12:00:00" + } + ], + "total": 1, + "page": 1, + "per_page": 50 + } +} +``` + +#### Szczegoly produktu +``` +GET api.php?endpoint=products&action=get&id={product_id} +``` + +Zwraca pelne dane produktu z jezykami, zdjeciami, kategoriami i atrybutami. + +Odpowiedz: +```json +{ + "status": "ok", + "data": { + "id": 1, + "sku": "PROD-001", + "ean": "5901234123457", + "price_brutto": 99.99, + "price_brutto_promo": null, + "price_netto": 81.29, + "price_netto_promo": null, + "quantity": 10, + "status": 1, + "promoted": 0, + "vat": 23, + "weight": 0.5, + "stock_0_buy": 0, + "custom_label_0": null, + "set_id": null, + "product_unit_id": 1, + "producer_id": 3, + "date_add": "2026-01-15 10:00:00", + "date_modify": "2026-02-19 12:00:00", + "languages": { + "pl": { + "name": "Produkt testowy", + "short_description": "Krotki opis", + "description": "

Pelny opis produktu

", + "meta_description": null, + "meta_keywords": null, + "meta_title": null, + "seo_link": "produkt-testowy", + "copy_from": null, + "warehouse_message_zero": null, + "warehouse_message_nonzero": null, + "tab_name_1": null, + "tab_description_1": null, + "tab_name_2": null, + "tab_description_2": null, + "canonical": null + } + }, + "images": [ + {"id": 1, "src": "product1.jpg", "alt": "Zdjecie produktu"} + ], + "categories": [1, 5], + "attributes": [ + {"attribute_id": 1, "value_id": 3} + ] + } +} +``` + +#### Tworzenie produktu +``` +POST api.php?endpoint=products&action=create +Content-Type: application/json + +{ + "price_brutto": 99.99, + "vat": 23, + "quantity": 10, + "status": 1, + "sku": "PROD-001", + "ean": "5901234123457", + "weight": 0.5, + "languages": { + "pl": { + "name": "Nowy produkt", + "description": "

Opis produktu

" + } + }, + "categories": [1, 5], + "products_related": [10, 20] +} +``` + +Wymagane: `languages` (min. 1 jezyk z `name`) oraz `price_brutto`. + +Odpowiedz (HTTP 201): +```json +{ + "status": "ok", + "data": { + "id": 42 + } +} +``` + +#### Aktualizacja produktu +``` +PUT api.php?endpoint=products&action=update&id={product_id} +Content-Type: application/json + +{ + "price_brutto": 129.99, + "status": 1, + "languages": { + "pl": { + "name": "Zaktualizowana nazwa" + } + } +} +``` + +Partial update — wystarczy przeslac tylko zmienione pola. Pola nieprzeslane zachowuja aktualna wartosc. + +Odpowiedz: pelne dane produktu (jak w `get`). + ### Slowniki #### Lista statusow zamowien @@ -195,4 +362,5 @@ UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key'; - Router: `\api\ApiRouter` (`autoload/api/ApiRouter.php`) - Kontrolery: `autoload/api/Controllers/` - `OrdersApiController` — zamowienia (5 akcji) + - `ProductsApiController` — produkty (4 akcje: list, get, create, update) - `DictionariesApiController` — slowniki (3 akcje) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4354b0f..165c634 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,20 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.297 (2026-02-19) - REST API produktów + +- **NEW**: Endpoint `products` w REST API — lista, szczegóły, tworzenie, aktualizacja produktów +- **NEW**: `\api\Controllers\ProductsApiController` — 4 akcje (list, get, create, update) +- **NEW**: `ProductRepository::listForApi()` — lista produktów z filtrowaniem (search/status/promoted), sortowaniem i paginacją +- **NEW**: `ProductRepository::findForApi()` — szczegóły produktu z językami, zdjęciami, kategoriami i atrybutami +- **NEW**: Partial update — `update` merguje przesłane pola z istniejącymi danymi produktu +- **NEW**: Mapowanie API → format formularza (`mapApiToFormData`) — status/promoted jako checkboxy, languages jako mapy +- **UPDATE**: `ApiRouter` — rejestracja endpointu `products` +- **UPDATE**: `docs/API.md` — dokumentacja 4 akcji produktowych z przykładami +- **Tests**: 21 nowych testów (`ProductsApiControllerTest`) + +--- + ## 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 diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 83c094d..fb17c91 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -82,6 +82,7 @@ REST API dla ordersPRO. Entry point: `api.php`. Stateless (bez sesji), autentyka ### Kontrolery (`api\Controllers\`) - `OrdersApiController` — lista, szczegoly, zmiana statusu, platnosc (5 akcji) +- `ProductsApiController` — lista, szczegoly, tworzenie, aktualizacja produktow (4 akcje) - `DictionariesApiController` — statusy, transporty, metody platnosci (3 akcje) Dokumentacja: `docs/API.md` diff --git a/docs/TESTING.md b/docs/TESTING.md index 48d267d..d742d2b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,10 +23,10 @@ composer test # standard ## Aktualny stan ```text -OK (666 tests, 1930 assertions) +OK (687 tests, 1971 assertions) ``` -Zweryfikowano: 2026-02-19 (ver. 0.296) +Zweryfikowano: 2026-02-19 (ver. 0.297) ## Konfiguracja @@ -89,6 +89,7 @@ tests/ | |-- ApiRouterTest.php | `-- Controllers/ | |-- OrdersApiControllerTest.php +| |-- ProductsApiControllerTest.php | `-- DictionariesApiControllerTest.php `-- Integration/ (puste — zarezerwowane) ``` diff --git a/docs/UPDATE_INSTRUCTIONS.md b/docs/UPDATE_INSTRUCTIONS.md index 1b69fe7..499ab85 100644 --- a/docs/UPDATE_INSTRUCTIONS.md +++ b/docs/UPDATE_INSTRUCTIONS.md @@ -18,17 +18,16 @@ Aktualizacje znajdują się w folderze `updates/0.XX/` gdzie XX oznacza dziesią ## Procedura tworzenia nowej aktualizacji -## Status biezacej aktualizacji (ver. 0.296) +## Status biezacej aktualizacji (ver. 0.297) -- Wersja udostepniona: `0.296` (data: 2026-02-19). +- Wersja udostepniona: `0.297` (data: 2026-02-19). - Pliki publikacyjne: - - `updates/0.20/ver_0.296.zip` - - `updates/0.20/ver_0.296_sql.txt` + - `updates/0.20/ver_0.297.zip` - Pliki metadanych aktualizacji: - `updates/changelog.php` - - `updates/versions.php` (`$current_ver = 296`) + - `updates/versions.php` (`$current_ver = 297`) - Weryfikacja testow przed publikacja: - - `OK (666 tests, 1930 assertions)` + - `OK (687 tests, 1971 assertions)` ### 1. Określ numer wersji Sprawdź ostatnią wersję w `updates/` i zwiększ o 1. diff --git a/tests/Unit/api/Controllers/ProductsApiControllerTest.php b/tests/Unit/api/Controllers/ProductsApiControllerTest.php new file mode 100644 index 0000000..962ede2 --- /dev/null +++ b/tests/Unit/api/Controllers/ProductsApiControllerTest.php @@ -0,0 +1,408 @@ +mockRepo = $this->createMock(ProductRepository::class); + $this->controller = new ProductsApiController($this->mockRepo); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = []; + } + + protected function tearDown(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = []; + http_response_code(200); + } + + // --- list --- + + public function testListReturnsProducts(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $this->mockRepo->method('listForApi') + ->willReturn([ + 'items' => [ + ['id' => 1, 'name' => 'Product A', 'price_brutto' => 99.99, 'status' => 1], + ], + '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['search'] = 'test'; + $_GET['status'] = '1'; + $_GET['promoted'] = '0'; + $_GET['sort'] = 'price_brutto'; + $_GET['sort_dir'] = 'ASC'; + $_GET['page'] = '2'; + $_GET['per_page'] = '25'; + + $this->mockRepo->expects($this->once()) + ->method('listForApi') + ->with( + $this->callback(function ($filters) { + return $filters['search'] === 'test' + && $filters['status'] === '1' + && $filters['promoted'] === '0'; + }), + 'price_brutto', + 'ASC', + 2, + 25 + ) + ->willReturn(['items' => [], 'total' => 0, 'page' => 2, 'per_page' => 25]); + + ob_start(); + $this->controller->list(); + ob_get_clean(); + } + + public function testListDefaultPagination(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $this->mockRepo->expects($this->once()) + ->method('listForApi') + ->with( + $this->anything(), + 'id', + 'DESC', + 1, + 50 + ) + ->willReturn(['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 50]); + + ob_start(); + $this->controller->list(); + ob_get_clean(); + } + + public function testListClampsPerPageTo100(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['per_page'] = '200'; + + $this->mockRepo->expects($this->once()) + ->method('listForApi') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + 100 + ) + ->willReturn(['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 100]); + + ob_start(); + $this->controller->list(); + ob_get_clean(); + } + + // --- get --- + + public function testGetReturnsProduct(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '42'; + + $this->mockRepo->method('findForApi') + ->with(42) + ->willReturn([ + 'id' => 42, + 'name' => 'Test Product', + 'price_brutto' => 199.99, + 'status' => 1, + 'languages' => [], + 'images' => [], + 'categories' => [], + 'attributes' => [], + ]); + + 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 testGetReturns404WhenProductNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '999'; + + $this->mockRepo->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()); + } + + public function testGetRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_GET['id'] = '1'; + + ob_start(); + $this->controller->get(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + // --- create --- + + public function testCreateRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $this->controller->create(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testCreateReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + // php://input returns empty in test environment → getJsonBody() returns null + ob_start(); + $this->controller->create(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('BAD_REQUEST', $json['code']); + } + + // --- update --- + + public function testUpdateRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '1'; + + ob_start(); + $this->controller->update(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testUpdateReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + ob_start(); + $this->controller->update(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testUpdateReturns404WhenProductNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '999'; + + $this->mockRepo->method('find') + ->with(999) + ->willReturn(null); + + ob_start(); + $this->controller->update(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('NOT_FOUND', $json['code']); + } + + public function testUpdateReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '1'; + + $this->mockRepo->method('find') + ->with(1) + ->willReturn(['id' => 1, 'status' => 1, 'promoted' => 0]); + + // php://input returns empty in test environment → getJsonBody() returns null + ob_start(); + $this->controller->update(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + $json = json_decode($output, true); + $this->assertSame('BAD_REQUEST', $json['code']); + } + + // --- mapApiToFormData (tested indirectly via reflection) --- + + public function testMapApiToFormDataConvertsStatusToCheckbox(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, ['status' => 1, 'promoted' => 0]); + + $this->assertSame('on', $result['status']); + $this->assertSame('', $result['promoted']); + } + + public function testMapApiToFormDataMapsLanguages(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $body = [ + 'languages' => [ + 'pl' => ['name' => 'Nazwa PL', 'description' => 'Opis PL'], + 'en' => ['name' => 'Name EN'], + ], + ]; + + $result = $method->invoke($this->controller, $body); + + $this->assertSame('Nazwa PL', $result['name']['pl']); + $this->assertSame('Opis PL', $result['description']['pl']); + $this->assertSame('Name EN', $result['name']['en']); + } + + public function testMapApiToFormDataMapsNumericFields(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $body = [ + 'price_brutto' => 99.99, + 'vat' => 23, + 'quantity' => 10, + 'sku' => 'PROD-001', + 'ean' => '5901234123457', + ]; + + $result = $method->invoke($this->controller, $body); + + $this->assertSame(99.99, $result['price_brutto']); + $this->assertSame(23, $result['vat']); + $this->assertSame(10, $result['quantity']); + $this->assertSame('PROD-001', $result['sku']); + $this->assertSame('5901234123457', $result['ean']); + } + + public function testMapApiToFormDataMapsCategories(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $body = [ + 'categories' => [1, 5, 12], + ]; + + $result = $method->invoke($this->controller, $body); + + $this->assertSame([1, 5, 12], $result['categories']); + } + + public function testMapApiToFormDataPartialUpdatePreservesExisting(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $existing = [ + 'status' => 1, + 'promoted' => 0, + 'price_brutto' => 50.00, + 'vat' => 23, + 'quantity' => 5, + 'sku' => 'OLD-SKU', + ]; + + // Only update price + $body = ['price_brutto' => 75.00]; + + $result = $method->invoke($this->controller, $body, $existing); + + $this->assertSame(75.00, $result['price_brutto']); + $this->assertSame('on', $result['status']); // preserved from existing + $this->assertSame('', $result['promoted']); // preserved from existing (0 → '') + $this->assertSame(23, $result['vat']); // preserved + $this->assertSame('OLD-SKU', $result['sku']); // preserved + } + + public function testMapApiToFormDataMapsForeignKeys(): void + { + $method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData'); + $method->setAccessible(true); + + $body = [ + 'set_id' => 3, + 'producer_id' => 7, + 'product_unit_id' => 2, + ]; + + $result = $method->invoke($this->controller, $body); + + $this->assertSame(3, $result['set']); + $this->assertSame(7, $result['producer_id']); + $this->assertSame(2, $result['product_unit']); + } +} diff --git a/updates/0.20/ver_0.297.zip b/updates/0.20/ver_0.297.zip new file mode 100644 index 0000000000000000000000000000000000000000..eb7067bef494d792a952d647bb2965116c3feb1e GIT binary patch literal 23453 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. 0.297 - 19.02.2026
+- NEW - REST API produktów (lista, szczegóły, tworzenie, aktualizacja) +- NEW - Endpoint products z filtrowaniem, sortowaniem i paginacją +- NEW - Partial update produktów (tylko zmienione pola) +
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) diff --git a/updates/versions.php b/updates/versions.php index 7fb0d47..e9d572c 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@