ver. 0.297: REST API products endpoint — list, get, create, update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 22:39:48 +01:00
parent 9cac0d1eeb
commit ebab220f7e
13 changed files with 1115 additions and 10 deletions

View File

@@ -36,7 +36,7 @@ composer test
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. 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 ### 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. 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.

View File

@@ -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(). * Szczegóły produktu (admin) — zastępuje factory product_details().
*/ */

View File

@@ -90,6 +90,10 @@ class ApiRouter
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo); $service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
return new Controllers\OrdersApiController($service, $orderRepo); 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) { 'dictionaries' => function () use ($db) {
$statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db); $statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db); $transportRepo = new \Domain\Transport\TransportRepository($db);

View File

@@ -0,0 +1,251 @@
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\Product\ProductRepository;
class ProductsApiController
{
private $productRepo;
public function __construct(ProductRepository $productRepo)
{
$this->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;
}
}

View File

@@ -139,6 +139,173 @@ Opcjonalnie w body: `{"send_email": true}`
PUT api.php?endpoint=orders&action=set_unpaid&id={order_id} 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": "<p>Pelny opis produktu</p>",
"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": "<p>Opis produktu</p>"
}
},
"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 ### Slowniki
#### Lista statusow zamowien #### 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`) - Router: `\api\ApiRouter` (`autoload/api/ApiRouter.php`)
- Kontrolery: `autoload/api/Controllers/` - Kontrolery: `autoload/api/Controllers/`
- `OrdersApiController` — zamowienia (5 akcji) - `OrdersApiController` — zamowienia (5 akcji)
- `ProductsApiController` — produkty (4 akcje: list, get, create, update)
- `DictionariesApiController` — slowniki (3 akcje) - `DictionariesApiController` — slowniki (3 akcje)

View File

@@ -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 ## 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**: REST API do zarządzania zamówieniami — lista, szczegóły, zmiana statusu, oznaczanie płatności

View File

@@ -82,6 +82,7 @@ REST API dla ordersPRO. Entry point: `api.php`. Stateless (bez sesji), autentyka
### Kontrolery (`api\Controllers\`) ### Kontrolery (`api\Controllers\`)
- `OrdersApiController` — lista, szczegoly, zmiana statusu, platnosc (5 akcji) - `OrdersApiController` — lista, szczegoly, zmiana statusu, platnosc (5 akcji)
- `ProductsApiController` — lista, szczegoly, tworzenie, aktualizacja produktow (4 akcje)
- `DictionariesApiController` — statusy, transporty, metody platnosci (3 akcje) - `DictionariesApiController` — statusy, transporty, metody platnosci (3 akcje)
Dokumentacja: `docs/API.md` Dokumentacja: `docs/API.md`

View File

@@ -23,10 +23,10 @@ composer test # standard
## Aktualny stan ## Aktualny stan
```text ```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 ## Konfiguracja
@@ -89,6 +89,7 @@ tests/
| |-- ApiRouterTest.php | |-- ApiRouterTest.php
| `-- Controllers/ | `-- Controllers/
| |-- OrdersApiControllerTest.php | |-- OrdersApiControllerTest.php
| |-- ProductsApiControllerTest.php
| `-- DictionariesApiControllerTest.php | `-- DictionariesApiControllerTest.php
`-- Integration/ (puste — zarezerwowane) `-- Integration/ (puste — zarezerwowane)
``` ```

View File

@@ -18,17 +18,16 @@ Aktualizacje znajdują się w folderze `updates/0.XX/` gdzie XX oznacza dziesią
## Procedura tworzenia nowej aktualizacji ## 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: - Pliki publikacyjne:
- `updates/0.20/ver_0.296.zip` - `updates/0.20/ver_0.297.zip`
- `updates/0.20/ver_0.296_sql.txt`
- Pliki metadanych aktualizacji: - Pliki metadanych aktualizacji:
- `updates/changelog.php` - `updates/changelog.php`
- `updates/versions.php` (`$current_ver = 296`) - `updates/versions.php` (`$current_ver = 297`)
- Weryfikacja testow przed publikacja: - Weryfikacja testow przed publikacja:
- `OK (666 tests, 1930 assertions)` - `OK (687 tests, 1971 assertions)`
### 1. Określ numer wersji ### 1. Określ numer wersji
Sprawdź ostatnią wersję w `updates/` i zwiększ o 1. Sprawdź ostatnią wersję w `updates/` i zwiększ o 1.

View File

@@ -0,0 +1,408 @@
<?php
namespace Tests\Unit\api\Controllers;
use PHPUnit\Framework\TestCase;
use api\Controllers\ProductsApiController;
use Domain\Product\ProductRepository;
class ProductsApiControllerTest extends TestCase
{
private $mockRepo;
private $controller;
protected function setUp(): void
{
$this->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']);
}
}

BIN
updates/0.20/ver_0.297.zip Normal file

Binary file not shown.

View File

@@ -1,3 +1,8 @@
<b>ver. 0.297 - 19.02.2026</b><br />
- 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)
<hr>
<b>ver. 0.296 - 19.02.2026</b><br /> <b>ver. 0.296 - 19.02.2026</b><br />
- NEW - REST API zamówień dla ordersPRO (lista, szczegóły, zmiana statusu, płatności) - 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 - Endpointy słownikowe (statusy, transporty, metody płatności)

View File

@@ -1,5 +1,5 @@
<? <?
$current_ver = 296; $current_ver = 297;
for ($i = 1; $i <= $current_ver; $i++) for ($i = 1; $i <= $current_ver; $i++)
{ {