diff --git a/README.md b/README.md index 7526279..755196b 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,9 @@ The API for TastyIgniter comes packed with a range of features to enhance your e Documentation can be found on the [TastyIgniter documentation website](https://tastyigniter.com/docs/extensions/api). -## Changelog - -Please see [CHANGELOG](https://github.com/tastyigniter/ti-ext-api/blob/master/CHANGELOG.md) for more information on what has changed recently. - ## Reporting issues -If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/tastyigniter/ti-ext-api/issues) on GitHub. +If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/tastyigniter/TastyIgniter/issues) on GitHub. ## Contributing diff --git a/docs/addresses.md b/docs/addresses.md new file mode 100644 index 0000000..f731423 --- /dev/null +++ b/docs/addresses.md @@ -0,0 +1,221 @@ +## Addresses + +This endpoint lets you `list`, `create`, `retrieve`, `update` and `delete` customer addresses on your TastyIgniter site. + +Responses follow the [JSON:API specification](https://jsonapi.org). + +### The address object + +#### Attributes + +| Key | Type | Description | +|--------------|----------|-----------------------------------------------------------------------------| +| `address_1` | `string` | **Required**. First line of the address (between 3 and 128 characters). | +| `address_2` | `string` | Second line of the address (between 1 and 128 characters). | +| `city` | `string` | **Required**. City or town (between 2 and 128 characters). | +| `state` | `string` | State or county (max 128 characters). | +| `postcode` | `string` | Postcode or ZIP code (max 128 characters). | +| `country_id` | `integer`| **Required**. Country ID from the countries table. | +| `customer_id`| `integer`| The customer this address belongs to. Set automatically for customer tokens.| + +#### The country relation + +When you request `?include=country`, each address may include a `country` relation with attributes such as `country_name`, `iso_code_2`, `iso_code_3`, `format`, `status`, `priority`. + +### List addresses + +Retrieves a list of addresses. Customers see only their own addresses; staff see all according to token abilities. + +Required abilities: `addresses:read` + +``` +GET /api/addresses +``` + +#### Parameters + +| Key | Type | Description | +|-------------|-----------|-----------------------------------------------------------------------------| +| `page` | `integer` | Page number. | +| `pageLimit` | `integer` | Number of items per page. | +| `include` | `string` | Relations to include. Options: `country`. Example: `?include=country` | + +#### Response + +```html +Status: 200 OK +``` + +```json +{ + "data": [ + { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": null, + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } + ], + "meta": { + "pagination": { + "total": 1, + "count": 1, + "per_page": 20, + "current_page": 1, + "total_pages": 1 + } + }, + "links": { + "self": "https://your.url/api/addresses?page=1", + "first": "https://your.url/api/addresses?page=1", + "last": "https://your.url/api/addresses?page=1" + } +} +``` + +### Create an address + +Creates a new address. For customer tokens, `customer_id` is set automatically. + +Required abilities: `addresses:write` + +``` +POST /api/addresses +``` + +#### Parameters + +| Key | Type | Description | +|--------------|----------|------------------------------------------------------------| +| `address_1` | `string` | **Required**. First line (3–128 characters). | +| `address_2` | `string` | Second line (optional). | +| `city` | `string` | **Required**. City (2–128 characters). | +| `state` | `string` | State or county (optional). | +| `postcode` | `string` | Postcode or ZIP (optional). | +| `country_id` | `integer`| **Required**. Country ID. | +| `customer_id`| `integer` | Customer ID (admin only; ignored for customer tokens). | + +#### Payload example + +```json +{ + "address_1": "1 Some Road", + "address_2": "Flat 2", + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222 +} +``` + +#### Response + +```html +Status: 201 Created +``` + +```json +{ + "data": { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": "Flat 2", + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } +} +``` + +### Retrieve an address + +Fetches a single address by ID. + +Required abilities: `addresses:read` + +``` +GET /api/addresses/:id +``` + +#### Parameters + +| Key | Type | Description | +|-----------|----------|----------------------------------------------------------------| +| `include` | `string` | Relations to include. Options: `country`. Example: `?include=country` | + +#### Response + +```html +Status: 200 OK +``` + +```json +{ + "data": { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": null, + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } +} +``` + +### Update an address + +Updates an existing address. + +Required abilities: `addresses:write` + +``` +PUT /api/addresses/:id +PATCH /api/addresses/:id +``` + +#### Parameters + +Same as create; all fields are optional when updating. + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated address object in the same shape as the retrieve response. + +### Delete an address + +Permanently deletes an address. + +Required abilities: `addresses:write` + +``` +DELETE /api/addresses/:id +``` + +#### Response + +```html +Status: 204 No Content +``` + +No response body. diff --git a/docs/orders.md b/docs/orders.md index eae5d3a..f094f3f 100644 --- a/docs/orders.md +++ b/docs/orders.md @@ -501,6 +501,41 @@ Status: 200 OK } ``` +### Update order status + +Updates only the order status. Use this when you only need to change the status (e.g. from pending to completed) without sending other order fields. + +Required abilities: `orders:write` + +``` +PATCH /api/orders/:order_id/status +``` + +#### Parameters + +| Key | Type | Description | +|-------------------|----------|-----------------------------------------------------------------------------| +| `status_id` | `integer`| **Required**. The Unique Identifier of the status to assign to the order. | +| `comment` | `string` | Optional comment to attach to the status change (max 500 characters). | +| `notify` | `boolean`| Optional. Whether to notify the customer of the status change (default: false). | + +#### Payload example + +```json +{ + "status_id": 4, + "status_comment": "Order ready for collection" +} +``` + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated order object in the same shape as the retrieve/update response (including the new `status_id` and `status_updated_at`). + ### Delete an order Permanently deletes an order. It cannot be undone. diff --git a/docs/reservations.md b/docs/reservations.md index bb173be..aecb2b2 100644 --- a/docs/reservations.md +++ b/docs/reservations.md @@ -364,7 +364,6 @@ PATCH /api/reservations/:reservation_id | `email` | `string` | The reservation's email address | | `telephone` | `string` | The reservation's telephone number | | `newsletter` | `boolean` | Whether the reservation opts into newsletter marketing | -| `reservation_group_id` | `integer` | The group the reservation belongs to, if any. | | `status` | `boolean` | Has the value `true` if the reservation is enabled or the value `false` if the reservation is disabled. | | `addresses` | `array` | The reservation's addresses, if any | @@ -418,6 +417,42 @@ Status: 200 OK } ``` +### Update reservation status + +Updates only the reservation status. Use this when you only need to change the status (e.g. from pending to confirmed) without sending other reservation fields. Only staff tokens may update reservation status; customer tokens receive 403. + +Required abilities: `reservations:write` + +``` +PATCH /api/reservations/:reservation_id/status +``` + +#### Parameters + +| Key | Type | Description | +|--------------|----------|-----------------------------------------------------------------------------| +| `status_id` | `integer`| **Required**. The Unique Identifier of the status to assign (must exist in `statuses`). | +| `comment` | `string` | Optional comment to attach to the status change (max 500 characters). | +| `notify` | `boolean`| Whether to notify the customer of the status change. | + +#### Payload example + +```json +{ + "status_id": 8, + "comment": "Table confirmed", + "notify": true +} +``` + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated reservation object in the same shape as the retrieve/update response (including the new `status_id` and `status_updated_at`). + ### Delete a reservation Permanently deletes a reservation. It cannot be undone. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a554ec5..3141b33 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Access to an undefined property Igniter\\User\\Models\\Address\:\:\$country\.$#' + identifier: property.notFound + count: 1 + path: src/ApiResources/Transformers/AddressTransformer.php + - message: '#^Access to an undefined property Igniter\\Cart\\Models\\Category\:\:\$locations\.$#' identifier: property.notFound diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index c83f64d..40a9d34 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -57,4 +57,8 @@ 'orders' => [ 'label_customer_id' => 'Customer ID', ], + + 'addresses' => [ + 'label_customer_id' => 'Customer ID', + ], ]; diff --git a/src/ApiResources/Addresses.php b/src/ApiResources/Addresses.php new file mode 100644 index 0000000..2dc5a78 --- /dev/null +++ b/src/ApiResources/Addresses.php @@ -0,0 +1,37 @@ + [ + 'index' => [ + 'pageLimit' => 20, + ], + 'store' => [], + 'show' => [], + 'update' => [], + 'destroy' => [], + ], + 'request' => AddressRequest::class, + 'repository' => AddressRepository::class, + 'transformer' => AddressTransformer::class, + 'resourceKey' => 'addresses', + ]; + + protected string|array $requiredAbilities = ['addresses:*']; +} diff --git a/src/ApiResources/Orders.php b/src/ApiResources/Orders.php index 72b2252..5015476 100644 --- a/src/ApiResources/Orders.php +++ b/src/ApiResources/Orders.php @@ -6,9 +6,12 @@ use Igniter\Api\ApiResources\Repositories\OrderRepository; use Igniter\Api\ApiResources\Requests\OrderRequest; +use Igniter\Api\ApiResources\Requests\StatusRequest; use Igniter\Api\ApiResources\Transformers\OrderTransformer; use Igniter\Api\Classes\ApiController; use Igniter\Api\Http\Actions\RestController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Orders API Controller @@ -26,6 +29,7 @@ class Orders extends ApiController 'show' => [], 'update' => [], 'destroy' => [], + 'updateStatus' => [], ], 'request' => OrderRequest::class, 'repository' => OrderRepository::class, @@ -34,6 +38,30 @@ class Orders extends ApiController protected string|array $requiredAbilities = ['orders:*']; + public function updateStatus(StatusRequest $request, int $orderId): Response + { + throw_if( + ($token = $this->getToken()) && $token->isForCustomer(), + new AccessDeniedHttpException('Customers are not allowed to update order status.') + ); + + $data = $request->validated(); + + $order = app(OrderRepository::class)->find($orderId); + + $data['staff_id'] = $this->user()->getKey(); + + $order->updateOrderStatus((int) $data['status_id'], array_only($data, ['comment', 'notify', 'staff_id'])); + + $response = $this->fractal() + ->item($order->refresh()) + ->transformWith(new OrderTransformer) + ->withResourceName('orders') + ->toArray(); + + return response()->json($response); + } + public function restAfterSave($model): void { if ($orderMenus = (array)request()->input('order_menus', [])) { diff --git a/src/ApiResources/Repositories/AddressRepository.php b/src/ApiResources/Repositories/AddressRepository.php new file mode 100644 index 0000000..d934e8f --- /dev/null +++ b/src/ApiResources/Repositories/AddressRepository.php @@ -0,0 +1,15 @@ + 'customer_id']; +} diff --git a/src/ApiResources/Requests/AddressRequest.php b/src/ApiResources/Requests/AddressRequest.php new file mode 100644 index 0000000..4872f20 --- /dev/null +++ b/src/ApiResources/Requests/AddressRequest.php @@ -0,0 +1,38 @@ + lang('igniter.cart::default.checkout.label_address_1'), + 'address_2' => lang('igniter.cart::default.checkout.label_address_2'), + 'city' => lang('igniter.cart::default.checkout.label_city'), + 'state' => lang('igniter.cart::default.checkout.label_state'), + 'postcode' => lang('igniter.cart::default.checkout.label_postcode'), + 'country_id' => lang('igniter.cart::default.checkout.label_country'), + 'customer_id' => lang('igniter.api::default.addresses.label_customer_id'), + ]; + } + + public function rules(): array + { + return [ + 'address_1' => ['required', 'min:3', 'max:128'], + 'address_2' => ['sometimes', 'nullable', 'min:1', 'max:128'], + 'city' => ['required', 'min:2', 'max:128'], + 'state' => ['sometimes', 'nullable', 'max:128'], + 'postcode' => ['sometimes', 'nullable', 'string', 'max:128'], + 'country_id' => ['required', 'integer'], + 'customer_id' => ['integer'], + ]; + } +} diff --git a/src/ApiResources/Requests/StatusRequest.php b/src/ApiResources/Requests/StatusRequest.php new file mode 100644 index 0000000..fca0617 --- /dev/null +++ b/src/ApiResources/Requests/StatusRequest.php @@ -0,0 +1,29 @@ + lang('igniter::admin.statuses.label_comment'), + 'notify' => lang('igniter::admin.statuses.label_notify_customer'), + ]; + } + + public function rules(): array + { + return [ + 'status_id' => ['required', 'integer', 'exists:statuses,status_id'], + 'comment' => ['nullable', 'string', 'max:500'], + 'notify' => ['nullable', 'boolean'], + ]; + } +} diff --git a/src/ApiResources/Reservations.php b/src/ApiResources/Reservations.php index c87ed58..6434249 100644 --- a/src/ApiResources/Reservations.php +++ b/src/ApiResources/Reservations.php @@ -6,9 +6,12 @@ use Igniter\Api\ApiResources\Repositories\ReservationRepository; use Igniter\Api\ApiResources\Requests\ReservationRequest; +use Igniter\Api\ApiResources\Requests\StatusRequest; use Igniter\Api\ApiResources\Transformers\ReservationTransformer; use Igniter\Api\Classes\ApiController; use Igniter\Api\Http\Actions\RestController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Reservations API Controller @@ -26,6 +29,7 @@ class Reservations extends ApiController 'show' => [], 'update' => [], 'destroy' => [], + 'updateStatus' => [], ], 'request' => ReservationRequest::class, 'repository' => ReservationRepository::class, @@ -33,4 +37,28 @@ class Reservations extends ApiController ]; protected string|array $requiredAbilities = ['reservations:*']; + + public function updateStatus(StatusRequest $request, int $reservationId): Response + { + throw_if( + ($token = $this->getToken()) && $token->isForCustomer(), + new AccessDeniedHttpException('Customers are not allowed to update reservation status.') + ); + + $data = $request->validated(); + + $reservation = app(ReservationRepository::class)->find($reservationId); + + $data['staff_id'] = $this->user()->getKey(); + + $reservation->addStatusHistory((int) $data['status_id'], array_only($data, ['comment', 'notify', 'staff_id'])); + + $response = $this->fractal() + ->item($reservation->refresh()) + ->transformWith(new ReservationTransformer) + ->withResourceName('reservations') + ->toArray(); + + return response()->json($response); + } } diff --git a/src/ApiResources/Transformers/AddressTransformer.php b/src/ApiResources/Transformers/AddressTransformer.php index e4158de..5ed6ef1 100644 --- a/src/ApiResources/Transformers/AddressTransformer.php +++ b/src/ApiResources/Transformers/AddressTransformer.php @@ -6,14 +6,24 @@ use Igniter\Api\Traits\MergesIdAttribute; use Igniter\User\Models\Address; +use League\Fractal\Resource\Item; use League\Fractal\TransformerAbstract; class AddressTransformer extends TransformerAbstract { use MergesIdAttribute; + protected array $availableIncludes = [ + 'country', + ]; + public function transform(Address $address): array { return $this->mergesIdAttribute($address); } + + public function includeCountry(Address $address): ?Item + { + return $address->country ? $this->item($address->country, new CountryTransformer, 'countries') : null; + } } diff --git a/src/ApiResources/Transformers/CountryTransformer.php b/src/ApiResources/Transformers/CountryTransformer.php new file mode 100644 index 0000000..086a1ae --- /dev/null +++ b/src/ApiResources/Transformers/CountryTransformer.php @@ -0,0 +1,19 @@ +mergesIdAttribute($country); + } +} diff --git a/src/Extension.php b/src/Extension.php index 26ea7bd..5c19a89 100755 --- a/src/Extension.php +++ b/src/Extension.php @@ -4,6 +4,7 @@ namespace Igniter\Api; +use Igniter\Api\ApiResources\Addresses; use Igniter\Api\ApiResources\Categories; use Igniter\Api\ApiResources\Currencies; use Igniter\Api\ApiResources\Customers; @@ -30,6 +31,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\SanctumServiceProvider; @@ -69,6 +71,8 @@ public function boot(): void { $this->configureRateLimiting(); + $this->registerStatusUpdateRoute(); + // Register all the available API routes ApiManager::registerRoutes(); @@ -124,6 +128,16 @@ public function registerApiResources(): array 'index', ], ], + 'addresses' => [ + 'controller' => Addresses::class, + 'name' => 'Addresses', + 'description' => 'An API resource for customer addresses', + 'actions' => [ + 'index:users', 'show:users', + 'store:users', 'update:users', + 'destroy:users', + ], + ], 'customers' => [ 'controller' => Customers::class, 'name' => 'Customers', @@ -275,4 +289,16 @@ protected function configureRateLimiting() { RateLimiter::for('api', fn(Request $request) => Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip())); } + + protected function registerStatusUpdateRoute(): void + { + Route::middleware(config('igniter-api.middleware')) + ->prefix(config('igniter-api.prefix')) + ->group(function(): void { + Route::patch('orders/{orderId}/status', [Orders::class, 'updateStatus']) + ->name('igniter.api.orders.update_status'); + Route::patch('reservations/{reservationId}/status', [Reservations::class, 'updateStatus']) + ->name('igniter.api.reservations.update_status'); + }); + } } diff --git a/tests/ApiResources/AddressesTest.php b/tests/ApiResources/AddressesTest.php new file mode 100644 index 0000000..4656469 --- /dev/null +++ b/tests/ApiResources/AddressesTest.php @@ -0,0 +1,193 @@ +create(), ['addresses:*']); + $customer = Customer::factory()->create(); + Address::factory()->count(3)->create(['customer_id' => $customer->getKey()]); + $address = Address::first(); + + $this + ->get(route('igniter.api.addresses.index')) + ->assertOk() + ->assertJsonPath('data.0.id', (string)$address->getKey()) + ->assertJsonPath('data.0.attributes.address_1', $address->address_1) + ->assertJsonCount(3, 'data'); +}); + +it('returns only authenticated customer addresses', function(): void { + $customer = Customer::factory()->create(); + $customer->addresses()->create([ + 'address_1' => '123 Own St', + 'city' => 'London', + 'country_id' => 1, + ]); + Customer::factory()->create()->addresses()->create([ + 'address_1' => '456 Other St', + 'city' => 'Paris', + 'country_id' => 1, + ]); + Sanctum::actingAs($customer, ['addresses:*']); + + $this + ->get(route('igniter.api.addresses.index')) + ->assertOk() + ->assertJsonPath('data.0.attributes.address_1', '123 Own St') + ->assertJsonCount(1, 'data'); +}); + +it('can not show another customer address', function(): void { + $anotherCustomer = Customer::factory()->create(); + $otherAddress = $anotherCustomer->addresses()->create([ + 'address_1' => '456 Other St', + 'city' => 'Paris', + 'country_id' => 1, + ]); + Sanctum::actingAs(Customer::factory()->create(), ['addresses:*']); + + $this + ->get(route('igniter.api.addresses.show', [$otherAddress->getKey()])) + ->assertStatus(404); +}); + +it('can not update customer aware column', function(): void { + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test St', + 'city' => 'London', + 'country_id' => 1, + ]); + Sanctum::actingAs($customer, ['addresses:*']); + + $this + ->put(route('igniter.api.addresses.update', [$address->getKey()]), [ + 'customer_id' => 9999, + 'address_1' => 'Updated St', + 'city' => 'London', + 'country_id' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.address_1', 'Updated St') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('shows an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test Address', + 'address_2' => 'Flat 1', + 'city' => 'London', + 'state' => 'Greater London', + 'postcode' => 'W1A 1AA', + 'country_id' => 1, + ]); + + $this + ->get(route('igniter.api.addresses.show', [$address->getKey()])) + ->assertOk() + ->assertJsonPath('data.id', (string)$address->getKey()) + ->assertJsonPath('data.attributes.address_1', '123 Test Address') + ->assertJsonPath('data.attributes.address_2', 'Flat 1') + ->assertJsonPath('data.attributes.city', 'London') + ->assertJsonPath('data.attributes.country_id', 1); +}); + +it('shows an address with country relationship', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $country = Country::factory()->create(['country_name' => 'United Kingdom', 'iso_code_2' => 'GB']); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test Address', + 'city' => 'London', + 'country_id' => $country->getKey(), + ]); + + $this + ->get(route('igniter.api.addresses.show', [$address->getKey()]).'?'.http_build_query([ + 'include' => 'country', + ])) + ->assertOk() + ->assertJsonPath('data.relationships.country.data.type', 'countries') + ->assertJsonPath('included.0.id', (string)$country->getKey()) + ->assertJsonPath('included.0.attributes.country_name', 'United Kingdom') + ->assertJsonPath('included.0.attributes.iso_code_2', 'GB'); +}); + +it('creates an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + + $this + ->post(route('igniter.api.addresses.store'), [ + 'customer_id' => $customer->getKey(), + 'address_1' => '1 New Road', + 'address_2' => 'Unit 2', + 'city' => 'Manchester', + 'state' => 'Lancashire', + 'postcode' => 'M1 1AA', + 'country_id' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.attributes.address_1', '1 New Road') + ->assertJsonPath('data.attributes.city', 'Manchester') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('creates an address as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['addresses:*']); + + $this + ->post(route('igniter.api.addresses.store'), [ + 'address_1' => '2 Customer Lane', + 'city' => 'Birmingham', + 'country_id' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.attributes.address_1', '2 Customer Lane') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('updates an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Old St', + 'city' => 'London', + 'country_id' => 1, + ]); + + $this + ->put(route('igniter.api.addresses.update', [$address->getKey()]), [ + 'address_1' => '123 Updated St', + 'city' => 'London', + 'country_id' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.address_1', '123 Updated St'); +}); + +it('deletes an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 To Delete', + 'city' => 'London', + 'country_id' => 1, + ]); + + $this + ->delete(route('igniter.api.addresses.destroy', [$address->getKey()])) + ->assertStatus(204); + + expect(Address::find($address->getKey()))->toBeNull(); +}); diff --git a/tests/ApiResources/OrdersTest.php b/tests/ApiResources/OrdersTest.php index 50c7e43..7274b4e 100644 --- a/tests/ApiResources/OrdersTest.php +++ b/tests/ApiResources/OrdersTest.php @@ -213,6 +213,58 @@ ->assertJsonPath('data.attributes.order_type', Location::COLLECTION); }); +it('updates order status', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertOk() + ->assertJsonPath('data.id', (string)$order->getKey()) + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('updates order status with comment', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + 'status_comment' => 'Ready for collection', + ]) + ->assertOk() + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('fails to update order status when status_id is missing', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_comment' => 'Comment only', + ]) + ->assertStatus(422); +}); + +it('can not update order status as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['orders:*']); + $customer->currentAccessToken()->shouldReceive('isForCustomer')->andReturnTrue(); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertStatus(403); +}); + it('deletes an order', function(): void { Sanctum::actingAs(User::factory()->create(), ['orders:*']); $order = Order::factory()->create(); diff --git a/tests/ApiResources/Requests/AddressRequestTest.php b/tests/ApiResources/Requests/AddressRequestTest.php new file mode 100644 index 0000000..7768d4d --- /dev/null +++ b/tests/ApiResources/Requests/AddressRequestTest.php @@ -0,0 +1,39 @@ +attributes(); + + expect($attributes)->toHaveKey('address_1', lang('igniter.cart::default.checkout.label_address_1')) + ->and($attributes)->toHaveKey('address_2', lang('igniter.cart::default.checkout.label_address_2')) + ->and($attributes)->toHaveKey('city', lang('igniter.cart::default.checkout.label_city')) + ->and($attributes)->toHaveKey('state', lang('igniter.cart::default.checkout.label_state')) + ->and($attributes)->toHaveKey('postcode', lang('igniter.cart::default.checkout.label_postcode')) + ->and($attributes)->toHaveKey('country_id', lang('igniter.cart::default.checkout.label_country')) + ->and($attributes)->toHaveKey('customer_id', lang('igniter.api::default.addresses.label_customer_id')); +}); + +it('returns correct validation rules', function(): void { + $request = new AddressRequest; + + $rules = $request->rules(); + + expect($rules)->toHaveKey('address_1') + ->and($rules)->toHaveKey('address_2') + ->and($rules)->toHaveKey('city') + ->and($rules)->toHaveKey('state') + ->and($rules)->toHaveKey('postcode') + ->and($rules)->toHaveKey('country_id') + ->and($rules)->toHaveKey('customer_id') + ->and($rules['address_1'])->toContain('required', 'min:3', 'max:128') + ->and($rules['city'])->toContain('required', 'min:2', 'max:128') + ->and($rules['country_id'])->toContain('required', 'integer') + ->and($rules['customer_id'])->toContain('integer'); +}); diff --git a/tests/ApiResources/Requests/OrderStatusRequestTest.php b/tests/ApiResources/Requests/OrderStatusRequestTest.php new file mode 100644 index 0000000..6e84dae --- /dev/null +++ b/tests/ApiResources/Requests/OrderStatusRequestTest.php @@ -0,0 +1,29 @@ +attributes(); + + expect($attributes)->toHaveKey('comment', lang('igniter::admin.statuses.label_comment')) + ->and($attributes)->toHaveKey('notify', lang('igniter::admin.statuses.label_notify_customer')); +}); + +it('returns correct validation rules', function(): void { + $request = new StatusRequest; + + $rules = $request->rules(); + + expect($rules)->toHaveKey('status_id') + ->and($rules)->toHaveKey('comment') + ->and($rules)->toHaveKey('notify') + ->and($rules['status_id'])->toContain('required', 'integer', 'exists:statuses,status_id') + ->and($rules['comment'])->toContain('nullable', 'string', 'max:500') + ->and($rules['notify'])->toContain('nullable', 'boolean'); +}); diff --git a/tests/ApiResources/ReservationsTest.php b/tests/ApiResources/ReservationsTest.php index ee70d09..9512992 100644 --- a/tests/ApiResources/ReservationsTest.php +++ b/tests/ApiResources/ReservationsTest.php @@ -7,6 +7,7 @@ use Igniter\Admin\Models\Status; use Igniter\Local\Models\Location; use Igniter\Reservation\Models\Reservation; +use Igniter\User\Models\Customer; use Igniter\User\Models\User; use Igniter\User\Models\UserGroup; use Laravel\Sanctum\Sanctum; @@ -159,6 +160,59 @@ ->assertJsonPath('data.attributes.reserve_time', '23:00'); }); +it('updates reservation status', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertOk() + ->assertJsonPath('data.id', (string)$reservation->getKey()) + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('updates reservation status with comment and notify', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + 'comment' => 'Table confirmed', + 'notify' => true, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('fails to update reservation status when status_id is missing', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'comment' => 'Comment only', + ]) + ->assertStatus(422); +}); + +it('can not update reservation status as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['reservations:*']); + $customer->currentAccessToken()->shouldReceive('isForCustomer')->andReturnTrue(); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertStatus(403); +}); + it('deletes a reservation', function(): void { Sanctum::actingAs(User::factory()->create(), ['reservations:*']); $reservation = Reservation::factory()->create(); diff --git a/tests/Classes/ApiManagerTest.php b/tests/Classes/ApiManagerTest.php index cd49f25..b2b1ef9 100644 --- a/tests/Classes/ApiManagerTest.php +++ b/tests/Classes/ApiManagerTest.php @@ -18,7 +18,7 @@ }); it('returns resources when they are loaded', function(): void { - expect($this->apiManager->getResources())->toHaveCount(15); + expect($this->apiManager->getResources()->count())->toBeGreaterThan(10); }); it('returns empty array when resource is not found', function(): void {