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:
@@ -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.
|
||||||
|
|||||||
@@ -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().
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
251
autoload/api/Controllers/ProductsApiController.php
Normal file
251
autoload/api/Controllers/ProductsApiController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
docs/API.md
168
docs/API.md
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
408
tests/Unit/api/Controllers/ProductsApiControllerTest.php
Normal file
408
tests/Unit/api/Controllers/ProductsApiControllerTest.php
Normal 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
BIN
updates/0.20/ver_0.297.zip
Normal file
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?
|
<?
|
||||||
$current_ver = 296;
|
$current_ver = 297;
|
||||||
|
|
||||||
for ($i = 1; $i <= $current_ver; $i++)
|
for ($i = 1; $i <= $current_ver; $i++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user