ver. 0.296: REST API for ordersPRO — orders management, dictionaries, API key auth

- New API layer: ApiRouter, OrdersApiController, DictionariesApiController
- Orders API: list (with filters/pagination/updated_since), details, change status, set paid/unpaid
- Dictionaries API: order statuses, transport methods, payment methods
- X-Api-Key authentication via pp_settings.api_key
- OrderRepository: listForApi(), findForApi(), touchUpdatedAt()
- updated_at column on pp_shop_orders for polling support
- api.php: skip session for API requests, route to ApiRouter
- SettingsController: api_key field in system tab
- 30 new tests (666 total, 1930 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 20:25:07 +01:00
parent 21efe28464
commit 9cac0d1eeb
22 changed files with 1457 additions and 54 deletions

View File

@@ -0,0 +1,177 @@
<?php
namespace Tests\Unit\api;
use PHPUnit\Framework\TestCase;
use api\ApiRouter;
use Domain\Settings\SettingsRepository;
class ApiRouterTest extends TestCase
{
private function createRouter(string $storedApiKey = 'test-api-key-123'): ApiRouter
{
$mockDb = $this->createMock(\medoo::class);
$mockSettings = $this->createMock(SettingsRepository::class);
$mockSettings->method('getSingleValue')
->with('api_key')
->willReturn($storedApiKey);
return new ApiRouter($mockDb, $mockSettings);
}
public function testHandleReturns401WhenNoApiKey(): void
{
unset($_SERVER['HTTP_X_API_KEY']);
$_GET['endpoint'] = 'orders';
$_GET['action'] = 'list';
$router = $this->createRouter();
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(401, http_response_code());
$json = json_decode($output, true);
$this->assertSame('error', $json['status']);
$this->assertSame('UNAUTHORIZED', $json['code']);
}
public function testHandleReturns401WhenWrongApiKey(): void
{
$_SERVER['HTTP_X_API_KEY'] = 'wrong-key';
$_GET['endpoint'] = 'orders';
$_GET['action'] = 'list';
$router = $this->createRouter();
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(401, http_response_code());
$json = json_decode($output, true);
$this->assertSame('UNAUTHORIZED', $json['code']);
}
public function testHandleReturns401WhenStoredKeyEmpty(): void
{
$_SERVER['HTTP_X_API_KEY'] = 'any-key';
$_GET['endpoint'] = 'orders';
$_GET['action'] = 'list';
$router = $this->createRouter('');
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(401, http_response_code());
}
public function testHandleReturns400WhenMissingEndpoint(): void
{
$_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123';
unset($_GET['endpoint']);
$_GET['action'] = 'list';
$_GET['endpoint'] = '';
$router = $this->createRouter();
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
$json = json_decode($output, true);
$this->assertSame('BAD_REQUEST', $json['code']);
}
public function testHandleReturns400WhenMissingAction(): void
{
$_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123';
$_GET['endpoint'] = 'orders';
$_GET['action'] = '';
$router = $this->createRouter();
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
}
public function testHandleReturns404ForUnknownEndpoint(): void
{
$_SERVER['HTTP_X_API_KEY'] = 'test-api-key-123';
$_GET['endpoint'] = 'unknown';
$_GET['action'] = 'list';
$router = $this->createRouter();
ob_start();
$router->handle();
$output = ob_get_clean();
$this->assertSame(404, http_response_code());
$json = json_decode($output, true);
$this->assertSame('NOT_FOUND', $json['code']);
}
public function testSendSuccessOutputsCorrectJson(): void
{
ob_start();
ApiRouter::sendSuccess(['foo' => 'bar']);
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertSame('bar', $json['data']['foo']);
}
public function testSendErrorOutputsCorrectJson(): void
{
ob_start();
ApiRouter::sendError('BAD_REQUEST', 'Test error', 400);
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
$json = json_decode($output, true);
$this->assertSame('error', $json['status']);
$this->assertSame('BAD_REQUEST', $json['code']);
$this->assertSame('Test error', $json['message']);
}
public function testRequireMethodReturnsTrueForMatchingMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
ob_start();
$result = ApiRouter::requireMethod('GET');
ob_get_clean();
$this->assertTrue($result);
}
public function testRequireMethodReturnsFalseAndSendsErrorForMismatch(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
ob_start();
$result = ApiRouter::requireMethod('GET');
$output = ob_get_clean();
$this->assertFalse($result);
$this->assertSame(405, http_response_code());
$json = json_decode($output, true);
$this->assertSame('METHOD_NOT_ALLOWED', $json['code']);
}
protected function tearDown(): void
{
unset($_SERVER['HTTP_X_API_KEY']);
unset($_SERVER['REQUEST_METHOD']);
$_GET = [];
http_response_code(200);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Tests\Unit\api\Controllers;
use PHPUnit\Framework\TestCase;
use api\Controllers\DictionariesApiController;
use Domain\ShopStatus\ShopStatusRepository;
use Domain\Transport\TransportRepository;
use Domain\PaymentMethod\PaymentMethodRepository;
class DictionariesApiControllerTest extends TestCase
{
private $mockStatusRepo;
private $mockTransportRepo;
private $mockPaymentRepo;
private $controller;
protected function setUp(): void
{
$this->mockStatusRepo = $this->createMock(ShopStatusRepository::class);
$this->mockTransportRepo = $this->createMock(TransportRepository::class);
$this->mockPaymentRepo = $this->createMock(PaymentMethodRepository::class);
$this->controller = new DictionariesApiController(
$this->mockStatusRepo,
$this->mockTransportRepo,
$this->mockPaymentRepo
);
$_SERVER['REQUEST_METHOD'] = 'GET';
}
protected function tearDown(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
http_response_code(200);
}
// --- statuses ---
public function testStatusesReturnsFormattedList(): void
{
$this->mockStatusRepo->method('allStatuses')
->willReturn([
0 => 'Nowe',
1 => 'Opłacone',
4 => 'W realizacji',
6 => 'Wysłane',
]);
ob_start();
$this->controller->statuses();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertCount(4, $json['data']);
$this->assertSame(0, $json['data'][0]['id']);
$this->assertSame('Nowe', $json['data'][0]['name']);
$this->assertSame(6, $json['data'][3]['id']);
$this->assertSame('Wysłane', $json['data'][3]['name']);
}
public function testStatusesRejectsPostMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
ob_start();
$this->controller->statuses();
$output = ob_get_clean();
$this->assertSame(405, http_response_code());
}
// --- transports ---
public function testTransportsReturnsFormattedList(): void
{
$this->mockTransportRepo->method('allActive')
->willReturn([
['id' => 1, 'name_visible' => 'InPost Paczkomat', 'cost' => '12.99'],
['id' => 2, 'name_visible' => 'Kurier DPD', 'cost' => '15.00'],
]);
ob_start();
$this->controller->transports();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertCount(2, $json['data']);
$this->assertSame(1, $json['data'][0]['id']);
$this->assertSame('InPost Paczkomat', $json['data'][0]['name']);
$this->assertSame(12.99, $json['data'][0]['cost']);
}
public function testTransportsRejectsPostMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
ob_start();
$this->controller->transports();
$output = ob_get_clean();
$this->assertSame(405, http_response_code());
}
// --- payment_methods ---
public function testPaymentMethodsReturnsFormattedList(): void
{
$this->mockPaymentRepo->method('allActive')
->willReturn([
['id' => 1, 'name' => 'Przelew bankowy'],
['id' => 2, 'name' => 'Przelewy24'],
['id' => 3, 'name' => 'Przy odbiorze'],
]);
ob_start();
$this->controller->payment_methods();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertCount(3, $json['data']);
$this->assertSame(1, $json['data'][0]['id']);
$this->assertSame('Przelew bankowy', $json['data'][0]['name']);
}
public function testPaymentMethodsRejectsPostMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
ob_start();
$this->controller->payment_methods();
$output = ob_get_clean();
$this->assertSame(405, http_response_code());
}
}

