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 0000000..eb7067b Binary files /dev/null and b/updates/0.20/ver_0.297.zip differ diff --git a/updates/changelog.php b/updates/changelog.php index 930ac01..2e6259e 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,8 @@ +ver. 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 @@