View File

@@ -0,0 +1,290 @@
<?php
namespace Tests\Unit\api\Controllers;
use PHPUnit\Framework\TestCase;
use api\Controllers\OrdersApiController;
use Domain\Order\OrderAdminService;
use Domain\Order\OrderRepository;
class OrdersApiControllerTest extends TestCase
{
private $mockService;
private $mockOrderRepo;
private $controller;
protected function setUp(): void
{
$this->mockService = $this->createMock(OrderAdminService::class);
$this->mockOrderRepo = $this->createMock(OrderRepository::class);
$this->controller = new OrdersApiController($this->mockService, $this->mockOrderRepo);
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET = [];
}
protected function tearDown(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET = [];
http_response_code(200);
}
// --- list ---
public function testListReturnsOrders(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$this->mockOrderRepo->method('listForApi')
->willReturn([
'items' => [
['id' => 1, 'number' => '2026/01/001', 'status' => 0, 'paid' => 0, 'summary' => 99.99],
],
'total' => 1,
'page' => 1,
'per_page' => 50,
]);
ob_start();
$this->controller->list();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertCount(1, $json['data']['items']);
$this->assertSame(1, $json['data']['total']);
}
public function testListRejectsPostMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
ob_start();
$this->controller->list();
$output = ob_get_clean();
$this->assertSame(405, http_response_code());
}
public function testListPassesFiltersToRepository(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET['status'] = '4';
$_GET['paid'] = '1';
$_GET['page'] = '2';
$_GET['per_page'] = '25';
$this->mockOrderRepo->expects($this->once())
->method('listForApi')
->with(
$this->callback(function ($filters) {
return $filters['status'] === '4' && $filters['paid'] === '1';
}),
2,
25
)
->willReturn(['items' => [], 'total' => 0, 'page' => 2, 'per_page' => 25]);
ob_start();
$this->controller->list();
ob_get_clean();
}
// --- get ---
public function testGetReturnsOrder(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET['id'] = '42';
$this->mockOrderRepo->method('findForApi')
->with(42)
->willReturn([
'id' => 42,
'number' => '2026/01/001',
'status' => 4,
'paid' => 1,
'summary' => 150.00,
'products' => [],
'statuses' => [],
]);
ob_start();
$this->controller->get();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertSame(42, $json['data']['id']);
}
public function testGetReturns404WhenOrderNotFound(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET['id'] = '999';
$this->mockOrderRepo->method('findForApi')
->with(999)
->willReturn(null);
ob_start();
$this->controller->get();
$output = ob_get_clean();
$this->assertSame(404, http_response_code());
$json = json_decode($output, true);
$this->assertSame('NOT_FOUND', $json['code']);
}
public function testGetReturns400WhenMissingId(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
ob_start();
$this->controller->get();
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
}
// --- change_status ---
public function testChangeStatusUpdatesOrder(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
$_GET['id'] = '10';
$this->mockOrderRepo->method('findRawById')
->with(10)
->willReturn(['id' => 10, 'status' => 0]);
$this->mockService->method('changeStatus')
->with(10, 5, false)
->willReturn(['result' => true]);
// Simulate JSON body via php://input override is not possible in unit tests,
// so we test the controller logic path via mock expectations
ob_start();
$this->controller->change_status();
$output = ob_get_clean();
// Without a real php://input body, getJsonBody returns null → BAD_REQUEST
$json = json_decode($output, true);
$this->assertSame('error', $json['status']);
$this->assertSame('BAD_REQUEST', $json['code']);
}
public function testChangeStatusReturns400WhenMissingId(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
ob_start();
$this->controller->change_status();
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
}
public function testChangeStatusRejectsGetMethod(): void
{
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET['id'] = '10';
ob_start();
$this->controller->change_status();
$output = ob_get_clean();
$this->assertSame(405, http_response_code());
}
// --- set_paid ---
public function testSetPaidReturns404WhenOrderNotFound(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
$_GET['id'] = '999';
$this->mockOrderRepo->method('findRawById')
->with(999)
->willReturn(null);
ob_start();
$this->controller->set_paid();
$output = ob_get_clean();
$this->assertSame(404, http_response_code());
}
public function testSetPaidReturns400WhenMissingId(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
ob_start();
$this->controller->set_paid();
$output = ob_get_clean();
$this->assertSame(400, http_response_code());
}
public function testSetPaidCallsServiceWhenOrderExists(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
$_GET['id'] = '10';
$this->mockOrderRepo->method('findRawById')
->with(10)
->willReturn(['id' => 10, 'paid' => 0]);
$this->mockService->expects($this->once())
->method('setOrderAsPaid')
->with(10, false);
ob_start();
$this->controller->set_paid();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertSame(1, $json['data']['paid']);
}
// --- set_unpaid ---
public function testSetUnpaidReturns404WhenOrderNotFound(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
$_GET['id'] = '999';
$this->mockOrderRepo->method('findRawById')
->with(999)
->willReturn(null);
ob_start();
$this->controller->set_unpaid();
$output = ob_get_clean();
$this->assertSame(404, http_response_code());
}
public function testSetUnpaidCallsServiceWhenOrderExists(): void
{
$_SERVER['REQUEST_METHOD'] = 'PUT';
$_GET['id'] = '10';
$this->mockOrderRepo->method('findRawById')
->with(10)
->willReturn(['id' => 10, 'paid' => 1]);
$this->mockService->expects($this->once())
->method('setOrderAsUnpaid')
->with(10);
ob_start();
$this->controller->set_unpaid();
$output = ob_get_clean();
$json = json_decode($output, true);
$this->assertSame('ok', $json['status']);
$this->assertSame(0, $json['data']['paid']);
}
}