From 554276140445279fed414eeccbb757c2735da410 Mon Sep 17 00:00:00 2001 From: Sam Poyigi <6567634+sampoyigi@users.noreply.github.com> Date: Sat, 28 Dec 2024 22:21:11 +0000 Subject: [PATCH] Update test scripts for improved coverage and precision settings. Signed-off-by: Sam Poyigi <6567634+sampoyigi@users.noreply.github.com> --- composer.json | 14 +- phpstan-baseline.neon | 80 +-- .../Conditions/ReviewCount.php | 2 +- src/Classes/Location.php | 340 +++++++--- src/Classes/Manager.php | 226 ------- src/Classes/WorkingPeriod.php | 34 +- src/Classes/WorkingSchedule.php | 39 +- src/Extension.php | 68 +- src/FormWidgets/MapArea.php | 16 +- src/Http/Middleware/CheckLocation.php | 15 +- src/Listeners/MaxOrderPerTimeslotReached.php | 2 +- src/Models/Concerns/HasDeliveryAreas.php | 30 +- src/Models/Concerns/HasWorkingHours.php | 56 +- src/Models/Concerns/Locationable.php | 2 +- src/Models/LocationArea.php | 28 +- src/Models/WorkingHour.php | 37 -- .../Conditions/ReviewCountTest.php | 38 +- tests/Classes/CoveredAreaConditionTest.php | 11 + tests/Classes/CoveredAreaTest.php | 62 +- tests/Classes/LocationTest.php | 607 +++++++++++++++++- tests/Classes/ScheduleItemTest.php | 39 ++ tests/Classes/WorkingPeriodTest.php | 114 +++- tests/Classes/WorkingRangeTest.php | 11 +- tests/Classes/WorkingScheduleTest.php | 270 +++++++- tests/Classes/WorkingTimeTest.php | 7 + tests/ExtensionTest.php | 126 +++- tests/FormWidgets/MapAreaTest.php | 34 +- tests/FormWidgets/MapViewTest.php | 12 + tests/FormWidgets/ScheduleEditorTest.php | 4 + .../Actions/LocationAwareControllerTest.php | 70 +- tests/Http/Controllers/LocationsTest.php | 33 +- tests/Http/Controllers/ReviewSettingsTest.php | 14 + tests/Http/Middleware/CheckLocationTest.php | 51 +- .../Http/Requests/LocationAreaRequestTest.php | 114 ++-- tests/Http/Requests/LocationRequestTest.php | 103 ++- tests/Http/Requests/ReviewRequestTest.php | 62 +- .../Http/Requests/WorkingHourRequestTest.php | 75 +-- .../MaxOrderPerTimeslotReachedTest.php | 105 +++ tests/MainMenuWidgets/LocationPickerTest.php | 72 ++- tests/Models/Actions/ReviewActionTest.php | 16 + .../Models/Concerns/HasDeliveryAreasTest.php | 127 ++++ tests/Models/Concerns/HasWorkingHoursTest.php | 164 +++++ tests/Models/Concerns/LocationHelpersTest.php | 155 +++++ tests/Models/Concerns/LocationableTest.php | 64 ++ tests/Models/LocationAreaTest.php | 260 ++++++++ tests/Models/LocationSettingsTest.php | 144 +++++ tests/Models/LocationTest.php | 78 ++- tests/Models/ReviewSettingsTest.php | 79 +++ tests/Models/ReviewTest.php | 171 +++++ tests/Models/Scopes/LocationScopeTest.php | 44 ++ tests/Models/Scopes/LocationableScopeTest.php | 97 +++ tests/Models/WorkingHourTest.php | 101 +++ tests/Traits/LocationAwareWidgetTest.php | 96 +++ 53 files changed, 3693 insertions(+), 926 deletions(-) delete mode 100644 src/Classes/Manager.php create mode 100644 tests/Http/Controllers/ReviewSettingsTest.php create mode 100644 tests/Listeners/MaxOrderPerTimeslotReachedTest.php create mode 100644 tests/Models/Actions/ReviewActionTest.php create mode 100644 tests/Models/Concerns/HasDeliveryAreasTest.php create mode 100644 tests/Models/Concerns/HasWorkingHoursTest.php create mode 100644 tests/Models/Concerns/LocationHelpersTest.php create mode 100644 tests/Models/Concerns/LocationableTest.php create mode 100644 tests/Models/LocationAreaTest.php create mode 100644 tests/Models/LocationSettingsTest.php create mode 100644 tests/Models/ReviewSettingsTest.php create mode 100644 tests/Models/ReviewTest.php create mode 100644 tests/Models/Scopes/LocationScopeTest.php create mode 100644 tests/Models/Scopes/LocationableScopeTest.php create mode 100644 tests/Models/WorkingHourTest.php create mode 100644 tests/Traits/LocationAwareWidgetTest.php diff --git a/composer.json b/composer.json index 41af6cf..04ef859 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,16 @@ } ], "require": { - "tastyigniter/core": "^v4.0@beta", - "tastyigniter/ti-ext-automation": "^v4.0@beta", - "tastyigniter/ti-ext-cart": "^v4.0@beta", - "tastyigniter/ti-ext-reservation": "^v4.0@beta" + "tastyigniter/core": "^v4.0@beta || ^v4.0@dev", + "tastyigniter/ti-ext-automation": "^v4.0@beta || ^v4.0@dev", + "tastyigniter/ti-ext-cart": "^v4.0@beta || ^v4.0@dev", + "tastyigniter/ti-ext-reservation": "^v4.0@beta || ^v4.0@dev" }, "require-dev": { "laravel/pint": "^1.2", "larastan/larastan": "^2.4.0", "sampoyigi/testbench": "dev-main as 1.0", - "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-laravel": "^3.0", "igniterlabs/ti-ext-importexport": "v4.x-dev as 4.0" }, "autoload": { @@ -45,7 +45,7 @@ } }, "scripts": { - "test": "vendor/bin/pest", + "test": "vendor/bin/pest --coverage --exactly=100", "test-coverage": "vendor/bin/pest --coverage", "format": "vendor/bin/pint", "static": "vendor/bin/phpstan analyse --ansi --memory-limit 1056M" @@ -58,5 +58,5 @@ }, "sort-packages": true }, - "minimum-stability": "beta" + "minimum-stability": "dev" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ee5760b..f468233 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -56,79 +56,79 @@ parameters: path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getSettings\\(\\)\\.$#" - count: 1 + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getKey\\(\\)\\.$#" + count: 3 path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:listDeliveryAreas\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getSettings\\(\\)\\.$#" count: 1 path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:searchOrDefaultDeliveryArea\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getSlugKeyName\\(\\)\\.$#" count: 1 path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:shouldAddLeadTime\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:listDeliveryAreas\\(\\)\\.$#" count: 1 path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getKey\\(\\)\\.$#" - count: 3 - path: src/Classes/Manager.php + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:newQuery\\(\\)\\.$#" + count: 1 + path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:getSlugKeyName\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:newWorkingSchedule\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:newQuery\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:searchOrDefaultDeliveryArea\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - - message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:newWorkingSchedule\\(\\)\\.$#" + message: "#^Call to an undefined method Igniter\\\\Local\\\\Contracts\\\\LocationInterface\\:\\:shouldAddLeadTime\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\:\\:IsEnabled\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:selectDistance\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:whereIsEnabled\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined method Illuminate\\\\Support\\\\Optional\\:\\:hasPermission\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined static method Igniter\\\\User\\\\Facades\\\\AdminAuth\\:\\:getUser\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined static method Igniter\\\\User\\\\Facades\\\\AdminAuth\\:\\:isSuperUser\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined static method Igniter\\\\User\\\\Facades\\\\AdminAuth\\:\\:user\\(\\)\\.$#" count: 1 - path: src/Classes/Manager.php + path: src/Classes/Location.php - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getWeekDaysOptions\\(\\)\\.$#" @@ -200,6 +200,11 @@ parameters: count: 1 path: src/Extension.php + - + message: "#^Call to an undefined static method Igniter\\\\Flame\\\\Support\\\\Facades\\\\Igniter\\:\\:runningInAdmin\\(\\)\\.$#" + count: 2 + path: src/Extension.php + - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:updateNearbyArea\\(\\)\\.$#" count: 1 @@ -286,17 +291,22 @@ parameters: path: src/Http/Controllers/Reviews.php - - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:current\\(\\)\\.$#" + message: "#^Call to an undefined static method Igniter\\\\Flame\\\\Support\\\\Facades\\\\Igniter\\:\\:hasDatabase\\(\\)\\.$#" count: 1 path: src/Http/Middleware/CheckLocation.php - - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:currentOrDefault\\(\\)\\.$#" + message: "#^Call to an undefined static method Igniter\\\\Flame\\\\Support\\\\Facades\\\\Igniter\\:\\:runningInAdmin\\(\\)\\.$#" + count: 2 + path: src/Http/Middleware/CheckLocation.php + + - + message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:current\\(\\)\\.$#" count: 1 path: src/Http/Middleware/CheckLocation.php - - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:resetSession\\(\\)\\.$#" + message: "#^Call to an undefined static method Igniter\\\\Local\\\\Facades\\\\Location\\:\\:currentOrDefault\\(\\)\\.$#" count: 1 path: src/Http/Middleware/CheckLocation.php @@ -470,11 +480,6 @@ parameters: count: 1 path: src/Models/Location.php - - - message: "#^Access to an undefined property Igniter\\\\Local\\\\Models\\\\Location\\:\\:\\$options\\.$#" - count: 1 - path: src/Models/Location.php - - message: "#^Access to an undefined property Igniter\\\\Local\\\\Models\\\\Location\\:\\:\\$permalink_slug\\.$#" count: 1 @@ -545,11 +550,6 @@ parameters: count: 1 path: src/Models/Location.php - - - message: "#^Method Igniter\\\\Local\\\\Models\\\\Location\\:\\:getCurrentTime\\(\\) should return Carbon\\\\Carbon but return statement is missing\\.$#" - count: 1 - path: src/Models/Location.php - - message: "#^Access to an undefined property Igniter\\\\Local\\\\Models\\\\LocationArea\\:\\:\\$boundaries\\.$#" count: 1 @@ -562,7 +562,7 @@ parameters: - message: "#^Access to an undefined property Igniter\\\\Local\\\\Models\\\\LocationArea\\:\\:\\$location_id\\.$#" - count: 1 + count: 2 path: src/Models/LocationArea.php - @@ -665,6 +665,11 @@ parameters: count: 1 path: src/Models/Review.php + - + message: "#^Call to an undefined static method Igniter\\\\Flame\\\\Support\\\\Facades\\\\Igniter\\:\\:runningInAdmin\\(\\)\\.$#" + count: 1 + path: src/Models/Review.php + - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Models\\\\Review\\:\\:whereReviewable\\(\\)\\.$#" count: 1 @@ -745,11 +750,6 @@ parameters: count: 1 path: src/Models/WorkingHour.php - - - message: "#^Call to an undefined static method Igniter\\\\Local\\\\Models\\\\WorkingHour\\:\\:where\\(\\)\\.$#" - count: 1 - path: src/Models/WorkingHour.php - - message: "#^Method Carbon\\\\Carbon\\:\\:addDay\\(\\) invoked with 1 parameter, 0 required\\.$#" count: 1 diff --git a/src/AutomationRules/Conditions/ReviewCount.php b/src/AutomationRules/Conditions/ReviewCount.php index 305c2c4..8777804 100644 --- a/src/AutomationRules/Conditions/ReviewCount.php +++ b/src/AutomationRules/Conditions/ReviewCount.php @@ -34,7 +34,7 @@ public function defineModelAttributes() public function getReviewCountAttribute($value, $object) { if (!$object instanceof Order && !$object instanceof Reservation) { - return false; + return 0; } return Review::query()->where([ diff --git a/src/Classes/Location.php b/src/Classes/Location.php index 4dd17a4..1d061f7 100644 --- a/src/Classes/Location.php +++ b/src/Classes/Location.php @@ -3,24 +3,39 @@ namespace Igniter\Local\Classes; use Carbon\Carbon; +use Closure; use Igniter\Cart\Classes\AbstractOrderType; -use Igniter\Flame\Geolite\Model\Distance; +use Igniter\Flame\Geolite\Contracts\CoordinatesInterface; use Igniter\Flame\Geolite\Model\Location as UserLocation; +use Igniter\Flame\Traits\EventEmitter; use Igniter\Local\Contracts\AreaInterface; +use Igniter\Local\Contracts\LocationInterface; use Igniter\Local\Models\Location as LocationModel; use Igniter\Local\Models\LocationArea; +use Igniter\System\Traits\SessionMaker; +use Igniter\User\Facades\AdminAuth; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; /** * Location Class */ -class Location extends Manager +class Location { - const CLOSED = 'closed'; + use EventEmitter; + use SessionMaker; - const OPEN = 'open'; + public const CLOSED = 'closed'; - const OPENING = 'opening'; + public const OPEN = 'open'; + + public const OPENING = 'opening'; + + protected string $sessionKey = 'local_info'; + + protected ?LocationInterface $model = null; + + protected string $locationModel = \Igniter\Local\Models\Location::class; protected ?CoveredArea $coveredArea = null; @@ -28,90 +43,192 @@ class Location extends Manager protected array $scheduleTimeslotCache = []; - // - // BOOT METHODS - // + protected static array $schedulesCache = []; - public function updateNearbyArea(AreaInterface $area) + /** + * The route parameter resolver callback. + */ + protected static ?Closure $locationSlugResolver = null; + + /** + * Resolve the location from route parameter. + */ + public function resolveLocationSlug(): ?string { - $this->setCurrent($area->location); + if (isset(static::$locationSlugResolver)) { + return call_user_func(static::$locationSlugResolver); + } - $this->setCoveredArea(new CoveredArea($area)); + return request()->route('location'); } - public function setCoveredArea(CoveredArea $coveredArea) + /** + * Set the location route parameter resolver callback. + */ + public function locationSlugResolver(Closure $resolver) { - $this->coveredArea = $coveredArea; + static::$locationSlugResolver = $resolver; + } - $areaId = $this->getSession('area'); - if ($areaId !== $coveredArea->getKey()) { - $this->putSession('area', $coveredArea->getKey()); - $this->fireSystemEvent('location.area.updated', [$coveredArea]); + public function check(): bool + { + return !is_null($this->current()); + } + + public function current(): ?LocationInterface + { + if (!is_null($this->model)) { + return $this->model; } - return $this; + $slug = $this->resolveLocationSlug(); + if ($slug && $model = $this->getBySlug($slug)) { + $this->setCurrent($model); + } else { + $id = $this->getSession('id'); + if ($id && $model = $this->getById($id)) { + $this->setModel($model); + } + } + + if (is_null($this->model) && is_single_location() && $defaultLocation = $this->locationModel::getDefault()) { + $this->setCurrent($defaultLocation); + } + + return $this->model; } - public function updateOrderType($code = null) + public function currentOrDefault(): ?LocationInterface { - $oldOrderType = $this->getSession('orderType'); + if ($model = $this->current()) { + return $model; + } - if (is_null($code)) { - $this->forgetSession('orderType'); + if ($defaultLocation = $this->locationModel::getDefault()) { + $this->setCurrent($defaultLocation); } - if (strlen($code)) { - $this->putSession('orderType', $code); - $this->fireSystemEvent('location.orderType.updated', [$code, $oldOrderType]); + return $defaultLocation; + } + + public function currentOrAssigned(): array + { + if ($this->check()) { + return [$this->getId()]; } + + if (AdminAuth::isSuperUser()) { + return []; + } + + return AdminAuth::user()?->locations?->pluck('location_id')->all() ?? []; } - public function updateUserPosition(UserLocation $position) + public function setCurrent(LocationInterface $locationModel) { - $oldPosition = $this->getSession('position'); + $this->setModel($locationModel); - $this->putSession('position', $position); + $this->putSession('id', $locationModel->getKey()); - $this->clearCoveredArea(); + $this->fireSystemEvent('location.current.updated', [$locationModel]); + } - $this->fireSystemEvent('location.position.updated', [$position, $oldPosition]); + public function getModel(): ?LocationInterface + { + return $this->model; } - // - // HELPER METHODS - // + public function setModel(LocationInterface $model): self + { + $this->model = $model; - public function clearCoveredArea() + return $this; + } + + public function getId(): ?int { - $this->coveredArea = null; - $this->forgetSession('area'); + return $this->current()?->getKey(); } - public function updateScheduleTimeSlot($dateTime, $isAsap = true) + public function getName(): ?string { - $orderType = $this->orderType(); - $oldSlot = $this->getSession($orderType.'-timeslot'); + return $this->model?->getName(); + } - $slot['dateTime'] = (!$isAsap && !is_null($dateTime)) ? make_carbon($dateTime) : null; - $slot['isAsap'] = $isAsap; + public function createLocationModel(): LocationInterface + { + $class = '\\'.ltrim($this->locationModel, '\\'); - if (!$slot) { - $this->forgetSession($orderType.'-timeslot'); - } else { - $this->putSession($orderType.'-timeslot', $slot); + return new $class; + } + + protected function createLocationModelQuery(): Builder + { + $model = $this->createLocationModel(); + $query = $model->newQuery(); + $this->extendLocationQuery($query); + + return $query; + } + + public function extendLocationQuery(Builder $query) + { + if (!optional(AdminAuth::getUser())->hasPermission('Admin.Locations')) { + $query->IsEnabled(); } + } - $this->fireSystemEvent('location.timeslot.updated', [$slot, $oldSlot]); + public function getById(string|int $identifier): ?LocationInterface + { + $query = $this->createLocationModelQuery(); + + /** @var LocationInterface $location */ + $location = $query->find($identifier); + + return $location ?: null; } - public function orderType() + public function getBySlug(string $slug): ?LocationInterface { - return $this->getSession('orderType', LocationModel::DELIVERY); + $model = $this->createLocationModel(); + $query = $this->createLocationModelQuery(); + + /** @var LocationInterface $location */ + $location = $query->where($model->getSlugKeyName(), $slug)->first(); + + return $location ?: null; } - public function requiresUserPosition() + public function clearInternalCache() { - return setting('location_order') == 1; + $this->model = null; + $this->orderTypes = null; + $this->coveredArea = null; + $this->scheduleTimeslotCache = []; + static::$locationSlugResolver = null; + } + + // + // Order Types + // + + public function updateOrderType($code = null) + { + $oldOrderType = $this->getSession('orderType'); + + if (is_null($code)) { + $this->forgetSession('orderType'); + } + + if (strlen($code)) { + $this->putSession('orderType', $code); + $this->fireSystemEvent('location.orderType.updated', [$code, $oldOrderType]); + } + } + + public function orderType() + { + return $this->getSession('orderType', LocationModel::DELIVERY); } public function checkOrderType($code = null) @@ -162,15 +279,32 @@ public function getActiveOrderTypes() return collect($this->getOrderTypes() ?? [])->filter(fn($orderType) => !$orderType->isDisabled()); } + // + // Timeslot + // + + public function updateScheduleTimeSlot($dateTime, $isAsap = null) + { + $orderType = $this->orderType(); + $oldSlot = $this->getSession($orderType.'-timeslot'); + + $slot['dateTime'] = (!$isAsap && !is_null($dateTime)) ? make_carbon($dateTime) : null; + $slot['isAsap'] = $isAsap; + + if (!array_filter($slot)) { + $this->forgetSession($orderType.'-timeslot'); + } else { + $this->putSession($orderType.'-timeslot', $slot); + } + + $this->fireSystemEvent('location.timeslot.updated', [$slot, $oldSlot]); + } + public function openingSchedule() { return $this->workingSchedule(Location::OPENING); } - // - // HOURS - // - public function deliverySchedule() { return $this->workingSchedule(LocationModel::DELIVERY); @@ -269,10 +403,6 @@ public function orderTimeIsAsap() return $orderTimeIsAsap || ($dateTime && now()->isAfter($dateTime)); } - // - // Timeslot - // - public function hasAsapSchedule() { if ($this->getOrderType()->getMinimumFutureDays()) { @@ -320,7 +450,7 @@ public function scheduleTimeslot($orderType = null) ? $this->orderLeadTime() : 0; $result = $this->getOrderType($orderType)->getSchedule()->getTimeslot( - $this->orderTimeInterval(), null, $leadMinutes + $this->orderTimeInterval(), null, $leadMinutes, ); return $this->scheduleTimeslotCache[$orderType] = $result; @@ -348,6 +478,67 @@ public function hasLaterSchedule() return $this->getOrderType()->getScheduleRestriction() !== AbstractOrderType::ASAP_ONLY; } + public function workingSchedule(string $type, null|int|array $days = null): WorkingSchedule + { + $cacheKey = sprintf('%s.%s', $this->getModel()->getKey(), $type); + + if (isset(self::$schedulesCache[$cacheKey])) { + return self::$schedulesCache[$cacheKey]; + } + + $schedule = $this->getModel()->newWorkingSchedule($type, $days); + + self::$schedulesCache[$cacheKey] = $schedule; + + return $schedule; + } + + // + // DELIVERY AREA + // + + public function updateNearbyArea(AreaInterface $area) + { + $this->setCurrent($area->location); + + $this->setCoveredArea(new CoveredArea($area)); + } + + public function setCoveredArea(CoveredArea $coveredArea) + { + $this->coveredArea = $coveredArea; + + $areaId = $this->getSession('area'); + if ($areaId !== $coveredArea->getKey()) { + $this->putSession('area', $coveredArea->getKey()); + $this->fireSystemEvent('location.area.updated', [$coveredArea]); + } + + return $this; + } + + public function updateUserPosition(UserLocation $position) + { + $oldPosition = $this->getSession('position'); + + $this->putSession('position', $position); + + $this->clearCoveredArea(); + + $this->fireSystemEvent('location.position.updated', [$position, $oldPosition]); + } + + public function clearCoveredArea() + { + $this->coveredArea = null; + $this->forgetSession('area'); + } + + public function requiresUserPosition() + { + return setting('location_order') == 1; + } + public function isCurrentAreaId($areaId) { return $this->getAreaId() == $areaId; @@ -358,10 +549,6 @@ public function getAreaId() return $this->coveredArea()->getKey(); } - // - // DELIVERY AREA - // - /** * @return \Igniter\Local\Classes\CoveredArea * @throws \Igniter\Flame\Exception\ApplicationException @@ -377,14 +564,9 @@ public function coveredArea() $area = $this->getModel()->findDeliveryArea($areaId); } - if ($area && $this->getId() !== $area->getLocationId()) { - $area = null; - $this->clearCoveredArea(); - } - if (is_null($area)) { $area = $this->getModel()->searchOrDefaultDeliveryArea( - $this->userPosition()->getCoordinates() + $this->userPosition()->getCoordinates(), ); } @@ -450,14 +632,10 @@ public function checkMinimumOrderTotal($cartTotal, $orderType = null) public function checkDistance() { $distance = $this->getModel()->calculateDistance( - $this->userPosition()->getCoordinates() + $this->userPosition()->getCoordinates(), ); - if (!$distance instanceof Distance) { - return $distance; - } - - return $distance->formatDistance($this->getModel()->getDistanceUnit()); + return $distance?->formatDistance($this->getModel()->getDistanceUnit()); } public function checkDeliveryCoverage(?UserLocation $userPosition = null) @@ -469,12 +647,14 @@ public function checkDeliveryCoverage(?UserLocation $userPosition = null) return $this->coveredArea()->checkBoundary($userPosition->getCoordinates()); } - protected function workingStatus($type = null, $timestamp = null) + public function searchByCoordinates(CoordinatesInterface $coordinates, int $limit = 20): Collection { - if (is_null($type)) { - $type = $this->orderType(); - } + $query = $this->createLocationModelQuery(); + $query->select('*')->selectDistance( + $coordinates->getLatitude(), + $coordinates->getLongitude(), + ); - return $this->workingSchedule($type)->checkStatus($timestamp); + return $query->orderBy('distance')->whereIsEnabled()->limit($limit)->get(); } } diff --git a/src/Classes/Manager.php b/src/Classes/Manager.php deleted file mode 100644 index 2057525..0000000 --- a/src/Classes/Manager.php +++ /dev/null @@ -1,226 +0,0 @@ -route('location'); - } - - /** - * Set the location route parameter resolver callback. - */ - public function locationSlugResolver(Closure $resolver) - { - static::$locationSlugResolver = $resolver; - } - - public function check(): bool - { - return !is_null($this->current()); - } - - public function current(): ?LocationInterface - { - if (!is_null($this->model)) { - return $this->model; - } - - $slug = $this->resolveLocationSlug(); - if ($slug && $model = $this->getBySlug($slug)) { - $this->setCurrent($model); - } else { - $id = $this->getSession('id'); - if ($id && $model = $this->getById($id)) { - $this->setModel($model); - } - } - - if (is_null($this->model) && is_single_location() && $defaultLocation = $this->locationModel::getDefault()) { - $this->setCurrent($defaultLocation); - } - - return $this->model; - } - - public function currentOrDefault(): ?LocationInterface - { - if ($model = $this->current()) { - return $model; - } - - if ($defaultLocation = $this->locationModel::getDefault()) { - $this->setCurrent($defaultLocation); - } - - return $defaultLocation; - } - - public function currentOrAssigned(): array - { - if ($this->check()) { - return [$this->getId()]; - } - - if (AdminAuth::isSuperUser()) { - return []; - } - - return AdminAuth::user()?->locations?->pluck('location_id')->all() ?? []; - } - - public function setCurrent(LocationInterface $locationModel) - { - $this->setModel($locationModel); - - $this->putSession('id', $locationModel->getKey()); - - $this->fireSystemEvent('location.current.updated', [$locationModel]); - } - - public function getModel(): ?LocationInterface - { - return $this->model; - } - - public function setModel(LocationInterface $model): self - { - $this->model = $model; - - return $this; - } - - public function getId(): ?int - { - return $this->current()?->getKey(); - } - - public function getName(): ?string - { - return $this->model?->getName(); - } - - /** - * Creates a new instance of the location model - */ - public function createLocationModel(): LocationInterface - { - $class = '\\'.ltrim($this->locationModel, '\\'); - - return new $class; - } - - /** - * Prepares a query derived from the location model. - */ - protected function createLocationModelQuery(): Builder - { - $model = $this->createLocationModel(); - $query = $model->newQuery(); - $this->extendLocationQuery($query); - - return $query; - } - - /** - * Extend the query used for finding the location. - * - * @return void - */ - public function extendLocationQuery(Builder $query) - { - if (!optional(AdminAuth::getUser())->hasPermission('Admin.Locations')) { - $query->IsEnabled(); - } - } - - /** - * Retrieve a location by their unique identifier. - */ - public function getById(string|int $identifier): ?LocationInterface - { - $query = $this->createLocationModelQuery(); - - /** @var LocationInterface $location */ - $location = $query->find($identifier); - - return $location ?: null; - } - - /** - * Retrieve a location by their unique slug. - */ - public function getBySlug(string $slug): ?LocationInterface - { - $model = $this->createLocationModel(); - $query = $this->createLocationModelQuery(); - - /** @var LocationInterface $location */ - $location = $query->where($model->getSlugKeyName(), $slug)->first(); - - return $location ?: null; - } - - public function searchByCoordinates(CoordinatesInterface $coordinates, int $limit = 20): Collection - { - $query = $this->createLocationModelQuery(); - $query->select('*')->selectDistance( - $coordinates->getLatitude(), - $coordinates->getLongitude() - ); - - return $query->orderBy('distance')->whereIsEnabled()->limit($limit)->get(); - } - - public function workingSchedule(string $type, ?int $days = null): WorkingSchedule - { - $cacheKey = sprintf('%s.%s', $this->getModel()->getKey(), $type); - - if (isset(self::$schedulesCache[$cacheKey])) { - return self::$schedulesCache[$cacheKey]; - } - - $schedule = $this->getModel()->newWorkingSchedule($type, $days); - - self::$schedulesCache[$cacheKey] = $schedule; - - return $schedule; - } -} diff --git a/src/Classes/WorkingPeriod.php b/src/Classes/WorkingPeriod.php index 0efac72..cc73715 100644 --- a/src/Classes/WorkingPeriod.php +++ b/src/Classes/WorkingPeriod.php @@ -72,16 +72,13 @@ public function nextOpenAt(WorkingTime $time) if (count($this->ranges) === 1) { return $range->start(); } - if (next($range) !== $range && $nextOpenTime = next($range)) { - reset($range); + if ($nextOpenTime = $this->getNextStartTime($range)) { return $nextOpenTime; } } if ($nextOpenTime = $this->findNextTimeInFreeTime('start', $time, $range)) { - reset($range); - return $nextOpenTime; } } @@ -143,7 +140,7 @@ public function opensLateAt(WorkingTime $time) public function timeslot(DateTimeInterface $dateTime, DateInterval $interval, ?DateInterval $leadTime = null) { return WorkingTimeslot::make($this->ranges)->generate( - $dateTime, $interval, $leadTime + $dateTime, $interval, $leadTime, ); } @@ -156,11 +153,9 @@ protected function findTimeInRange(WorkingTime $time) } } - protected function findNextTimeInFreeTime($type, WorkingTime $time, WorkingRange $timeRange, ?WorkingRange &$prevTimeRange = null) + protected function findNextTimeInFreeTime($type, WorkingTime $time, WorkingRange $timeRange) { - $timeOffRange = $prevTimeRange - ? WorkingRange::create([$prevTimeRange->end(), $timeRange->start()]) - : WorkingRange::create(['00:00', $timeRange->start()]); + $timeOffRange = WorkingRange::create(['00:00', $timeRange->start()]); if ( $timeOffRange->containsTime($time) @@ -168,8 +163,6 @@ protected function findNextTimeInFreeTime($type, WorkingTime $time, WorkingRange ) { return $timeRange->{$type}(); } - - $prevTimeRange = $timeRange; } /** @@ -183,12 +176,29 @@ protected function checkWorkingRangesOverlaps($ranges) if ($nextRange && $range->overlaps($nextRange)) { throw new WorkingHourException(sprintf( 'Time ranges %s and %s overlap.', - $range, $nextRange + $range, $nextRange, )); } } } + protected function getNextStartTime(WorkingRange $range): ?WorkingTime + { + $currentRangeFound = false; + foreach ($this->ranges as $currentRange) { + if ($currentRange === $range) { + $currentRangeFound = true; + continue; // Skip the current range + } + + if ($currentRangeFound) { + return $currentRange->start(); // Return the next range start + } + } + + return null; + } + public function isEmpty(): bool { return empty($this->ranges); diff --git a/src/Classes/WorkingSchedule.php b/src/Classes/WorkingSchedule.php index 5054eeb..da02277 100644 --- a/src/Classes/WorkingSchedule.php +++ b/src/Classes/WorkingSchedule.php @@ -151,17 +151,17 @@ public function forDate(DateTimeInterface $date): WorkingPeriod public function isOpen() { - return $this->isOpenAt(new DateTime); + return $this->isOpenAt(Carbon::now()); } public function isOpening() { - return (bool)$this->nextOpenAt(new DateTime); + return (bool)$this->nextOpenAt(Carbon::now()); } public function isClosed() { - return $this->isClosedAt(new DateTime); + return $this->isClosedAt(Carbon::now()); } public function isOpenOn(string $day): bool @@ -186,7 +186,7 @@ public function isOpenAt(DateTimeInterface $dateTime): bool // but are closed the next day and the date range falls // inside the late night opening return $this->forDate( - Carbon::parse($dateTime)->subDay() + Carbon::parse($dateTime)->subDay(), )->opensLateAt($workingTime); } @@ -202,7 +202,7 @@ public function nextOpenAt(DateTimeInterface $dateTime) } $nextOpenAt = $this->forDate($dateTime)->nextOpenAt( - WorkingTime::fromDateTime($dateTime) + WorkingTime::fromDateTime($dateTime), ); if (!$this->hasPeriod()) { @@ -228,7 +228,7 @@ public function nextOpenAt(DateTimeInterface $dateTime) return $dateTime->setTime( $nextOpenAt->toDateTime()->format('G'), - $nextOpenAt->toDateTime()->format('i') + $nextOpenAt->toDateTime()->format('i'), ); } @@ -244,7 +244,7 @@ public function nextCloseAt(DateTimeInterface $dateTime) } $nextCloseAt = $this->forDate($dateTime)->nextCloseAt( - WorkingTime::fromDateTime($dateTime) + WorkingTime::fromDateTime($dateTime), ); if (!$this->hasPeriod()) { @@ -263,7 +263,7 @@ public function nextCloseAt(DateTimeInterface $dateTime) return $dateTime->setTime( $nextCloseAt->toDateTime()->format('G'), - $nextCloseAt->toDateTime()->format('i') + $nextCloseAt->toDateTime()->format('i'), ); } @@ -283,14 +283,14 @@ public function getPeriods() public function getOpenTime($format = null) { - $time = $this->nextOpenAt(new DateTime); + $time = $this->nextOpenAt(Carbon::now()); return ($time && $format) ? $time->format($format) : $time; } public function getCloseTime($format = null) { - $time = $this->nextCloseAt(new DateTime); + $time = $this->nextCloseAt(Carbon::now()); return ($time && $format) ? $time->format($format) : $time; } @@ -312,10 +312,6 @@ public function checkStatus($dateTime = null) return WorkingPeriod::OPENING; } - if ($this->isClosedAt($dateTime)) { - return WorkingPeriod::CLOSED; - } - return WorkingPeriod::CLOSED; } @@ -389,11 +385,11 @@ public function setExceptions(array $exceptions) protected function parseDate($start = null) { if (!$start) { - return new DateTime; + return Carbon::now(); } if (is_string($start)) { - return new DateTime($start); + return Carbon::parse($start); } if ($start instanceof DateTime) { @@ -420,7 +416,7 @@ protected function parsePeriods($periods) } elseif (is_array($period)) { $day = WorkingDay::normalizeName($day); $parsedPeriods[$day] = array_merge( - $parsedPeriods[$day] ?? [], $period + $parsedPeriods[$day] ?? [], $period, ); } } @@ -452,9 +448,10 @@ protected function isTimeslotValid(DateTimeInterface $timeslot, DateTimeInterfac } // +2 as we subtracted a day and need to count the current day - if (Carbon::instance($dateTime)->addDays($this->maxDays + 2)->lt($timeslot)) { - return false; - } +// if (Carbon::instance($dateTime)->addDays($this->maxDays + 2)->lt($timeslot)) { +// return false; +// } + // Commented out as not necessary. The above condition is already checked in isBetweenPeriodForDays method $result = WorkingScheduleTimeslotValidEvent::dispatchOnce($this, $timeslot); @@ -502,7 +499,7 @@ protected function isBetweenPeriodForDays($timeslot) { return Carbon::instance($timeslot)->between( now()->startOfDay()->addDays($this->minDays), - now()->endOfDay()->addDays($this->maxDays + 2) + now()->endOfDay()->addDays($this->maxDays + 2), ); } } diff --git a/src/Extension.php b/src/Extension.php index b694703..70726a7 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -6,6 +6,7 @@ use Igniter\Admin\Classes\Navigation; use Igniter\Admin\DashboardWidgets\Charts; use Igniter\Admin\Facades\AdminMenu; +use Igniter\Admin\Widgets\Form; use Igniter\Cart\Classes\OrderTypes; use Igniter\Cart\Models\Order; use Igniter\Flame\Geolite\Facades\Geocoder; @@ -92,9 +93,7 @@ public function boot() Order::implement(ReviewAction::class); Reservation::implement(ReviewAction::class); - if (Igniter::runningInAdmin()) { - $this->registerLocationsMainMenuItems(); - } + $this->registerLocationsMainMenuItems(); } public function registerAutomationRules() @@ -253,26 +252,24 @@ protected function extendDashboardChartsDatasets() { Charts::extend(function($charts) { $charts->bindEvent('charts.extendDatasets', function() use ($charts) { - if (!ReviewSettings::allowReviews()) { - return; + if (ReviewSettings::allowReviews()) { + $charts->mergeDataset('reports', 'sets', [ + 'reviews' => [ + 'label' => 'lang:igniter.local::default.reviews.text_title', + 'color' => '#FFB74D', + 'model' => Review::class, + 'column' => 'created_at', + 'priority' => 40, + ], + ]); } - - $charts->mergeDataset('reports', 'sets', [ - 'reviews' => [ - 'label' => 'lang:igniter.local::default.reviews.text_title', - 'color' => '#FFB74D', - 'model' => Review::class, - 'column' => 'created_at', - 'priority' => 40, - ], - ]); }); }); } protected function addAssetsToReviewsSettingsPage() { - Event::listen('admin.form.extendFieldsBefore', function($form) { + Event::listen('admin.form.extendFieldsBefore', function(Form $form) { if (!$form->model instanceof ReviewSettings) { return; } @@ -297,13 +294,11 @@ protected function addReviewsRelationship(): void protected function bindRememberLocationAreaEvents(): void { Event::listen('location.position.updated', function($location, $position, $oldPosition) { - if ($position->format() === $oldPosition?->format()) { - return; + if ($position->format() !== $oldPosition?->format()) { + $this->updateCustomerLastArea([ + 'query' => $position->format(), + ]); } - - $this->updateCustomerLastArea([ - 'query' => $position->format(), - ]); }); Event::listen('location.area.updated', function($location, $coveredArea) { @@ -313,7 +308,7 @@ protected function bindRememberLocationAreaEvents(): void }); Event::listen(['igniter.user.login', 'igniter.socialite.login'], function() { - try { + rescue(function() { if (!strlen($lastArea = Auth::customer()->last_location_area)) { return; } @@ -329,8 +324,7 @@ protected function bindRememberLocationAreaEvents(): void if ($areaId && $area = LocationArea::find($areaId)) { LocationFacade::updateNearbyArea($area); } - } catch (\Exception $exception) { - } + }); }); } @@ -350,16 +344,18 @@ protected function updateCustomerLastArea($value) protected function registerLocationsMainMenuItems() { - AdminMenu::registerCallback(function(Navigation $manager) { - $manager->registerMainItems([ - MainMenuItem::widget('locations', LocationPicker::class) - ->priority(0) - ->permission('Admin.Locations') - ->mergeConfig([ - 'form' => 'igniter.local::/models/location', - 'request' => LocationRequest::class, - ]), - ]); - }); + if (Igniter::runningInAdmin()) { + AdminMenu::registerCallback(function(Navigation $manager) { + $manager->registerMainItems([ + MainMenuItem::widget('locations', LocationPicker::class) + ->priority(0) + ->permission('Admin.Locations') + ->mergeConfig([ + 'form' => 'igniter.local::/models/location', + 'request' => LocationRequest::class, + ]), + ]); + }); + } } } diff --git a/src/FormWidgets/MapArea.php b/src/FormWidgets/MapArea.php index 075bd39..475b578 100644 --- a/src/FormWidgets/MapArea.php +++ b/src/FormWidgets/MapArea.php @@ -9,7 +9,7 @@ use Igniter\Flame\Exception\FlashException; use Igniter\Flame\Html\HtmlFacade as Html; use Igniter\Local\Models\LocationArea; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; /** @@ -100,7 +100,7 @@ public function loadAssets() if (strlen($key = setting('maps_api_key'))) { $url = 'https://maps.googleapis.com/maps/api/js?key=%s&libraries=geometry'; $this->addJs(sprintf($url, $key), - ['name' => 'google-maps-js', 'async' => null, 'defer' => null] + ['name' => 'google-maps-js', 'async' => null, 'defer' => null], ); } @@ -182,7 +182,7 @@ public function onSaveRecord() }); flash()->success(sprintf(lang('igniter::admin.alert_success'), - 'Area '.($form->context == 'create' ? 'created' : 'updated') + 'Area '.($form->context == 'create' ? 'created' : 'updated'), ))->now(); $this->formField->value = null; @@ -199,11 +199,11 @@ public function onSaveRecord() public function onDeleteArea() { throw_unless($areaId = input('areaId'), - new FlashException(lang('igniter.local::default.alert_invalid_area')) + new FlashException(lang('igniter.local::default.alert_invalid_area')), ); throw_unless($model = $this->getRelationModel()->find($areaId), - new FlashException(sprintf(lang('igniter::admin.form.not_found'), $areaId)) + new FlashException(sprintf(lang('igniter::admin.form.not_found'), $areaId)), ); $model->delete(); @@ -266,12 +266,8 @@ protected function getMapAreas() return $this->mapAreas = $result; } - protected function makeAreaFormWidget($model, $context = null) + protected function makeAreaFormWidget($model, $context) { - if (is_null($context)) { - $context = $model->exists ? 'edit' : 'create'; - } - if (is_null($model->location_id)) { $model->location_id = $this->model->getKey(); } diff --git a/src/Http/Middleware/CheckLocation.php b/src/Http/Middleware/CheckLocation.php index 83ffec2..18aa8d2 100644 --- a/src/Http/Middleware/CheckLocation.php +++ b/src/Http/Middleware/CheckLocation.php @@ -12,15 +12,10 @@ class CheckLocation { public function handle(Request $request, Closure $next) { - if (!Igniter::hasDatabase()) { - $location = null; - } elseif (Igniter::runningInAdmin()) { - if (($location = $this->checkAdminLocation()) === false) { - Location::resetSession(); - - return redirect()->back(); - } - } else { + $location = null; + if (Igniter::runningInAdmin()) { + $location = $this->checkAdminLocation(); + } elseif (Igniter::hasDatabase()) { $location = Location::currentOrDefault(); } @@ -37,7 +32,7 @@ public function handle(Request $request, Closure $next) return redirect()->to(page_url('home')); } - if (!$location->isEnabled() && !AdminAuth::getUser()?->hasPermission('Admin.Locations')) { + if ($locationParam && !$location->isEnabled() && !AdminAuth::getUser()?->hasPermission('Admin.Locations')) { flash()->error(lang('igniter.local::default.alert_location_required')); return redirect()->to(page_url('home')); diff --git a/src/Listeners/MaxOrderPerTimeslotReached.php b/src/Listeners/MaxOrderPerTimeslotReached.php index 5d42b43..454639e 100644 --- a/src/Listeners/MaxOrderPerTimeslotReached.php +++ b/src/Listeners/MaxOrderPerTimeslotReached.php @@ -11,7 +11,7 @@ class MaxOrderPerTimeslotReached { - protected static $ordersCache = []; + public static $ordersCache = []; public function subscribe(Dispatcher $dispatcher) { diff --git a/src/Models/Concerns/HasDeliveryAreas.php b/src/Models/Concerns/HasDeliveryAreas.php index 2c8295a..0e03b12 100644 --- a/src/Models/Concerns/HasDeliveryAreas.php +++ b/src/Models/Concerns/HasDeliveryAreas.php @@ -2,6 +2,7 @@ namespace Igniter\Local\Models\Concerns; +use Igniter\Flame\Geolite\Contracts\CoordinatesInterface; use Igniter\Flame\Geolite\Facades\Geocoder; use Igniter\Local\Contracts\AreaInterface; use Igniter\Local\Models\LocationArea; @@ -83,7 +84,7 @@ public function findDeliveryArea($areaId) */ public function searchOrDefaultDeliveryArea($coordinates) { - if ($area = $this->searchDeliveryArea($coordinates)) { + if ($coordinates && ($area = $this->searchDeliveryArea($coordinates))) { return $area; } @@ -96,23 +97,15 @@ public function searchOrDefaultDeliveryArea($coordinates) */ public function searchOrFirstDeliveryArea($coordinates) { - if (!$area = $this->searchDeliveryArea($coordinates)) { - $area = $this->delivery_areas->first(); + if ($coordinates && ($area = $this->searchDeliveryArea($coordinates))) { + return $area; } - return $area; + return $this->delivery_areas->first(); } - /** - * @param \Igniter\Flame\Geolite\Contracts\CoordinatesInterface $coordinates - * @return \Igniter\Local\Contracts\AreaInterface|null - */ - public function searchDeliveryArea($coordinates) + public function searchDeliveryArea(CoordinatesInterface $coordinates): ?AreaInterface { - if (!$coordinates) { - return null; - } - return $this->delivery_areas ->sortBy('priority') ->first(function(AreaInterface $model) use ($coordinates) { @@ -138,17 +131,8 @@ public function getDistanceUnit() */ public function addLocationAreas($deliveryAreas) { - $locationId = $this->getKey(); - if (!is_numeric($locationId)) { - return false; - } - - if (!is_array($deliveryAreas)) { - return false; - } - $idsToKeep = []; - foreach ($deliveryAreas as $area) { + foreach ($deliveryAreas ?: [] as $area) { $locationArea = $this->delivery_areas()->firstOrNew([ 'area_id' => $area['area_id'] ?? null, ])->fill(array_except($area, ['area_id'])); diff --git a/src/Models/Concerns/HasWorkingHours.php b/src/Models/Concerns/HasWorkingHours.php index fd5c2c7..0f34114 100644 --- a/src/Models/Concerns/HasWorkingHours.php +++ b/src/Models/Concerns/HasWorkingHours.php @@ -14,14 +14,6 @@ trait HasWorkingHours { - /** - * @return Carbon - */ - public function getCurrentTime() - { - traceLog('Deprecated function. No longer supported.'); - } - public function availableWorkingTypes() { return array_merge([ @@ -29,17 +21,12 @@ public function availableWorkingTypes() ], collect(resolve(OrderTypes::class)->listOrderTypes())->keys()->all()); } - public function listWorkingHours() - { - traceLog('Deprecated function. Use getWorkingHours() instead.'); - } - /** * @return mixed 24_7, daily or flexible */ public function workingHourType($hourType = null) { - return array_get($this->options, "hours.{$hourType}.type"); + return $this->getSettings("hours.{$hourType}.type"); } public function getWorkingHoursByType($type) @@ -66,9 +53,7 @@ public function getWorkingHourByDateAndType($date, $type) $date = make_carbon($date); } - $weekday = $date->format('N') - 1; - - return $this->getWorkingHourByDayAndType($weekday, $type); + return $this->getWorkingHourByDayAndType($date->format('N'), $type); } public function getWorkingHours() @@ -96,7 +81,7 @@ public function newWorkingSchedule($type, $days = null) } $schedule = WorkingSchedule::create($days, - $this->getWorkingHoursByType($type) ?? new Collection([]) + $this->getWorkingHoursByType($type) ?? new Collection([]), ); $schedule->setType($type); @@ -172,41 +157,6 @@ public function addOpeningHours(null|string|array $type, ?array $data = []) return true; } - protected function parseHoursFromOptions(&$value) - { - // Rename options array index 'opening_hours' to 'hours' - if (isset($value['opening_hours'])) { - $hours = $value['opening_hours']; - foreach (['opening', 'daily', 'delivery', 'collection'] as $type) { - foreach (['type', 'days', 'hours'] as $suffix) { - if (isset($hours["{$type}_{$suffix}"])) { - $valueItem = $hours["{$type}_{$suffix}"]; - if ($suffix == 'type') { - $valueItem = $valueItem != '24_7' ? $valueItem : '24_7'; - } - - $typeIndex = $type == 'daily' ? 'opening' : $type; - - if ($suffix == 'hours') { - $value['hours'][$typeIndex]['open'] = $valueItem['open'] ?? '00:00'; - $value['hours'][$typeIndex]['close'] = $valueItem['close'] ?? '23:59'; - } else { - $value['hours'][$typeIndex][$suffix] = $valueItem; - } - } - } - } - - if (isset($hours['flexible_hours']) && is_array($hours['flexible_hours'])) { - foreach (['opening', 'delivery', 'collection'] as $type) { - $value['hours'][$type]['flexible'] = $hours['flexible_hours']; - } - } - - unset($value['opening_hours']); - } - } - protected function createDefaultWorkingHours() { foreach (['opening', 'delivery', 'collection'] as $hourType) { diff --git a/src/Models/Concerns/Locationable.php b/src/Models/Concerns/Locationable.php index 309a426..f4df04c 100644 --- a/src/Models/Concerns/Locationable.php +++ b/src/Models/Concerns/Locationable.php @@ -46,7 +46,7 @@ protected function detachLocationsOnDelete() $locationable = $this->getLocationableRelationObject(); - if (Igniter::runningInAdmin() && !AdminAuth::isSuperUser() && $locationable->count() > 1) { + if (Igniter::runningInAdmin() && !AdminAuth::isSuperUser() && $locationable->count()) { throw new SystemException(lang('igniter::admin.alert_warning_locationable_delete')); } diff --git a/src/Models/LocationArea.php b/src/Models/LocationArea.php index f4d63f4..eea65ec 100644 --- a/src/Models/LocationArea.php +++ b/src/Models/LocationArea.php @@ -92,23 +92,6 @@ public function defaultable(): Builder // Accessors & Mutators // - public function getConditionsAttribute($value) - { - // backward compatibility v2.0 - if (!is_array($conditions = json_decode($value ?? '', true))) { - $conditions = []; - } - - foreach ($conditions as $key => &$item) { - if (isset($item['condition'])) { - $item['type'] = $item['condition']; - unset($item['condition']); - } - } - - return $conditions; - } - public function getVerticesAttribute() { return isset($this->boundaries['vertices']) ? @@ -155,7 +138,7 @@ public function getCircle() $geolite = app('geolite'); $coordinate = $geolite->coordinates( $this->circle->lat, - $this->circle->lng + $this->circle->lng, ); return $geolite->circle($coordinate, $this->circle->radius); @@ -173,14 +156,14 @@ public function isPolygonBoundary() public function getLocationId() { - return $this->attributes['location_id']; + return $this->location_id; } public function checkBoundary(CoordinatesInterface $coordinate) { if ($this->isAddressBoundary()) { $position = Geocoder::reverse( - $coordinate->getLatitude(), $coordinate->getLongitude() + $coordinate->getLatitude(), $coordinate->getLongitude(), )->first(); if ($position) { @@ -218,10 +201,7 @@ public function pointInCircle(CoordinatesInterface $coordinate) public function matchAddressComponents(LocationInterface $position) { - $components = array_get($this->boundaries, 'components'); - if (!is_array($components)) { - $components = []; - } + $components = (array)array_get($this->boundaries, 'components'); $groupedComponents = collect($components)->groupBy('type')->all(); diff --git a/src/Models/WorkingHour.php b/src/Models/WorkingHour.php index dce6b10..f3dfb9c 100644 --- a/src/Models/WorkingHour.php +++ b/src/Models/WorkingHour.php @@ -25,8 +25,6 @@ class WorkingHour extends Model implements WorkingHourInterface */ protected $table = 'working_hours'; - public $incrementing = false; - protected $timeFormat = 'H:i'; public $relation = [ @@ -107,10 +105,6 @@ public function getCloseAttribute() public function isOpenAllDay() { - if (!$this->open || !$this->close) { - return null; - } - $diffInHours = (int)floor($this->open->diffInHours($this->close)); return $diffInHours >= 23 || $diffInHours == 0; @@ -118,10 +112,6 @@ public function isOpenAllDay() public function isPastMidnight() { - if (!$this->opening_time || !$this->closing_time) { - return null; - } - return $this->opening_time > $this->closing_time; } @@ -140,33 +130,6 @@ public function getClose() return $this->close->format('H:i'); } - public function getHoursByLocation($id) - { - $collection = []; - - foreach (self::where('location_id', $id)->get() as $row) { - $row = $this->parseRecord($row); - $collection[$row['type']][$row['weekday']] = $row; - } - - return $collection; - } - - public function parseRecord($row) - { - $type = !empty($row['type']) ? $row['type'] : 'opening'; - $collection = array_merge($row, [ - 'location_id' => $row['location_id'], - 'day' => $row['day'], - 'type' => $type, - 'open' => strtotime("{$row['day']} {$row['opening_time']}"), - 'close' => strtotime("{$row['day']} {$row['closing_time']}"), - 'is_24_hours' => $row['open_all_day'], - ]); - - return $collection; - } - public function getWeekDate() { return new Carbon($this->day); diff --git a/tests/AutomationRules/Conditions/ReviewCountTest.php b/tests/AutomationRules/Conditions/ReviewCountTest.php index e65e3a2..40acad6 100644 --- a/tests/AutomationRules/Conditions/ReviewCountTest.php +++ b/tests/AutomationRules/Conditions/ReviewCountTest.php @@ -2,9 +2,22 @@ namespace Igniter\Local\Tests\AutomationRules\Conditions; +use Igniter\Automation\AutomationException; use Igniter\Automation\Models\RuleCondition; use Igniter\Cart\Models\Order; use Igniter\Local\AutomationRules\Conditions\ReviewCount; +use Igniter\Reservation\Models\Reservation; + +it('returns correct condition details', function() { + $condition = new ReviewCount(); + + $result = $condition->conditionDetails(); + + expect($result)->toMatchArray([ + 'name' => 'Review Count', + 'description' => 'Number of reviews for this order or reservation', + ]); +}); it('defines model attributes correctly', function() { $reviewCount = new ReviewCount; @@ -16,7 +29,7 @@ ]); }); -it('counts reviews correctly', function() { +it('counts reviews for order object correctly', function() { $order = Order::factory()->hasReview(1)->create(); $reviewCount = new ReviewCount; @@ -24,6 +37,22 @@ expect($reviewCount->getReviewCountAttribute(null, $order))->toBe(1); }); +it('counts reviews for reservation object correctly', function() { + $reservation = Reservation::factory()->hasReview(1)->create(); + + $reviewCount = new ReviewCount; + + expect($reviewCount->getReviewCountAttribute(null, $reservation))->toBe(1); +}); + +it('returns zero when object is not order or reservation', function() { + $reviewCount = new ReviewCount; + + $result = $reviewCount->getReviewCountAttribute(null, new \stdClass); + + expect($result)->toBe(0); +}); + it('evaluates isTrue when no reviews correctly', function() { $order = Order::factory()->create(); @@ -62,3 +91,10 @@ $params = ['order' => $order]; expect($orderAttribute->isTrue($params))->toBeTrue(); }); + +it('throws exception when neither order nor reservation object is provided in parameters', function() { + $reviewCount = new ReviewCount; + $params = []; + + expect(fn() => $reviewCount->isTrue($params))->toThrow(AutomationException::class); +}); diff --git a/tests/Classes/CoveredAreaConditionTest.php b/tests/Classes/CoveredAreaConditionTest.php index 2ecdd26..e94a24e 100644 --- a/tests/Classes/CoveredAreaConditionTest.php +++ b/tests/Classes/CoveredAreaConditionTest.php @@ -62,3 +62,14 @@ expect($condition->isValid(150))->toBeTrue() ->and($condition->isValid(50))->toBeFalse(); }); + +it('validates when no matching type', function() { + $condition = new CoveredAreaCondition([ + 'type' => 'invalid', + 'amount' => 10, + 'total' => 100, + 'priority' => 1, + ]); + + expect($condition->isValid(150))->toBeTrue(); +}); diff --git a/tests/Classes/CoveredAreaTest.php b/tests/Classes/CoveredAreaTest.php index 5a4e1bc..631ad11 100644 --- a/tests/Classes/CoveredAreaTest.php +++ b/tests/Classes/CoveredAreaTest.php @@ -4,13 +4,13 @@ use Igniter\Local\Classes\CoveredArea; use Igniter\Local\Classes\CoveredAreaCondition; +use Igniter\Local\Facades\Location; use Igniter\Local\Models\LocationArea; use Mockery; it('calculates delivery amount correctly', function() { $model = Mockery::mock(LocationArea::class); $model->shouldReceive('offsetExists')->andReturnTrue(); - $model->shouldReceive('extendableGet')->with('conditions')->andReturn([ ['type' => 'above', 'amount' => 10, 'total' => 100, 'priority' => 1], ['type' => 'below', 'amount' => 5, 'total' => 50, 'priority' => 2], @@ -25,7 +25,34 @@ ->and($coveredArea->deliveryAmount(40))->toBe(5.0); }); -it('calculates minimum order total correctly', function() { +it('calculates delivery amount using delivery charges correctly', function() { + $model = Mockery::mock(LocationArea::class); + $model->shouldReceive('offsetExists')->andReturnTrue(); + $model->shouldReceive('extendableGet')->with('conditions')->andReturn([ + ['type' => 'above', 'amount' => 10, 'total' => 100, 'priority' => 1], + ['type' => 'below', 'amount' => 5, 'total' => 50, 'priority' => 2], + ]); + $model->shouldReceive('extendableGet')->with('boundaries')->andReturn([ + 'distance' => [ + ['distance' => 15, 'type' => 'greater', 'charge' => 1], + ['distance' => 10, 'type' => 'less', 'charge' => 5], + ['distance' => 15, 'type' => 'equals_or_greater', 'charge' => 10], + ['distance' => 12, 'type' => 'equals_or_less', 'charge' => 15], + ], + ]); + Location::shouldReceive('userPosition')->andReturnSelf(); + Location::shouldReceive('isValid')->andReturnTrue(); + Location::shouldReceive('checkDistance')->andReturn(20, 5, 15, 12); + + $coveredArea = new CoveredArea($model); + + expect($coveredArea->deliveryAmount(100))->toBe(11.0) + ->and($coveredArea->deliveryAmount(40))->toBe(10.0) + ->and($coveredArea->deliveryAmount(40))->toBe(15.0) + ->and($coveredArea->deliveryAmount(40))->toBe(20.0); +}); + +it('returns minimum order total correctly', function() { $model = Mockery::mock(LocationArea::class); $model->shouldReceive('offsetExists')->andReturnTrue(); @@ -40,6 +67,28 @@ ->and($coveredArea->minimumOrderTotal(40))->toBe(50.0); }); +it('returns zero minimum order total when no conditions are met', function() { + $model = Mockery::mock(LocationArea::class); + $model->shouldReceive('offsetExists')->andReturnTrue(); + $model->shouldReceive('extendableGet')->with('conditions')->andReturn([]); + + $coveredArea = new CoveredArea($model); + + expect($coveredArea->minimumOrderTotal(100))->toBe(0); +}); + +it('returns unavailable when matched rule is -1', function() { + $model = Mockery::mock(LocationArea::class); + $model->shouldReceive('offsetExists')->andReturnTrue(); + $model->shouldReceive('extendableGet')->with('conditions')->andReturn([ + ['type' => 'above', 'amount' => -1, 'total' => 100, 'priority' => 1], + ]); + + $coveredArea = new CoveredArea($model); + + expect($coveredArea->minimumOrderTotal(100))->toBe(100.0); +}); + it('lists conditions correctly', function() { $model = Mockery::mock(LocationArea::class); $model->shouldReceive('offsetExists')->andReturnTrue(); @@ -82,3 +131,12 @@ ->and($labels[4])->toContain('not available above') ->and($labels[5])->toContain('not available below'); }); + +it('access model attributes', function() { + $model = Mockery::mock(LocationArea::class)->makePartial(); + $model->shouldReceive('getAttribute')->with('name')->andReturn('Area name'); + + $coveredArea = new CoveredArea($model); + + expect($coveredArea->name)->toBe('Area name'); +}); diff --git a/tests/Classes/LocationTest.php b/tests/Classes/LocationTest.php index 69b1fa1..efe3a86 100644 --- a/tests/Classes/LocationTest.php +++ b/tests/Classes/LocationTest.php @@ -2,100 +2,637 @@ namespace Igniter\Local\Tests\Classes; +use Carbon\Carbon; use Igniter\Cart\Classes\AbstractOrderType; +use Igniter\Cart\Facades\Cart; use Igniter\Flame\Geolite\Model\Location as UserLocation; use Igniter\Local\Classes\CoveredArea; use Igniter\Local\Classes\Location; +use Igniter\Local\Classes\WorkingSchedule; +use Igniter\Local\Contracts\LocationInterface; use Igniter\Local\Facades\Location as LocationFacade; use Igniter\Local\Models\Location as LocationModel; use Igniter\Local\Models\LocationArea; +use Igniter\User\Facades\AdminAuth; +use Igniter\User\Models\User; use Illuminate\Support\Facades\Event; -use Mockery; -it('updates nearby area correctly', function() { - $location = new Location; - $area = Mockery::mock(LocationArea::class); - $area->shouldReceive('extendableGet')->with('location')->andReturn(new LocationModel); - $area->shouldReceive('getKey')->andReturn(1); +beforeEach(function() { + $this->location = new Location; +}); + +afterEach(function() { + $this->location->clearInternalCache(); + $this->location->resetSession(); +}); + +it('returns location slug from resolver callback', function() { + $this->location->locationSlugResolver(fn() => 'test-location'); + + $result = $this->location->resolveLocationSlug(); + + expect($result)->toBe('test-location'); +}); + +it('returns current location when model is already set', function() { + $model = mock(LocationInterface::class); + $this->location->setModel($model); + + $result = $this->location->current(); + + expect($result)->toBe($model); +}); + +it('sets current location by slug', function() { + $model = LocationModel::factory()->create(['permalink_slug' => 'test-slug']); + $this->location->locationSlugResolver(fn() => 'test-slug'); - $location->updateNearbyArea($area); + $result = $this->location->current(); - expect($location->coveredArea())->toBeInstanceOf(CoveredArea::class); + expect($result->getKey())->toBe($model->getKey()); +}); + +it('sets current location by session id', function() { + $model = LocationModel::factory()->create(); + $this->location->putSession('id', $model->getKey()); + + $this->location->current(); + + expect($this->location->getName())->toBe($model->location_name); +}); + +it('sets default location when no current location is set', function() { + config(['igniter-system.locationMode' => 'single']); + $defaultLocation = LocationModel::getDefault(); + + $result = $this->location->current(); + + expect($result->getKey())->toBe($defaultLocation->getKey()); +}); + +it('returns default location in currentOrDefault', function() { + $defaultLocation = LocationModel::getDefault(); + + $result = $this->location->currentOrDefault(); + + expect($result->getKey())->toBe($defaultLocation->getKey()); +}); + +it('returns current location in currentOrDefault', function() { + $model = mock(LocationInterface::class)->makePartial(); + $this->location->setModel($model); + + $result = $this->location->currentOrDefault(); + + expect($result)->toBe($model); +}); + +it('returns assigned location ids for non-super user in currentOrAssigned', function() { + $user = mock(User::class)->makePartial(); + $user->shouldReceive('extendableGet')->with('locations')->andReturnSelf(); + $user->shouldReceive('pluck')->andReturnSelf(); + $user->shouldReceive('all')->andReturn([1, 2, 3]); + AdminAuth::shouldReceive('isSuperUser')->andReturn(false); + AdminAuth::shouldReceive('user')->andReturn($user); + + $result = $this->location->currentOrAssigned(); + + expect($result)->toBe([1, 2, 3]); +}); + +it('returns empty array for super user in currentOrAssigned', function() { + AdminAuth::shouldReceive('isSuperUser')->andReturn(true); + + $result = $this->location->currentOrAssigned(); + + expect($result)->toBe([]); +}); + +it('returns current location id in currentOrAssigned', function() { + $location = mock(LocationInterface::class)->makePartial(); + $location->shouldReceive('getKey')->andReturn(1); + $this->location->setCurrent($location); + + $result = $this->location->currentOrAssigned(); + + expect($result)->toBe([1]); }); it('updates order type correctly', function() { - $location = new Location; + $this->location->updateOrderType(LocationModel::DELIVERY); + + expect($this->location->orderType())->toBe(LocationModel::DELIVERY); + + $this->location->updateOrderType(LocationModel::COLLECTION); + + expect($this->location->orderType())->toBe(LocationModel::COLLECTION); +}); - $location->updateOrderType(LocationModel::COLLECTION); +it('clears order type', function() { + $this->location->updateOrderType(); - expect($location->orderType())->toBe(LocationModel::COLLECTION); + expect($this->location->getSession('orderType'))->toBeNull(); }); it('updates user position correctly', function() { - $location = new Location; $userPosition = new UserLocation('google', []); - $location->updateUserPosition($userPosition); + $this->location->updateUserPosition($userPosition); - expect($location->userPosition())->toBe($userPosition); + expect($this->location->userPosition())->toBe($userPosition); }); it('updates schedule time slot correctly', function() { Event::fake(); - $location = new Location; - - $location->updateScheduleTimeSlot('2022-12-31 12:00:00', false); + $this->location->updateScheduleTimeSlot('2022-12-31 12:00:00', false); Event::assertDispatched('location.timeslot.updated'); }); +it('clears schedule time slot', function() { + Event::fake(); + + $this->location->updateScheduleTimeSlot(null); + + expect($this->location->getSession($this->location->orderType().'-timeslot'))->toBeNull(); +}); + +it('returns true when location order setting is enabled', function() { + setting()->set('location_order', 1); + + $result = $this->location->requiresUserPosition(); + + expect($result)->toBeTrue(); +}); + it('checks order type correctly', function() { - $location = new Location; - $location->setModel(new LocationModel); + $this->location->setModel(new LocationModel); - expect($location->checkOrderType(LocationModel::DELIVERY))->toBeTrue(); + expect($this->location->checkOrderType(LocationModel::DELIVERY))->toBeTrue(); }); it('gets order type correctly', function() { - $location = new Location; - $location->setModel(new LocationModel); + $this->location->setModel(new LocationModel); + + expect($this->location->getOrderType(LocationModel::DELIVERY))->toBeInstanceOf(AbstractOrderType::class); + + $this->location->putSession('orderType', LocationModel::COLLECTION); + + expect($this->location->orderTypeIsCollection())->toBeTrue(); + + $this->location->putSession('orderType', LocationModel::DELIVERY); + + expect($this->location->orderTypeIsDelivery())->toBeTrue(); +}); + +it('returns true when order type is available and not disabled', function() { + $location = mock(Location::class)->makePartial(); + $orderType = mock(AbstractOrderType::class); + $orderType->shouldReceive('isDisabled')->andReturn(false); + $location->shouldReceive('getOrderType')->andReturn($orderType); + + $result = $location->hasOrderType('delivery'); + + expect($result)->toBeTrue(); +}); + +it('returns false when order type is not available', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getOrderType')->andReturn(null); + + $result = $location->hasOrderType('delivery'); + + expect($result)->toBeFalse(); +}); + +it('returns active order types when all are enabled', function() { + $model = mock(LocationModel::class)->makePartial(); + $this->location->setModel($model); + $orderType1 = mock(AbstractOrderType::class); + $orderType1->shouldReceive('isDisabled')->andReturnFalse(); + $orderType2 = mock(AbstractOrderType::class); + $orderType2->shouldReceive('isDisabled')->andReturnFalse(); + $model->shouldReceive('availableOrderTypes')->andReturn(collect(['delivery' => $orderType1, 'collection' => $orderType2])); + + $result = $this->location->getActiveOrderTypes(); - expect($location->getOrderType(LocationModel::DELIVERY))->toBeInstanceOf(AbstractOrderType::class); + expect($result->count())->toBe(2); +}); + +it('returns opening schedule', function() { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('workingSchedule')->with(LocationModel::OPENING)->andReturn($schedule); + + $result = $location->openingSchedule(); + + expect($result)->toBe($schedule); +}); + +it('returns delivery schedule', function() { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('workingSchedule')->with(LocationModel::DELIVERY)->andReturn($schedule); + + $result = $location->deliverySchedule(); + + expect($result)->toBe($schedule); +}); + +it('returns collection schedule', function() { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('workingSchedule')->with(LocationModel::COLLECTION)->andReturn($schedule); + + $result = $location->collectionSchedule(); + + expect($result)->toBe($schedule); +}); + +it('returns open/close time for given order type and time format', function($method, $scheduleMethod, $type, $format) { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('workingSchedule')->with($type)->andReturn($schedule); + $schedule->shouldReceive($scheduleMethod)->with($format)->andReturn('08:00'); + + $result = $location->$method($type, $format); + + expect($result)->toBe('08:00'); +})->with([ + ['openTime', 'getOpenTime', 'delivery', 'H:i'], + ['openTime', 'getOpenTime', 'collection', 'H:i'], + ['openTime', 'getOpenTime', 'opening', 'H:i'], + ['closeTime', 'getCloseTime', 'delivery', 'H:i'], + ['closeTime', 'getCloseTime', 'collection', 'H:i'], + ['closeTime', 'getCloseTime', 'opening', 'H:i'], +]); + +it('returns open/close time for current order type and no time format', function($method, $scheduleMethod, $type) { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('orderType')->andReturn($type); + $location->shouldReceive('workingSchedule')->with($type)->andReturn($schedule); + $schedule->shouldReceive($scheduleMethod)->andReturn('22:00'); + + $result = $location->$method(); + + expect($result)->toBe('22:00'); +})->with([ + ['openTime', 'getOpenTime', 'delivery'], + ['openTime', 'getOpenTime', 'collection'], + ['openTime', 'getOpenTime', 'opening'], + ['closeTime', 'getCloseTime', 'delivery'], + ['closeTime', 'getCloseTime', 'collection'], + ['closeTime', 'getCloseTime', 'opening'], +]); + +it('returns last order time', function() { + $location = mock(Location::class)->makePartial(); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('getOrderType->getSchedule')->andReturn($schedule); + $schedule->shouldReceive('getCloseTime')->andReturn('22:00'); + + $result = $location->lastOrderTime(); + + expect($result->toTimeString())->toBe('22:00:00'); }); it('gets minimum order total correctly', function() { - $location = new Location; - $location->setModel(new LocationModel); + $this->location->setModel(new LocationModel); LocationFacade::shouldReceive('coveredArea->minimumOrderTotal')->andReturn(10.0); - expect($location->minimumOrderTotal(LocationModel::DELIVERY))->toBeNumeric(); + expect($this->location->minimumOrderTotal(LocationModel::DELIVERY))->toBeNumeric(); }); it('checks minimum order total correctly', function() { - $location = new Location; - $location->setModel(new LocationModel); + $this->location->setModel(new LocationModel); LocationFacade::shouldReceive('coveredArea->minimumOrderTotal')->andReturn(10.0); - expect($location->checkMinimumOrderTotal(100, LocationModel::DELIVERY))->toBeBool(); + expect($this->location->checkMinimumOrder(100, LocationModel::DELIVERY))->toBeBool(); }); it('checks order time correctly', function() { - $location = new Location; - $location->setModel(new LocationModel(['location_id' => 1])); + $this->location->setModel(new LocationModel(['location_id' => 1])); + + expect($this->location->checkOrderTime())->toBeBool(); +}); + +it('checks order time returns false when current time is after order time', function() { + $this->location->setModel(new LocationModel(['location_id' => 1])); + + expect($this->location->checkOrderTime(now()->subMinutes(10), LocationModel::DELIVERY))->toBeFalse(); +}); + +it('checks order time returns false when no future days and location is closed', function() { + $location = mock(Location::class)->makePartial(); + $orderType = mock(AbstractOrderType::class); + $location->shouldReceive('getOrderType')->andReturn($orderType); + $location->shouldReceive('isClosed')->andReturnTrue(); + $orderType->shouldReceive('getFutureDays')->andReturnFalse(); + + expect($location->checkOrderTime(now()->toDateTimeString()))->toBeFalse(); +}); + +it('checks order time returns false when location is closed in future dates', function() { + $location = mock(Location::class)->makePartial(); + $orderType = mock(AbstractOrderType::class); + $location->shouldReceive('getOrderType')->andReturn($orderType); + $location->shouldReceive('isClosed')->andReturnFalse(); + $orderType->shouldReceive('getMinimumFutureDays')->andReturn(5); + $orderType->shouldReceive('getFutureDays')->andReturn(10); + + expect($location->checkOrderTime(now()->addDay(1)))->toBeFalse(); +}); + +it('orderTimeIsAsap returns first schedule timeslot when session date time is in the past', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('orderType')->andReturn('delivery'); + $location->shouldReceive('getSession')->with('delivery-timeslot.dateTime')->andReturn('2023-10-10 10:00:00'); + $location->shouldReceive('orderTimeIsAsap')->andReturn(false); + $location->shouldReceive('hasAsapSchedule')->andReturn(false); + $location->shouldReceive('firstScheduleTimeslot')->andReturn('2023-10-10 12:00:00'); + + $result = $location->orderDateTime(); + + expect($result->toDateTimeString())->toBe('2023-10-10 12:00:00'); +}); + +it('orderTimeIsAsap returns false when order time is not ASAP and no ASAP schedule', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('hasAsapSchedule')->andReturn(false); + + $result = $location->orderTimeIsAsap(); + + expect($result)->toBeFalse(); +}); + +it('orderTimeIsAsap returns false when order time is not ASAP', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('hasAsapSchedule')->andReturnFalse(); + + $result = $location->orderTimeIsAsap(); + + expect($result)->toBeFalse(); +}); + +it('orderTimeIsAsap returns false when order time is not ASAP and location is closed', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('hasAsapSchedule')->andReturn(true); + $location->shouldReceive('orderType')->andReturn('delivery'); + $location->shouldReceive('getSession')->with('delivery-timeslot.dateTime')->andReturn('2023-10-10 10:00:00'); + $location->shouldReceive('getSession')->with('delivery-timeslot.isAsap', true)->andReturn(false); + $location->shouldReceive('isOpened')->andReturnFalse()->once(); + + $result = $location->orderTimeIsAsap(); + + expect($result)->toBeFalse(); +}); + +it('hasAsapSchedule returns false when order type has minimum future days', function() { + $orderType = mock(AbstractOrderType::class); + $orderType->shouldReceive('getMinimumFutureDays')->andReturn(1); + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getOrderType')->andReturn($orderType); + + $result = $location->hasAsapSchedule(); + + expect($result)->toBeFalse(); +}); + +it('isOpened returns true when location is open', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getOrderType->getSchedule->isOpen')->andReturnTrue(); + + $result = $location->isOpened(); + + expect($result)->toBeTrue(); +}); + +it('returns first schedule timeslot when location is closed', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('isClosed')->andReturn(true); + $location->shouldReceive('firstScheduleTimeslot')->andReturn(make_carbon('2023-10-10 12:00:00')); + + $result = $location->asapScheduleTimeslot(); + + expect($result->toDateTimeString())->toBe('2023-10-10 12:00:00'); +}); + +it('returns first schedule timeslot when limit orders is enabled', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('isClosed')->andReturn(false); + $location->shouldReceive('getModel->getSettings')->with('checkout.limit_orders')->andReturn(true); + $location->shouldReceive('firstScheduleTimeslot')->andReturn(make_carbon('2023-10-10 12:00:00')); + + $result = $location->asapScheduleTimeslot(); + + expect($result->toDateTimeString())->toBe('2023-10-10 12:00:00'); +}); + +it('returns first schedule timeslot when available', function() { + $location = mock(Location::class)->makePartial(); + $timeslot = Carbon::parse('2023-10-10 12:00:00'); + $location->shouldReceive('scheduleTimeslot')->andReturn(collect([[$timeslot]])); + + $result = $location->firstScheduleTimeslot(); + + expect($result->toDateTimeString())->toBe('2023-10-10 12:00:00'); +}); + +it('returns schedule timeslot when available and cached', function() { + $location = mock(Location::class)->makePartial(); + $orderType = mock(AbstractOrderType::class); + $schedule = mock(WorkingSchedule::class); + $location->shouldReceive('orderType')->andReturn('delivery'); + $location->shouldReceive('getOrderType')->andReturn($orderType); + $orderType->shouldReceive('getLeadTime')->andReturn(10); + $orderType->shouldReceive('getInterval')->andReturn(15); + $orderType->shouldReceive('getSchedule')->andReturn($schedule); + $schedule->shouldReceive('getTimeslot')->with(15, null, 10)->andReturn(collect(['timeslot'])); + $model = mock(LocationModel::class)->makePartial(); + $model->shouldReceive('shouldAddLeadTime')->andReturnTrue(); + $location->setModel($model); + + $result = $location->scheduleTimeslot(); + + expect($result->all())->toBe(['timeslot']); + + // Call again to test cache + $result = $location->scheduleTimeslot(); + + expect($result->all())->toBe(['timeslot']); +}); + +it('returns false when at least one order type is available', function() { + $this->location->setModel(new LocationModel); + $result = $this->location->checkNoOrderTypeAvailable(); + + expect($result)->toBeFalse(); +}); + +it('returns true when order type has later schedule', function() { + $this->location->setModel(new LocationModel); + + $result = $this->location->hasLaterSchedule(); + + expect($result)->toBeTrue(); +}); + +it('returns working schedule', function() { + $location = LocationModel::factory()->create(); + $this->location->setModel($location); + + $result = $this->location->workingSchedule('delivery'); + + expect($result->getType())->toBe('delivery') + ->and(array_keys($result->getPeriods()))->toBe([ + 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', + ]); + + // Call again to test cache + $this->location->workingSchedule('delivery'); +}); + +it('updates nearby area correctly', function() { + $area = LocationArea::factory()->create(['location_id' => LocationModel::factory()->create()->getKey()]); + + $this->location->updateNearbyArea($area); + + expect($this->location->coveredArea())->toBeInstanceOf(CoveredArea::class) + ->and($this->location->isCurrentAreaId($area->getKey()))->toBeTrue() + ->and($this->location->getAreaId())->toBe($area->getKey()); +}); + +it('coveredArea returns covered area by session area id', function() { + $location = LocationModel::factory()->create(); + $area = LocationArea::factory()->create(['location_id' => $location->getKey()]); + $this->location->putSession('area', $area->getKey()); + $this->location->setModel($location); + + $result = $this->location->coveredArea(); + + expect($result->getKey())->toBe($area->getKey()); +}); + +it('coveredArea returns new covered area when session area id does not match location id', function() { + $location = LocationModel::factory()->create(); + $location2 = LocationModel::factory()->create(); + $area = LocationArea::factory()->create(['location_id' => $location->getKey()]); + $this->location->putSession('area', $area->getKey()); + $this->location->setModel($location2); + + $result = $this->location->coveredArea(); + + expect($result->getKey())->toBeNull(); +}); + +it('returns delivery areas from the model', function() { + $location = LocationModel::factory()->create(); + $location->delivery_areas()->saveMany([ + LocationArea::factory()->make(['location_id' => $location->getKey()]), + LocationArea::factory()->make(['location_id' => $location->getKey()]), + ]); + $this->location->setModel($location); + + $result = $this->location->deliveryAreas(); + + expect($result)->toHaveCount(2); +}); + +it('returns delivery amount from covered area', function() { + $location = LocationModel::factory()->create(); + $area = LocationArea::factory()->create([ + 'location_id' => $location->getKey(), + 'conditions' => [ + ['type' => 'above', 'amount' => 10, 'total' => 100, 'priority' => 1], + ], + ]); + $this->location->putSession('area', $area->getKey()); + $this->location->setModel($location); + + $result = $this->location->deliveryAmount(100); + + expect($result)->toBe(10.0); +}); + +it('returns minimum order total', function() { + $location = LocationModel::factory()->create(); + $area = LocationArea::factory()->create([ + 'location_id' => $location->getKey(), + 'conditions' => [ + ['type' => 'above', 'amount' => 50, 'total' => 100, 'priority' => 1], + ], + ]); + $this->location->putSession('area', $area->getKey()); + $this->location->setModel($location); + LocationFacade::shouldReceive('coveredArea')->andReturn(new CoveredArea($area)); + Cart::shouldReceive('subtotal')->andReturn(200); + + $result = $this->location->minimumOrder(200); + + expect($result)->toBe(100.0); +}); + +it('returns delivery charge conditions from covered area', function() { + $location = LocationModel::factory()->create(); + $area = LocationArea::factory()->create([ + 'location_id' => $location->getKey(), + 'conditions' => [ + ['type' => 'above', 'amount' => 50, 'total' => 100, 'priority' => 1], + ['type' => 'below', 'amount' => 50, 'total' => 100, 'priority' => 1], + ], + ]); + $this->location->putSession('area', $area->getKey()); + $this->location->setModel($location); + + $result = $this->location->getDeliveryChargeConditions(); + + expect($result)->toHaveCount(2); +}); + +it('returns formatted distance when distance is an instance of Distance', function() { + $location = LocationModel::factory()->create([ + 'location_lat' => 51.0, + 'location_lng' => -0.0, + ]); + $location->distanceUnit = 'km'; + $this->location->setModel($location); + $userPosition = new UserLocation('google', [ + 'latitude' => 40.0, + 'longitude' => -74.0, + ]); + $this->location->putSession('position', $userPosition); + + $result = $this->location->checkDistance(); + + expect($result)->toBe(2129.6443); +}); + +it('returns locations ordered by distance', function() { + $location = LocationModel::factory()->create(); + $location->update(['location_lat' => 1.01, 'location_lng' => 0.01]); + $this->location->setModel($location); + $userPosition = new UserLocation('google', [ + 'latitude' => 0.01, + 'longitude' => 0.01, + ]); + $this->location->putSession('position', $userPosition); + + $result = $this->location->searchByCoordinates($userPosition->getCoordinates()); - expect($location->checkOrderTime(now()->setHour(12), LocationModel::DELIVERY))->toBeBool(); + expect($result)->toBeCollection(); }); it('checks delivery coverage correctly', function() { - $location = new Location; - $location->setModel(new LocationModel); + $this->location->setModel(new LocationModel); $userPosition = new UserLocation('google', [ 'latitude' => 0.01, 'longitude' => 0.01, ]); + $this->location->putSession('position', $userPosition); - expect($location->checkDeliveryCoverage($userPosition))->toBeBool(); + expect($this->location->checkDeliveryCoverage())->toBeBool(); }); diff --git a/tests/Classes/ScheduleItemTest.php b/tests/Classes/ScheduleItemTest.php index 63e9486..c58196c 100644 --- a/tests/Classes/ScheduleItemTest.php +++ b/tests/Classes/ScheduleItemTest.php @@ -42,6 +42,22 @@ ->and($hours[1][0]['status'])->toBeTrue(); }); +it('creates empty hours when no matching type', function() { + $data = [ + 'type' => 'invalid', + 'days' => [1, 2, 3], + 'open' => '08:00', + 'close' => '17:00', + 'timesheet' => [], + 'flexible' => [], + ]; + + $scheduleItem = ScheduleItem::create('Test', $data); + $hours = $scheduleItem->getHours(); + + expect(array_filter($hours))->toBeEmpty(); +}); + it('creates timesheet hours correctly', function() { $data = [ 'type' => 'timesheet', @@ -73,6 +89,28 @@ ->and($hours[0][1]['status'])->toBeTrue(); }); +it('creates timesheet hours from json encoded string', function() { + $data = [ + 'type' => 'timesheet', + 'days' => [], + 'open' => '08:00', + 'close' => '17:00', + 'timesheet' => json_encode([ + [ + 'hours' => '09:00-12:00,13:00-17:00', + 'status' => 1, + ], + ]), + 'flexible' => [], + ]; + + $scheduleItem = ScheduleItem::create('Test', $data); + $hours = $scheduleItem->getHours(); + + expect($scheduleItem->type)->toBe('timesheet') + ->and($hours)->toHaveCount(7); +}); + it('creates flexible hours correctly', function() { $data = [ 'type' => 'flexible', @@ -82,6 +120,7 @@ 'timesheet' => [], 'flexible' => [ ['hours' => '09:00-12:00,13:00-17:00'], + ['open' => '09:00', 'close' => '12:00'], // backward compatibility ], ]; diff --git a/tests/Classes/WorkingPeriodTest.php b/tests/Classes/WorkingPeriodTest.php index feacc10..e3a34cd 100644 --- a/tests/Classes/WorkingPeriodTest.php +++ b/tests/Classes/WorkingPeriodTest.php @@ -5,7 +5,9 @@ use DateInterval; use DateTime; use Igniter\Local\Classes\WorkingPeriod; +use Igniter\Local\Classes\WorkingRange; use Igniter\Local\Classes\WorkingTime; +use Igniter\Local\Exceptions\WorkingHourException; it('creates correctly', function() { $times = [ @@ -48,7 +50,8 @@ $workingPeriod = WorkingPeriod::create($times); expect($workingPeriod->openTimeAt(new WorkingTime(10, 00))->format())->toBe('08:00') - ->and($workingPeriod->openTimeAt(new WorkingTime(14, 00))->format())->toBe('13:00'); + ->and($workingPeriod->openTimeAt(new WorkingTime(14, 00))->format())->toBe('13:00') + ->and($workingPeriod->openTimeAt(new WorkingTime(06, 00))->format())->toBe('08:00'); }); it('gets close time at a specific time', function() { @@ -60,19 +63,52 @@ $workingPeriod = WorkingPeriod::create($times); expect($workingPeriod->closeTimeAt(new WorkingTime(10, 00))->format())->toBe('12:00') - ->and($workingPeriod->closeTimeAt(new WorkingTime(14, 00))->format())->toBe('17:00'); + ->and($workingPeriod->closeTimeAt(new WorkingTime(14, 00))->format())->toBe('17:00') + ->and($workingPeriod->closeTimeAt(new WorkingTime(18, 00))->format())->toBe('17:00'); }); -it('gets next open time at a specific time', function() { +it('gets next open time when time is within a range', function() { + $times = [ + ['08:00', '12:00'], + ]; + + $workingPeriod = WorkingPeriod::create($times); + + expect($workingPeriod->nextOpenAt(new WorkingTime(10, 00))->format())->toBe('08:00'); +}); + +it('gets next open time when time is within a range and has multiple ranges', function() { $times = [ ['08:00', '12:00'], ['13:00', '17:00'], + ['18:00', '22:00'], ]; $workingPeriod = WorkingPeriod::create($times); - expect($workingPeriod->nextOpenAt(new WorkingTime(06, 00))->format())->toBe('08:00') - ->and($workingPeriod->nextOpenAt(new WorkingTime(12, 30))->format())->toBe('13:00'); + expect($workingPeriod->nextOpenAt(new WorkingTime(10, 00))->format())->toBe('13:00') + ->and($workingPeriod->nextOpenAt(new WorkingTime(15, 00))->format())->toBe('18:00'); +}); + +it('gets next open time when time is not within a range but in free time', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + + $workingPeriod = WorkingPeriod::create($times); + + expect($workingPeriod->nextOpenAt(new WorkingTime(6, 00))->format())->toBe('08:00'); +}); + +it('returns false when no next open time is found', function() { + $times = [ + ['13:00', '17:00'], + ]; + + $workingPeriod = WorkingPeriod::create($times); + + expect($workingPeriod->nextOpenAt(new WorkingTime(18, 00)))->toBeFalse(); }); it('gets next close time at a specific time', function() { @@ -137,3 +173,71 @@ ->and($timeslot[0]->format('H:i'))->toBe('08:00') ->and($timeslot[1]->format('H:i'))->toBe('08:15'); }); + +it('returns an iterator for the ranges', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + $workingPeriod = WorkingPeriod::create($times); + $iterator = $workingPeriod->getIterator(); + + expect(iterator_to_array($iterator))->toHaveCount(2); +}); + +it('checks if an offset exists in ranges', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + $workingPeriod = WorkingPeriod::create($times); + + $exists = $workingPeriod->offsetExists(0); + + expect($exists)->toBeTrue(); +}); + +it('retrieves a range by offset', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + $workingPeriod = WorkingPeriod::create($times); + + $retrievedRange = $workingPeriod->offsetGet(0); + + expect($retrievedRange)->toBeInstanceOf(WorkingRange::class); +}); + +it('throws an exception when setting a range by offset', function() { + $times = []; + $workingPeriod = WorkingPeriod::create($times); + + expect(fn() => $workingPeriod->offsetSet(0, WorkingRange::create(['08:00', '12:00']))) + ->toThrow(WorkingHourException::class); +}); + +it('unsets a range by offset', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + $workingPeriod = WorkingPeriod::create($times); + + $workingPeriod->offsetUnset(0); + + expect(count($workingPeriod))->toBe(1); +}); + +it('returns a string representation of the ranges', function() { + $times = [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ]; + $workingPeriod = WorkingPeriod::create($times); + + $stringRepresentation = (string)$workingPeriod; + + expect($stringRepresentation)->toBe('08:00-12:00,13:00-17:00'); +}); + diff --git a/tests/Classes/WorkingRangeTest.php b/tests/Classes/WorkingRangeTest.php index b98db69..b284371 100644 --- a/tests/Classes/WorkingRangeTest.php +++ b/tests/Classes/WorkingRangeTest.php @@ -17,7 +17,8 @@ it('creates from ranges correctly', function() { $ranges = [ - WorkingRange::create(['08:00', '12:00']), + WorkingRange::create(['10:00', '12:00']), + WorkingRange::create(['08:00', '17:00']), WorkingRange::create(['13:00', '17:00']), ]; @@ -60,6 +61,14 @@ ->and($workingRange->containsTime(new WorkingTime(18, 00)))->toBeFalse(); }); +it('checks if contains time when ends the next day', function() { + $times = ['15:00', '04:00']; + + $workingRange = WorkingRange::create($times); + + expect($workingRange->containsTime(new WorkingTime(18, 00)))->toBeTrue(); +}); + it('checks if overlaps', function() { $workingRange1 = WorkingRange::create(['08:00', '12:00']); $workingRange2 = WorkingRange::create(['11:00', '13:00']); diff --git a/tests/Classes/WorkingScheduleTest.php b/tests/Classes/WorkingScheduleTest.php index d65eabf..8333158 100644 --- a/tests/Classes/WorkingScheduleTest.php +++ b/tests/Classes/WorkingScheduleTest.php @@ -6,6 +6,9 @@ use DateTime; use Igniter\Local\Classes\WorkingPeriod; use Igniter\Local\Classes\WorkingSchedule; +use Igniter\Local\Exceptions\WorkingHourException; +use Igniter\Local\Models\WorkingHour; +use Illuminate\Support\Carbon; it('creates correctly', function() { $workingSchedule = new WorkingSchedule('UTC', 5); @@ -24,12 +27,20 @@ it('fills correctly', function() { $workingSchedule = new WorkingSchedule('UTC', 5); + $workingHour = WorkingHour::create([ + 'weekday' => 'tuesday', + 'opening_time' => '08:00', + 'closing_time' => '17:00', + 'status' => 0, + ]); + $workingSchedule->fill([ 'periods' => [ 'monday' => [ ['08:00', '12:00'], ['13:00', '17:00'], ], + $workingHour, ], 'exceptions' => [ '2022-12-31' => [ @@ -42,9 +53,160 @@ ->and($workingSchedule->exceptions())->toHaveCount(1); }); +it('sets the current time to now', function() { + $workingSchedule = new WorkingSchedule(); + $now = new DateTime('2023-01-01 12:00:00'); + + $result = $workingSchedule->setNow($now); + + expect($result)->toBe($workingSchedule); +}); + +it('sets the timezone correctly', function() { + $workingSchedule = new class extends WorkingSchedule + { + public function timezone() + { + return $this->timezone; + } + }; + $timezone = 'America/New_York'; + + $workingSchedule->setTimezone($timezone); + + expect($workingSchedule->timezone()->getName())->toBe($timezone); +}); + +it('checks next open time', function() { + $this->travelTo(new DateTime('2023-01-01 10:00:00')); // Sunday + $workingSchedule = new WorkingSchedule('UTC', [0, 15]); + $workingSchedule->fill([ + 'periods' => [ + 'monday' => [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ], + ], + ]); + + $result = $workingSchedule->isOpening(); + + expect($result)->toBeTrue(); +}); + +it('checks next open time fails when no periods', function() { + $this->travelTo(new DateTime('2023-01-03 10:00:00')); // Tuesday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + + $result = $workingSchedule->isOpening(); + + expect($result)->toBeFalse(); +}); + +it('opens on day with no periods', function() { + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'monday' => [], + ], + ]); + + $result = $workingSchedule->isOpenOn('monday'); + + expect($result)->toBeFalse(); +}); + +it('closed on day with no periods', function() { + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'monday' => [], + ], + ]); + + $result = $workingSchedule->isClosedOn('sunday'); + + expect($result)->toBeTrue(); +}); + +it('returns the working period for a given date', function() { + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'monday' => [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ], + ], + ]); + + $result = $workingSchedule->getPeriod(new DateTime('2023-01-03 10:00:00')); + + expect($result)->toBeInstanceOf(WorkingPeriod::class); +}); + +it('returns the next open time formatted', function() { + $this->travelTo(new DateTime('2023-01-01 10:00:00')); // Sunday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'monday' => [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ], + ], + ]); + + $result = $workingSchedule->getOpenTime('H:i'); + + expect($result)->toBe('08:00'); +}); + +it('returns the next close time formatted', function() { + $this->travelTo(new DateTime('2023-01-01 10:00:00')); // Sunday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'sunday' => [ + ['08:00', '12:00'], + ['13:00', '17:00'], + ], + ], + ]); + + $result = $workingSchedule->getCloseTime('H:i'); + + expect($result)->toBe('12:00'); +}); + +it('checks next close time fails when no periods and exceptions', function() { + $this->travelTo(new DateTime('2023-01-03 10:00:00')); // Tuesday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + + $result = $workingSchedule->getCloseTime('H:i'); + + expect($result)->toBeNull(); +}); + +it('checks next close time with exceptions', function() { + $this->travelTo(new DateTime('2023-01-03 10:00:00')); // Tuesday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'exceptions' => [ + '2024-01-03' => [ + ['08:00', '12:00'], + ], + ], + ]); + + $result = $workingSchedule->getCloseTime('H:i'); + + expect($result)->toBe('12:00'); +}); + it('checks status correctly', function() { + $this->travelTo(new DateTime('2022-12-25 10:00:00')); $workingSchedule = new WorkingSchedule('UTC', 0); - $workingSchedule->fill([ 'periods' => [ 'monday' => [ @@ -60,16 +222,22 @@ ], ]); - expect($workingSchedule->checkStatus(new DateTime('2022-12-25 10:00:00')))->toBe(WorkingPeriod::OPEN) - ->and($workingSchedule->checkStatus(new DateTime('2022-12-31 20:00:00')))->toBe(WorkingPeriod::CLOSED) + expect($workingSchedule->checkStatus())->toBe(WorkingPeriod::OPEN) + ->and($workingSchedule->checkStatus('2022-12-31 20:00:00'))->toBe(WorkingPeriod::CLOSED) ->and($workingSchedule->checkStatus(new DateTime('2023-01-02 20:00:00')))->toBe(WorkingPeriod::CLOSED) ->and($workingSchedule->checkStatus(new DateTime('2023-01-02 10:00:00')))->toBe(WorkingPeriod::OPEN) ->and($workingSchedule->checkStatus(new DateTime('2023-01-02 07:00:00')))->toBe(WorkingPeriod::OPENING); }); +it('throws exception when checking status with invalid date instance', function() { + $workingSchedule = new WorkingSchedule('UTC', 0); + + expect(fn() => $workingSchedule->checkStatus(new \stdClass()))->toThrow(WorkingHourException::class); +}); + it('gets timeslot correctly', function() { + $this->travelTo(new DateTime('2023-01-02 10:00:00')); $workingSchedule = new WorkingSchedule('UTC', 5); - $workingSchedule->fill([ 'periods' => [ 'monday' => [ @@ -79,15 +247,56 @@ ], ]); - $timeslot = $this->travelTo(new DateTime('2023-01-02 10:00:00'), function() use ($workingSchedule) { - return $workingSchedule->getTimeslot(15, new DateTime('2023-01-02 10:00:00'))->all(); - }); + $timeslot = $workingSchedule->getTimeslot(15, new DateTime('2023-01-02 10:00:00'))->all(); expect($timeslot)->toBeArray() ->and($timeslot['2023-01-02'])->toHaveCount(22); }); +it('gets empty timeslot when no next open time', function() { + $this->travelTo(new DateTime('2023-01-03 10:00:00')); // Tuesday + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + + $timeslot = $workingSchedule->getTimeslot(15, new DateTime('2023-01-02 10:00:00'))->all(); + + expect($timeslot)->toBeEmpty(); +}); + +it('gets timeslot when when closes late', function() { + $workingSchedule = new WorkingSchedule('UTC', [0, 5]); + $workingSchedule->fill([ + 'periods' => [ + 'sunday' => [ + ['18:00', '02:00'], + ], + ], + ]); + + $timeslot = $workingSchedule->getTimeslot(15, new DateTime('2023-01-03 20:00:00'))->all(); + + expect($timeslot)->toBeEmpty(); +}); + +it('gets timeslot when when next end date is less than current date', function() { + $workingSchedule = new WorkingSchedule('UTC', [1, 1]); + $workingSchedule->fill([ + 'periods' => [ + 'tuesday' => [ + ['18:00', '19:00'], + ], + 'monday' => [ + ['18:00', '23:00'], + ], + ], + ]); + + $timeslot = $workingSchedule->getTimeslot(3, new DateTime('2023-01-03 20:00:00'))->all(); + + expect($timeslot)->toBeEmpty(); +}); + it('generates timeslot correctly', function() { + $this->travelTo(new DateTime('2023-01-02 10:00:00')); $workingSchedule = new WorkingSchedule('UTC', 5); $workingSchedule->fill([ @@ -99,13 +308,48 @@ ], ]); - $timeslot = $this->travelTo(new DateTime('2023-01-02 10:00:00'), function() use ($workingSchedule) { - return $workingSchedule->generateTimeslot( - new DateTime('2023-01-02 10:00:00'), - new DateInterval('PT15M') - )->all(); - }); + $timeslot = $workingSchedule->generateTimeslot( + new DateTime('2023-01-02 10:00:00'), + new DateInterval('PT15M'), + )->all(); expect($timeslot)->toBeArray() ->and($timeslot)->toHaveCount(22); }); + +it('generates empty timeslot', function() { + $this->travelTo(new DateTime('2023-01-02 07:00:00')); + $workingSchedule = new WorkingSchedule('UTC', [2, 5]); + $workingSchedule->fill([ + 'exceptions' => [ + '2023-01-02' => [ + ['08:00', '12:00'], + ], + ], + ]); + + $timeslot = $workingSchedule->generateTimeslot( + new DateTime('2023-01-02 10:00:00'), + new DateInterval('PT15M'), + )->all(); + + expect($timeslot)->toBeArray()->and($timeslot)->toHaveCount(0); +}); + +it('adjusts end date when next close date is before current date', function() { + $dateTime = Carbon::now(); + $workingSchedule = mock(WorkingSchedule::class)->makePartial(); + $workingSchedule->shouldReceive('nextOpenAt')->andReturn($dateTime->copy()->subDay()); + $workingSchedule->shouldReceive('nextCloseAt')->andReturn($dateTime->copy()->subDay()); + $workingPeriod = mock(WorkingPeriod::class); + $workingPeriod->shouldReceive('closesLate')->andReturnFalse(); + $workingSchedule->shouldReceive('forDate')->andReturn($workingPeriod); + + $reflection = new \ReflectionClass(WorkingSchedule::class); + $method = $reflection->getMethod('createPeriodForDays'); + $method->setAccessible(true); + $result = $method->invoke($workingSchedule, $dateTime); + + + expect($result->end->toDateString())->toBe($dateTime->copy()->subDay()->addDay()->toDateString()); +}); diff --git a/tests/Classes/WorkingTimeTest.php b/tests/Classes/WorkingTimeTest.php index fe20367..a1e6613 100644 --- a/tests/Classes/WorkingTimeTest.php +++ b/tests/Classes/WorkingTimeTest.php @@ -4,6 +4,7 @@ use DateTime; use Igniter\Local\Classes\WorkingTime; +use Igniter\Local\Exceptions\WorkingHourException; it('creates correctly', function() { $workingTime = WorkingTime::create('08:00'); @@ -12,6 +13,10 @@ ->and($workingTime->minutes())->toBe(0); }); +it('throws exception when creating with invalid date', function() { + expect(fn() => WorkingTime::create('invalid'))->toThrow(WorkingHourException::class); +}); + it('creates from DateTime correctly', function() { $dateTime = new DateTime('08:00'); @@ -27,8 +32,10 @@ expect($workingTime1->isSame($workingTime1))->toBeTrue() ->and($workingTime1->isSame($workingTime2))->toBeFalse() + ->and($workingTime1->isAfter($workingTime1))->toBeFalse() ->and($workingTime1->isAfter($workingTime2))->toBeFalse() ->and($workingTime1->isBefore($workingTime2))->toBeTrue() + ->and($workingTime1->isBefore($workingTime1))->toBeFalse() ->and($workingTime1->isSameOrAfter($workingTime1))->toBeTrue() ->and($workingTime1->isSameOrAfter($workingTime2))->toBeFalse(); }); diff --git a/tests/ExtensionTest.php b/tests/ExtensionTest.php index ced82c6..42dedb5 100644 --- a/tests/ExtensionTest.php +++ b/tests/ExtensionTest.php @@ -2,13 +2,18 @@ namespace Igniter\Local\Tests; +use Igniter\Admin\DashboardWidgets\Charts; +use Igniter\Admin\Facades\AdminMenu; +use Igniter\Cart\Http\Controllers\Menus; use Igniter\Flame\Geolite\Facades\Geocoder; use Igniter\Flame\Geolite\Model\Location as UserPosition; +use Igniter\Flame\Support\Facades\Igniter; use Igniter\Local\Extension; use Igniter\Local\Facades\Location as LocationFacade; use Igniter\Local\Models\Location; use Igniter\Local\Models\LocationArea; use Igniter\Local\Models\Review; +use Igniter\Local\Models\ReviewSettings; use Igniter\Reservation\Models\Reservation; use Igniter\User\Facades\Auth; use Igniter\User\Models\Customer; @@ -60,35 +65,33 @@ }); it('updates customer last area on location position updated', function() { + $customer = Customer::factory()->create(); $location = Mockery::mock(Location::class); $position = Mockery::mock(UserPosition::class); $oldPosition = Mockery::mock(UserPosition::class); - $position->shouldReceive('format')->andReturn('new-position'); $oldPosition->shouldReceive('format')->andReturn('old-position'); - - Event::fake(); + Auth::shouldReceive('customer')->andReturn($customer); $this->extension->boot(); Event::dispatch('location.position.updated', [$location, $position, $oldPosition]); - Event::assertDispatched('location.position.updated'); + expect($customer->last_location_area)->toContain('new-position'); }); it('updates customer last area on location area updated', function() { + $customer = Customer::factory()->create(); $location = Mockery::mock(Location::class); $coveredArea = Mockery::mock(LocationArea::class); - $coveredArea->shouldReceive('getKey')->andReturn(1); - - Event::fake(); + Auth::shouldReceive('customer')->andReturn($customer); $this->extension->boot(); Event::dispatch('location.area.updated', [$location, $coveredArea]); - Event::assertDispatched('location.area.updated'); + expect($customer->last_location_area)->toContain(1); }); it('updates user position and nearby area on user login', function() { @@ -100,17 +103,114 @@ 'last_location_area' => json_encode(['query' => 'test-query', 'areaId' => $locationArea->getKey()]), ]); Auth::shouldReceive('customer')->andReturn($customer); + $userLocation = Mockery::mock(UserPosition::class)->makePartial(); + Geocoder::shouldReceive('geocode')->with('test-query')->andReturn(collect([$userLocation])); + + LocationFacade::shouldReceive('updateUserPosition')->with($userLocation)->twice(); + LocationFacade::shouldReceive('updateNearbyArea')->twice(); - Geocoder::shouldReceive('geocode')->with('test-query')->andReturnSelf(); - Geocoder::shouldReceive('first')->andReturn(Mockery::mock(UserPosition::class)); + $this->extension->boot(); + + Event::dispatch('igniter.user.login'); +}); - LocationFacade::shouldReceive('updateNearbyArea')->with($locationArea); +it('does not updates user position on user login when last_location_area is empty', function() { + $customer = Customer::factory()->create([ + 'customer_id' => 1, + 'last_location_area' => '', + ]); + Auth::shouldReceive('customer')->andReturn($customer); - Event::fake(); + LocationFacade::shouldReceive('updateUserPosition')->never(); + LocationFacade::shouldReceive('updateNearbyArea')->never(); $this->extension->boot(); Event::dispatch('igniter.user.login'); +}); + +it('returns delivery condition with correct attributes', function() { + $extension = new \Igniter\Local\Extension(app()); + + $result = $extension->registerCartConditions(); + + expect($result)->toEqual([ + \Igniter\Local\CartConditions\Delivery::class => [ + 'name' => 'delivery', + 'label' => 'lang:igniter.local::default.text_delivery', + 'description' => 'lang:igniter.local::default.help_delivery_condition', + ], + ]); +}); + +it('returns registered permissions array', function() { + $extension = new \Igniter\Local\Extension(app()); + + $result = $extension->registerPermissions(); + + expect($result)->toEqual([ + 'Admin.Locations' => [ + 'label' => 'lang:igniter.local::default.locations_permissions', + 'group' => 'igniter::admin.permissions.name', + ], + 'Admin.Reviews' => [ + 'description' => 'lang:igniter.local::default.reviews.permissions', + 'group' => 'igniter.cart::default.text_permission_order_group', + ], + ]); +}); + +it('returns registered settings array', function() { + $extension = new \Igniter\Local\Extension(app()); + + $result = $extension->registerSettings(); + + expect($result)->toEqual([ + 'reviewsettings' => [ + 'label' => 'lang:igniter.local::default.reviews.text_settings', + 'icon' => 'fa fa-gear', + 'description' => 'lang:igniter.local::default.reviews.text_settings_description', + 'model' => \Igniter\Local\Models\ReviewSettings::class, + 'permissions' => ['Admin.Reviews'], + ], + ]); +}); + +it('returns registered onboarding steps array', function() { + $extension = new \Igniter\Local\Extension(app()); + + $result = $extension->registerOnboardingSteps(); + + expect($result)->toEqual([ + 'igniter.local::locations' => [ + 'label' => 'igniter.local::default.onboarding_locations', + 'description' => 'igniter.local::default.help_onboarding_locations', + 'icon' => 'fa-store', + 'url' => admin_url('locations'), + 'priority' => 15, + 'complete' => [\Igniter\Local\Models\Location::class, 'onboardingIsComplete'], + ], + ]); +}); + +it('returns registered dashboard charts', function() { + ReviewSettings::set('allow_reviews', true); + $charts = new class(resolve(Menus::class)) extends Charts + { + public function testDatasets() + { + return $this->listSets(); + } + }; + $datasets = $charts->testDatasets(); + + expect($datasets['reports']['sets']['reviews']['model'])->toBe(Review::class); +}); + +it('registers locations picker admin menus when running in admin', function() { + Igniter::partialMock()->shouldReceive('runningInAdmin')->andReturnTrue(); + $this->extension->boot(); + $menuItems = AdminMenu::getMainItems(); - Event::assertDispatched('igniter.user.login'); + expect($menuItems['locations'])->not->toBeNull(); }); diff --git a/tests/FormWidgets/MapAreaTest.php b/tests/FormWidgets/MapAreaTest.php index ad57e85..2fd2ffd 100644 --- a/tests/FormWidgets/MapAreaTest.php +++ b/tests/FormWidgets/MapAreaTest.php @@ -12,9 +12,9 @@ beforeEach(function() { $this->location = Location::factory()->create(); - $formField = (new FormField('test_field', 'Map area')) + $this->formField = (new FormField('test_field', 'Map area')) ->displayAs('maparea', ['valueFrom' => 'delivery_areas']); - $this->mapAreaWidget = new MapArea(resolve(Locations::class), $formField, [ + $this->mapAreaWidget = new MapArea(resolve(Locations::class), $this->formField, [ 'model' => $this->location, ]); }); @@ -49,6 +49,10 @@ }); it('prepares variables correctly', function() { + $this->formField->value = [ + ['area_id' => 1, 'name' => 'Test Area 1'], + ]; + $this->mapAreaWidget->prepareVars(); expect($this->mapAreaWidget->vars['field'])->toBeInstanceOf(FormField::class) @@ -58,8 +62,14 @@ ->and($this->mapAreaWidget->vars)->toHaveKey('prompt'); }); -it('saves value correctly', function() { +it('gets saves value', function() { expect($this->mapAreaWidget->getSaveValue([]))->toBeNull(); + + $area = LocationArea::factory()->create(); + request()->request->set('___dragged_test_field', [$area->getKey()]); + $this->formField->value = collect([$area]); + + expect($this->mapAreaWidget->getSaveValue([]))->toBeArray(); }); it('returns no save value when sortable is disabled correctly', function() { @@ -68,11 +78,25 @@ expect($this->mapAreaWidget->getSaveValue([]))->toBe(FormField::NO_SAVE_DATA); }); -it('loads record correctly', function() { +it('loads new record correctly', function() { expect($this->mapAreaWidget->onLoadRecord())->toBeString(); }); -it('saves record correctly', function() { +it('loads existing record correctly', function() { + $area = LocationArea::factory()->create(); + request()->request->set('recordId', $area->getKey()); + + expect($this->mapAreaWidget->onLoadRecord())->toBeString(); +}); + +it('saves new record correctly', function() { + expect($this->mapAreaWidget->onSaveRecord())->toBeArray(); +}); + +it('saves existing record correctly', function() { + $area = LocationArea::factory()->create(); + request()->request->set('areaId', $area->getKey()); + expect($this->mapAreaWidget->onSaveRecord())->toBeArray(); }); diff --git a/tests/FormWidgets/MapViewTest.php b/tests/FormWidgets/MapViewTest.php index e1d8a02..948c3ea 100644 --- a/tests/FormWidgets/MapViewTest.php +++ b/tests/FormWidgets/MapViewTest.php @@ -45,6 +45,12 @@ $this->mapViewWidget->loadAssets(); }); +it('renders correctly', function() { + $this->mapViewWidget->prepareVars(); + + expect($this->mapViewWidget->render())->toBeString(); +}); + it('prepares variables correctly', function() { $this->mapViewWidget->prepareVars(); @@ -53,6 +59,12 @@ ->and($this->mapViewWidget->vars['mapCenter'])->toBeArray() ->and($this->mapViewWidget->vars['shapeSelector'])->toBe('[data-map-shape]') ->and($this->mapViewWidget->vars['previewMode'])->toBeFalse(); + + $this->mapViewWidget->center = ['lat' => 51.5074, 'lng' => 0.1278]; + + $this->mapViewWidget->prepareVars(); + + expect($this->mapViewWidget->vars['mapCenter'])->toBe(['lat' => 51.5074, 'lng' => 0.1278]); }); it('checks configuration correctly', function() { diff --git a/tests/FormWidgets/ScheduleEditorTest.php b/tests/FormWidgets/ScheduleEditorTest.php index cb9686c..dc26673 100644 --- a/tests/FormWidgets/ScheduleEditorTest.php +++ b/tests/FormWidgets/ScheduleEditorTest.php @@ -33,6 +33,10 @@ expect($this->scheduleEditorWidget->vars['field'])->toBeInstanceOf(FormField::class) ->and($this->scheduleEditorWidget->vars['schedules'])->toBeArray(); + + // test coverage for cached schedules + + $this->scheduleEditorWidget->prepareVars(); }); it('loads assets correctly', function() { diff --git a/tests/Http/Actions/LocationAwareControllerTest.php b/tests/Http/Actions/LocationAwareControllerTest.php index 2e310f4..f47576f 100644 --- a/tests/Http/Actions/LocationAwareControllerTest.php +++ b/tests/Http/Actions/LocationAwareControllerTest.php @@ -10,10 +10,14 @@ use Igniter\Local\Http\Actions\LocationAwareController; use Igniter\Local\Models\Location; use Igniter\Local\Models\Review; +use Igniter\Local\Models\WorkingHour; use Illuminate\Support\Facades\Event; beforeEach(function() { - Event::fake(); + Event::fakeExcept([ + 'admin.list.extendQuery', + 'admin.filter.extendQuery', + ]); $this->controller = new class extends AdminController { @@ -32,17 +36,51 @@ public $listConfig = [ 'list' => [ 'model' => Location::class, - 'configFile' => 'config_file', + 'configFile' => [ + 'list' => [ + 'filter' => [ + 'scopes' => [ + 'status' => [ + 'label' => 'lang:admin::lang.list.filter_recent', + 'conditions' => 'created_at >= :recent', + 'modelClass' => Location::class, + 'value' => '-30 days', + 'locationAware' => true, + ], + ], + ], + 'columns' => [ + 'location_id' => [ + 'label' => 'lang:admin::lang.locations.column_id', + 'type' => 'number', + 'searchable' => true, + ], + ], + ], + ], ], ]; public $formConfig = [ + 'name' => 'lang:admin::lang.locations.text_form_name', 'model' => Location::class, - 'configFile' => 'config_file', + 'configFile' => [ + 'form' => [ + 'tabs' => [ + 'fields' => [ + 'location_id' => [ + 'label' => 'lang:admin::lang.locations.label_id', + 'type' => 'text', + 'span' => 'left', + ], + ], + ], + ], + ], ]; }; - $this->locationAwareController = new LocationAwareController($this->controller); + $this->locationAwareController = $this->controller->asExtension(LocationAwareController::class); }); it('initializes correctly', function() { @@ -61,6 +99,23 @@ $this->controller->fireEvent('controller.beforeRemap'); }); +it('applies location scope on events', function() { + $this->controller->fireEvent('controller.beforeRemap'); + + $listWidgets = $this->controller->asExtension(ListController::class)->makeLists(); + $listWidget = $listWidgets['list']; + + expect($listWidget->render())->toBeString(); + + $filterWidget = $this->controller->widgets['list_filter']; + expect($filterWidget->render())->toBeString(); + + LocationFacade::shouldReceive('currentOrAssigned')->andReturn([1, 2]); + $query = Menu::query(); + $this->controller->fireEvent('admin.controller.extendFormQuery', [$query]); + expect($query->toSql())->toContain('`location_id` in (?, ?)'); +}); + it('applies location scope correctly', function($query, $expectedSql) { LocationFacade::shouldReceive('currentOrAssigned')->andReturn([1, 2]); $this->locationAwareController->locationApplyScope($query); @@ -71,6 +126,13 @@ fn() => [Menu::query(), 'and `locationables`.`location_id` in (?, ?)'], ]); +it('does not applies location scope when model does not use Locationable trait', function() { + $query = WorkingHour::query(); + $this->locationAwareController->locationApplyScope($query); + + expect($query->toSql())->not->toContain('`location_id`'); +}); + it('does not applies location scope when user current or assigned location is missing', function() { $query = Review::query(); diff --git a/tests/Http/Controllers/LocationsTest.php b/tests/Http/Controllers/LocationsTest.php index 0ee91e8..e2aef9f 100644 --- a/tests/Http/Controllers/LocationsTest.php +++ b/tests/Http/Controllers/LocationsTest.php @@ -40,6 +40,21 @@ ->assertOk(); }); +it('sets a default location', function() { + $location = Location::factory()->create(); + + actingAsSuperUser() + ->post(route('igniter.local.locations'), [ + 'default' => $location->getKey(), + ], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onSetDefault', + ]); + + Location::$defaultModels = []; + expect(Location::getDefaultKey())->toBe($location->getKey()); +}); + it('creates location', function() { actingAsSuperUser() ->post(route('igniter.local.locations', ['slug' => 'create']), [ @@ -92,13 +107,25 @@ expect(Location::where('location_name', 'Updated Location')->exists())->toBeTrue(); }); -it('deletes location', function() { +it('updates location settings', function() { $location = Location::factory()->create(); actingAsSuperUser() - ->post(route('igniter.local.locations', ['slug' => 'edit/'.$location->getKey()]), [ - 'coupon_id' => $location->coupon_id, + ->post(route('igniter.local.locations', ['slug' => 'settings/'.$location->getKey()]), [ + 'Location' => [ + ], ], [ + 'X-Requested-With' => 'XMLHttpRequest', + 'X-IGNITER-REQUEST-HANDLER' => 'onSave', + ]) + ->assertOk(); +}); + +it('deletes location', function() { + $location = Location::factory()->create(); + + actingAsSuperUser() + ->post(route('igniter.local.locations', ['slug' => 'edit/'.$location->getKey()]), [], [ 'X-Requested-With' => 'XMLHttpRequest', 'X-IGNITER-REQUEST-HANDLER' => 'onDelete', ]); diff --git a/tests/Http/Controllers/ReviewSettingsTest.php b/tests/Http/Controllers/ReviewSettingsTest.php new file mode 100644 index 0000000..8a2175e --- /dev/null +++ b/tests/Http/Controllers/ReviewSettingsTest.php @@ -0,0 +1,14 @@ +shouldReceive('runningInAdmin')->andReturnTrue(); + actingAsSuperUser() + ->get(route('igniter.system.extensions', ['slug' => 'edit/igniter/local/reviewsettings'])) + ->assertOk(); +}); + diff --git a/tests/Http/Middleware/CheckLocationTest.php b/tests/Http/Middleware/CheckLocationTest.php index 75912a2..1d46505 100644 --- a/tests/Http/Middleware/CheckLocationTest.php +++ b/tests/Http/Middleware/CheckLocationTest.php @@ -23,6 +23,20 @@ expect(request()->route('location'))->toBe($location->permalink_slug); }); +it('handles admin request correctly', function() { + Route::get('admin/test-route/{location}', fn() => 'ok')->middleware(CheckLocation::class); + + $location = LocationModel::factory()->create([ + 'permalink_slug' => 'test-location', + ]); + + Location::shouldReceive('currentOrDefault')->andReturn($location); + + $this->get('admin/test-route/test-location')->assertStatus(200); + + expect(request()->route('location'))->toBe($location->permalink_slug); +}); + it('redirects when location route parameter does not match current location slug', function() { Route::get('test-route/{location}', fn() => 'ok')->middleware(CheckLocation::class); @@ -33,7 +47,24 @@ Location::shouldReceive('currentOrDefault')->andReturn($location); $this->get('test-route/wrong-location') - ->assertStatus(302); + ->assertStatus(302) + ->assertRedirect(page_url('home')); +}); + +it('redirects when location is disabled and admin does not have permission', function() { + Route::get('test-route/{location}', fn() => 'ok')->middleware(CheckLocation::class); + + $location = LocationModel::factory()->create([ + 'permalink_slug' => 'test-location', + 'location_status' => 0, + ]); + + Location::shouldReceive('currentOrDefault')->andReturn($location); + AdminAuth::shouldReceive('getUser->hasPermission')->andReturn(false); + + $this->get('test-route/test-location') + ->assertStatus(302) + ->assertRedirect(page_url('home')); }); it('checks admin location correctly', function() { @@ -53,3 +84,21 @@ expect(request()->route('location'))->toBe($location->permalink_slug); }); + +it('checks admin location fails when user not assigned to location', function() { + Route::get('admin/test-route', fn() => 'ok')->middleware(CheckLocation::class); + + $user = User::factory()->create(); + $location = LocationModel::factory()->create([ + 'permalink_slug' => 'test-location', + ]); + + AdminAuth::shouldReceive('check')->andReturn(true); + Location::shouldReceive('resetSession')->andReturnNull(); + Location::shouldReceive('current')->andReturn($location); + AdminAuth::shouldReceive('user')->andReturn($user); + + $this->get('admin/test-route'); + + expect(request()->route('location'))->toBeNull(); +}); diff --git a/tests/Http/Requests/LocationAreaRequestTest.php b/tests/Http/Requests/LocationAreaRequestTest.php index 09cd0f4..7fbe511 100644 --- a/tests/Http/Requests/LocationAreaRequestTest.php +++ b/tests/Http/Requests/LocationAreaRequestTest.php @@ -4,78 +4,44 @@ use Igniter\Local\Http\Requests\LocationAreaRequest; -beforeEach(function() { - $this->rules = (new LocationAreaRequest)->rules(); -}); - -it('has required rule for name, priority, and status', function() { - expect('required')->toBeIn(array_get($this->rules, 'type')) - ->and('required')->toBeIn(array_get($this->rules, 'name')) - ->and('required')->toBeIn(array_get($this->rules, 'boundaries.components.*.type')) - ->and('required')->toBeIn(array_get($this->rules, 'boundaries.components.*.value')) - ->and('required')->toBeIn(array_get($this->rules, 'boundaries.distance.*.type')) - ->and('required')->toBeIn(array_get($this->rules, 'boundaries.distance.*.distance')) - ->and('required')->toBeIn(array_get($this->rules, 'boundaries.distance.*.charge')) - ->and('required')->toBeIn(array_get($this->rules, 'conditions.*.amount')) - ->and('required')->toBeIn(array_get($this->rules, 'conditions.*.type')) - ->and('required')->toBeIn(array_get($this->rules, 'conditions.*.total')); -}); - -it('has sometimes rule for inputs', function() { - expect('sometimes')->toBeIn(array_get($this->rules, 'type')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'name')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.components')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.components.*.type')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.components.*.value')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.polygon')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.distance.*.type')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.distance.*.distance')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'boundaries.distance.*.charge')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'conditions')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'conditions.*.amount')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'conditions.*.type')) - ->and('sometimes')->toBeIn(array_get($this->rules, 'conditions.*.total')); -}); - -it('has integer rule for area_id', function() { - expect('integer')->toBeIn(array_get($this->rules, 'area_id')); -}); - -it('has string rule for type, components, and distance', function() { - expect('string')->toBeIn(array_get($this->rules, 'type')) - ->and('string')->toBeIn(array_get($this->rules, 'name')) - ->and('string')->toBeIn(array_get($this->rules, 'boundaries.components.*.type')) - ->and('string')->toBeIn(array_get($this->rules, 'boundaries.components.*.value')) - ->and('string')->toBeIn(array_get($this->rules, 'boundaries.distance.*.type')); -}); - -it('has numeric rule for distance, charge, amount, and total', function() { - expect('numeric')->toBeIn(array_get($this->rules, 'boundaries.distance.*.distance')) - ->and('numeric')->toBeIn(array_get($this->rules, 'boundaries.distance.*.charge')) - ->and('numeric')->toBeIn(array_get($this->rules, 'conditions.*.amount')) - ->and('numeric')->toBeIn(array_get($this->rules, 'conditions.*.total')); -}); - -it('has alpha_dash rule for type', function() { - expect('alpha_dash')->toBeIn(array_get($this->rules, 'conditions.*.type')); -}); - -it('has json rule for circle and vertices', function() { - expect('json')->toBeIn(array_get($this->rules, 'boundaries.circle')) - ->and('json')->toBeIn(array_get($this->rules, 'boundaries.vertices')); -}); - -it('has required_if rule for components, polygon, and circle', function() { - expect('required_if:type,address')->toBeIn(array_get($this->rules, 'boundaries.components')) - ->and('required_if:type,polygon')->toBeIn(array_get($this->rules, 'boundaries.polygon')) - ->and('required_if:type,circle')->toBeIn(array_get($this->rules, 'boundaries.circle')); -}); - -it('has required_unless rule for vertices', function() { - expect('required_unless:type,address')->toBeIn(array_get($this->rules, 'boundaries.vertices')); -}); - -it('has array rule for components, distance, and conditions', function() { - expect('array')->toBeIn(array_get($this->rules, 'boundaries.components')) - ->and('array')->toBeIn(array_get($this->rules, 'conditions')); +it('returns correct attribute labels', function() { + $attributes = (new LocationAreaRequest())->attributes(); + + expect($attributes)->toHaveKey('type', lang('igniter.local::default.label_area_type')) + ->and($attributes)->toHaveKey('name', lang('igniter.local::default.label_area_name')) + ->and($attributes)->toHaveKey('area_id', lang('igniter.local::default.label_area_id')) + ->and($attributes)->toHaveKey('boundaries.components', lang('igniter.local::default.label_address_component')) + ->and($attributes)->toHaveKey('boundaries.components.*.type', lang('igniter.local::default.label_address_component_type')) + ->and($attributes)->toHaveKey('boundaries.components.*.value', lang('igniter.local::default.label_address_component_value')) + ->and($attributes)->toHaveKey('boundaries.polygon', lang('igniter.local::default.label_area_shape')) + ->and($attributes)->toHaveKey('boundaries.circle', lang('igniter.local::default.label_area_circle')) + ->and($attributes)->toHaveKey('boundaries.vertices', lang('igniter.local::default.label_area_vertices')) + ->and($attributes)->toHaveKey('boundaries.distance.*.type', lang('igniter.local::default.label_area_distance')) + ->and($attributes)->toHaveKey('boundaries.distance.*.distance', lang('igniter.local::default.label_area_distance')) + ->and($attributes)->toHaveKey('boundaries.distance.*.charge', lang('igniter.local::default.label_area_charge')) + ->and($attributes)->toHaveKey('conditions', lang('igniter.local::default.label_delivery_condition')) + ->and($attributes)->toHaveKey('conditions.*.amount', lang('igniter.local::default.label_area_charge')) + ->and($attributes)->toHaveKey('conditions.*.type', lang('igniter.local::default.label_charge_condition')) + ->and($attributes)->toHaveKey('conditions.*.total', lang('igniter.local::default.label_area_min_amount')); +}); + +it('returns correct validation rules', function() { + $rules = (new LocationAreaRequest())->rules(); + + expect($rules)->toHaveKey('type', ['sometimes', 'required', 'string']) + ->and($rules)->toHaveKey('name', ['sometimes', 'required', 'string']) + ->and($rules)->toHaveKey('area_id', ['integer']) + ->and($rules)->toHaveKey('boundaries.components', ['sometimes', 'required_if:type,address', 'array']) + ->and($rules)->toHaveKey('boundaries.components.*.type', ['sometimes', 'required', 'string']) + ->and($rules)->toHaveKey('boundaries.components.*.value', ['sometimes', 'required', 'string']) + ->and($rules)->toHaveKey('boundaries.polygon', ['sometimes', 'required_if:type,polygon']) + ->and($rules)->toHaveKey('boundaries.circle', ['nullable', 'required_if:type,circle', 'json']) + ->and($rules)->toHaveKey('boundaries.vertices', ['nullable', 'required_unless:type,address', 'json']) + ->and($rules)->toHaveKey('boundaries.distance.*.type', ['sometimes', 'required', 'string']) + ->and($rules)->toHaveKey('boundaries.distance.*.distance', ['sometimes', 'required', 'numeric']) + ->and($rules)->toHaveKey('boundaries.distance.*.charge', ['sometimes', 'required', 'numeric']) + ->and($rules)->toHaveKey('conditions', ['sometimes', 'array']) + ->and($rules)->toHaveKey('conditions.*.amount', ['sometimes', 'required', 'numeric']) + ->and($rules)->toHaveKey('conditions.*.type', ['sometimes', 'required', 'alpha_dash']) + ->and($rules)->toHaveKey('conditions.*.total', ['sometimes', 'required', 'numeric']); }); diff --git a/tests/Http/Requests/LocationRequestTest.php b/tests/Http/Requests/LocationRequestTest.php index 48b6039..0164270 100644 --- a/tests/Http/Requests/LocationRequestTest.php +++ b/tests/Http/Requests/LocationRequestTest.php @@ -4,68 +4,43 @@ use Igniter\Local\Http\Requests\LocationRequest; -beforeEach(function() { - $this->rules = (new LocationRequest)->rules(); -}); - -it('has required rule for location_name, location_email and ...', function() { - expect('required')->toBeIn(array_get($this->rules, 'location_name')) - ->and('required')->toBeIn(array_get($this->rules, 'location_email')) - ->and('required')->toBeIn(array_get($this->rules, 'location_address_1')) - ->and('required')->toBeIn(array_get($this->rules, 'is_auto_lat_lng')); -}); - -it('has required_if rule for location_lat and location_lng', function() { - expect('required_if:is_auto_lat_lng,0')->toBeIn(array_get($this->rules, 'location_lat')) - ->and('required_if:is_auto_lat_lng,0')->toBeIn(array_get($this->rules, 'location_lng')); -}); - -it('has string rule for location_name, location_telephone and ...', function() { - expect('string')->toBeIn(array_get($this->rules, 'location_name')) - ->and('string')->toBeIn(array_get($this->rules, 'location_telephone')) - ->and('string')->toBeIn(array_get($this->rules, 'location_address_1')) - ->and('string')->toBeIn(array_get($this->rules, 'location_address_2')) - ->and('string')->toBeIn(array_get($this->rules, 'location_city')) - ->and('string')->toBeIn(array_get($this->rules, 'location_state')) - ->and('string')->toBeIn(array_get($this->rules, 'location_postcode')); -}); - -it('has sometimes rule for inputs', function() { - expect('nullable')->toBeIn(array_get($this->rules, 'location_telephone')) - ->and('required_if:is_auto_lat_lng,0')->toBeIn(array_get($this->rules, 'location_lat')) - ->and('required_if:is_auto_lat_lng,0')->toBeIn(array_get($this->rules, 'location_lng')); -}); - -it('has max characters rule for inputs', function() { - expect('max:96')->toBeIn(array_get($this->rules, 'location_email')) - ->and('between:2,255')->toBeIn(array_get($this->rules, 'location_address_1')) - ->and('max:255')->toBeIn(array_get($this->rules, 'location_address_2')) - ->and('max:255')->toBeIn(array_get($this->rules, 'location_city')) - ->and('max:255')->toBeIn(array_get($this->rules, 'location_state')) - ->and('max:15')->toBeIn(array_get($this->rules, 'location_postcode')) - ->and('max:3028')->toBeIn(array_get($this->rules, 'description')) - ->and('max:255')->toBeIn(array_get($this->rules, 'permalink_slug')); -}); - -it('has boolean rule for inputs', function() { - expect('boolean')->toBeIn(array_get($this->rules, 'is_auto_lat_lng')) - ->and('boolean')->toBeIn(array_get($this->rules, 'location_status')) - ->and('boolean')->toBeIn(array_get($this->rules, 'is_default')); -}); - -it('has alpha_dash rule for permalink_slug', function() { - expect('alpha_dash')->toBeIn(array_get($this->rules, 'permalink_slug')); -}); - -it('has email rule for location_email', function() { - expect('email:filter')->toBeIn(array_get($this->rules, 'location_email')); -}); - -it('has numeric rule for location_lat and location_lng', function() { - expect('numeric')->toBeIn(array_get($this->rules, 'location_lat')) - ->and('numeric')->toBeIn(array_get($this->rules, 'location_lng')); -}); - -it('has between rule for location_name', function() { - expect('between:2,32')->toBeIn(array_get($this->rules, 'location_name')); +it('returns correct attribute labels', function() { + $attributes = (new LocationRequest())->attributes(); + + expect($attributes)->toHaveKey('location_name', lang('igniter::admin.label_name')) + ->and($attributes)->toHaveKey('location_email', lang('igniter::admin.label_email')) + ->and($attributes)->toHaveKey('location_telephone', lang('igniter.local::default.label_telephone')) + ->and($attributes)->toHaveKey('location_address_1', lang('igniter.local::default.label_address_1')) + ->and($attributes)->toHaveKey('location_address_2', lang('igniter.local::default.label_address_2')) + ->and($attributes)->toHaveKey('location_city', lang('igniter.local::default.label_city')) + ->and($attributes)->toHaveKey('location_state', lang('igniter.local::default.label_state')) + ->and($attributes)->toHaveKey('location_postcode', lang('igniter.local::default.label_postcode')) + ->and($attributes)->toHaveKey('options.auto_lat_lng', lang('igniter.local::default.label_auto_lat_lng')) + ->and($attributes)->toHaveKey('location_lat', lang('igniter.local::default.label_latitude')) + ->and($attributes)->toHaveKey('location_lng', lang('igniter.local::default.label_longitude')) + ->and($attributes)->toHaveKey('description', lang('igniter::admin.label_description')) + ->and($attributes)->toHaveKey('location_status', lang('igniter::admin.label_status')) + ->and($attributes)->toHaveKey('permalink_slug', lang('igniter.local::default.label_permalink_slug')) + ->and($attributes)->toHaveKey('gallery.title', lang('igniter.local::default.label_gallery_title')) + ->and($attributes)->toHaveKey('gallery.description', lang('igniter::admin.label_description')); +}); + +it('returns correct validation rules', function() { + $rules = (new LocationRequest())->rules(); + + expect($rules)->toHaveKey('location_name', ['required', 'string', 'between:2,32']) + ->and($rules)->toHaveKey('permalink_slug', ['nullable', 'alpha_dash', 'max:255']) + ->and($rules)->toHaveKey('location_email', ['required', 'email:filter', 'max:96']) + ->and($rules)->toHaveKey('location_telephone', ['nullable', 'string']) + ->and($rules)->toHaveKey('location_address_1', ['required', 'string', 'between:2,255']) + ->and($rules)->toHaveKey('location_address_2', ['nullable', 'string', 'max:255']) + ->and($rules)->toHaveKey('location_city', ['nullable', 'string', 'max:255']) + ->and($rules)->toHaveKey('location_state', ['nullable', 'string', 'max:255']) + ->and($rules)->toHaveKey('location_postcode', ['nullable', 'string', 'max:15']) + ->and($rules)->toHaveKey('is_auto_lat_lng', ['required', 'boolean']) + ->and($rules)->toHaveKey('location_lat', ['required_if:is_auto_lat_lng,0', 'numeric']) + ->and($rules)->toHaveKey('location_lng', ['required_if:is_auto_lat_lng,0', 'numeric']) + ->and($rules)->toHaveKey('description', ['max:3028']) + ->and($rules)->toHaveKey('location_status', ['boolean']) + ->and($rules)->toHaveKey('is_default', ['boolean']); }); diff --git a/tests/Http/Requests/ReviewRequestTest.php b/tests/Http/Requests/ReviewRequestTest.php index 3d62f17..0406f38 100644 --- a/tests/Http/Requests/ReviewRequestTest.php +++ b/tests/Http/Requests/ReviewRequestTest.php @@ -4,43 +4,33 @@ use Igniter\Local\Http\Requests\ReviewRequest; -beforeEach(function() { - $request = (new ReviewRequest)->merge([ - 'reviewable_type' => 'type', - 'reviewable_id' => 1, - ]); - $this->rules = ($request)->rules(); +it('returns correct attribute labels', function() { + $attributes = (new ReviewRequest())->attributes(); + + expect($attributes)->toHaveKey('reviewable_type', lang('igniter.local::default.reviews.label_reviewable_type')) + ->and($attributes)->toHaveKey('reviewable_id', lang('igniter.local::default.reviews.label_reviewable_id')) + ->and($attributes)->toHaveKey('location_id', lang('igniter.local::default.reviews.label_location')) + ->and($attributes)->toHaveKey('customer_id', lang('igniter.local::default.reviews.label_customer')) + ->and($attributes)->toHaveKey('quality', lang('igniter.local::default.reviews.label_quality')) + ->and($attributes)->toHaveKey('delivery', lang('igniter.local::default.reviews.label_delivery')) + ->and($attributes)->toHaveKey('service', lang('igniter.local::default.reviews.label_service')) + ->and($attributes)->toHaveKey('review_text', lang('igniter.local::default.reviews.label_text')) + ->and($attributes)->toHaveKey('review_status', lang('admin::lang.label_status')); }); -it('has required rule for inputs', function() { - expect('required')->toBeIn(array_get($this->rules, 'location_id')) - ->and('required')->toBeIn(array_get($this->rules, 'customer_id')) - ->and('required')->toBeIn(array_get($this->rules, 'reviewable_type')) - ->and('required')->toBeIn(array_get($this->rules, 'reviewable_id')) - ->and('required')->toBeIn(array_get($this->rules, 'quality')) - ->and('required')->toBeIn(array_get($this->rules, 'delivery')) - ->and('required')->toBeIn(array_get($this->rules, 'service')) - ->and('required')->toBeIn(array_get($this->rules, 'review_text')) - ->and('required')->toBeIn(array_get($this->rules, 'review_status')); -}); - -it('has integer rule for quality, delivery, and service, location_id and customer_id', function() { - expect('integer')->toBeIn(array_get($this->rules, 'location_id')) - ->and('integer')->toBeIn(array_get($this->rules, 'customer_id')) - ->and('integer')->toBeIn(array_get($this->rules, 'quality')) - ->and('integer')->toBeIn(array_get($this->rules, 'delivery')) - ->and('integer')->toBeIn(array_get($this->rules, 'service')); -}); - -it('has between rule for review_text', function() { - expect('between:2,1028')->toBeIn(array_get($this->rules, 'review_text')); -}); - -it('has boolean rule for review_status', function() { - expect('boolean')->toBeIn(array_get($this->rules, 'review_status')); -}); - -it('has exists rule for reviewable_id', function() { - expect('exists:type,type_id')->toBeIn(array_get($this->rules, 'reviewable_id')); +it('returns correct validation rules', function() { + $reviewRequest = new ReviewRequest(); + $reviewRequest->merge(['reviewable_type' => 'locations']); + $rules = $reviewRequest->rules(); + + expect($rules)->toHaveKey('reviewable_type', ['required']) + ->and($rules)->toHaveKey('reviewable_id', ['required', 'integer', 'exists:locations,location_id']) + ->and($rules)->toHaveKey('location_id', ['required', 'integer']) + ->and($rules)->toHaveKey('customer_id', ['required', 'integer']) + ->and($rules)->toHaveKey('quality', ['required', 'integer', 'min:1', 'max:5']) + ->and($rules)->toHaveKey('delivery', ['required', 'integer', 'min:1', 'max:5']) + ->and($rules)->toHaveKey('service', ['required', 'integer', 'min:1', 'max:5']) + ->and($rules)->toHaveKey('review_text', ['required', 'between:2,1028']) + ->and($rules)->toHaveKey('review_status', ['required', 'boolean']); }); diff --git a/tests/Http/Requests/WorkingHourRequestTest.php b/tests/Http/Requests/WorkingHourRequestTest.php index 6a22fbf..e30c725 100644 --- a/tests/Http/Requests/WorkingHourRequestTest.php +++ b/tests/Http/Requests/WorkingHourRequestTest.php @@ -4,54 +4,29 @@ use Igniter\Local\Http\Requests\WorkingHourRequest; -beforeEach(function() { - $this->rules = (new WorkingHourRequest)->rules(); -}); - -it('has required_if rule for flexible.*.status', function() { - expect('required_if:type,daily')->toBeIn(array_get($this->rules, 'days.*')) - ->and('required_if:type,daily')->toBeIn(array_get($this->rules, 'open')) - ->and('required_if:type,daily')->toBeIn(array_get($this->rules, 'close')) - ->and('required_if:type,timesheet')->toBeIn(array_get($this->rules, 'timesheet')) - ->and('required_if:type,flexible')->toBeIn(array_get($this->rules, 'flexible')) - ->and('required_if:type,flexible')->toBeIn(array_get($this->rules, 'flexible.*.day')) - ->and('required_if:type,flexible')->toBeIn(array_get($this->rules, 'flexible.*.hours')) - ->and('required_if:type,flexible')->toBeIn(array_get($this->rules, 'flexible.*.status')); -}); - -it('has sometimes rule for inputs', function() { - expect('sometimes')->toBeIn(array_get($this->rules, 'flexible.*.status')); -}); - -it('has alpha_dash rule for type', function() { - expect('alpha_dash')->toBeIn(array_get($this->rules, 'type')); -}); - -it('has in rule for type', function() { - expect('in:24_7,daily,timesheet,flexible')->toBeIn(array_get($this->rules, 'type')); -}); - -it('has integer rule for days', function() { - expect('integer')->toBeIn(array_get($this->rules, 'days.*')); -}); - -it('has between rule for days', function() { - expect('between:0,7')->toBeIn(array_get($this->rules, 'days.*')); -}); - -it('has date_format rule for open and close', function() { - expect('date_format:H:i')->toBeIn(array_get($this->rules, 'open')) - ->and('date_format:H:i')->toBeIn(array_get($this->rules, 'close')); -}); - -it('has string rule for timesheet', function() { - expect('string')->toBeIn(array_get($this->rules, 'timesheet')); -}); - -it('has array rule for flexible', function() { - expect('array')->toBeIn(array_get($this->rules, 'flexible')); -}); - -it('has numeric rule for flexible.*.day', function() { - expect('numeric')->toBeIn(array_get($this->rules, 'flexible.*.day')); +it('returns correct attribute labels', function() { + $attributes = (new WorkingHourRequest())->attributes(); + + expect($attributes)->toHaveKey('type', lang('igniter.local::default.label_schedule_type')) + ->and($attributes)->toHaveKey('days.*', lang('igniter.local::default.label_schedule_days')) + ->and($attributes)->toHaveKey('open', lang('igniter.local::default.label_schedule_open')) + ->and($attributes)->toHaveKey('close', lang('igniter.local::default.label_schedule_close')) + ->and($attributes)->toHaveKey('timesheet', lang('igniter.local::default.text_timesheet')) + ->and($attributes)->toHaveKey('flexible.*.day', lang('igniter.local::default.label_schedule_days')) + ->and($attributes)->toHaveKey('flexible.*.hours', lang('igniter.local::default.label_schedule_hours')) + ->and($attributes)->toHaveKey('flexible.*.status', lang('igniter::admin.label_status')); +}); + +it('returns correct validation rules', function() { + $rules = (new WorkingHourRequest())->rules(); + + expect($rules)->toHaveKey('type', ['alpha_dash', 'in:24_7,daily,timesheet,flexible']) + ->and($rules)->toHaveKey('days.*', ['required_if:type,daily', 'integer', 'between:0,7']) + ->and($rules)->toHaveKey('open', ['required_if:type,daily', 'date_format:H:i']) + ->and($rules)->toHaveKey('close', ['required_if:type,daily', 'date_format:H:i']) + ->and($rules)->toHaveKey('timesheet', ['required_if:type,timesheet', 'string']) + ->and($rules)->toHaveKey('flexible', ['required_if:type,flexible', 'array']) + ->and($rules)->toHaveKey('flexible.*.day', ['required_if:type,flexible', 'numeric']) + ->and($rules)->toHaveKey('flexible.*.hours', ['required_if:type,flexible']) + ->and($rules)->toHaveKey('flexible.*.status', ['sometimes', 'required_if:type,flexible', 'boolean']); }); diff --git a/tests/Listeners/MaxOrderPerTimeslotReachedTest.php b/tests/Listeners/MaxOrderPerTimeslotReachedTest.php new file mode 100644 index 0000000..aed4d4d --- /dev/null +++ b/tests/Listeners/MaxOrderPerTimeslotReachedTest.php @@ -0,0 +1,105 @@ +setType(Location::OPENING); + $listener = new MaxOrderPerTimeslotReached(); + $timeslot = '2023-01-01 12:00:00'; + + $result = $listener->timeslotValid($workingSchedule, $timeslot); + + expect($result)->toBeNull(); +}); + +it('returns true when limit orders is disabled', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders')->andReturnFalse(); + LocationFacade::shouldReceive('current')->andReturn($location); + + $workingSchedule = new WorkingSchedule('UTC', [1, 1]); + $workingSchedule->setType(Location::DELIVERY); + $listener = new MaxOrderPerTimeslotReached(); + $timeslot = '2023-01-01 12:00:00'; + + $result = $listener->timeslotValid($workingSchedule, $timeslot); + + expect($result)->toBeNull(); +}); + +it('returns false when timeslot exceeds max orders for delivery', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders')->andReturnTrue(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders_count', 50)->andReturn(1); + $location->shouldReceive('getOrderTimeInterval')->with(Location::DELIVERY)->andReturn(15); + LocationFacade::shouldReceive('getId')->andReturn(1); + LocationFacade::shouldReceive('current')->andReturn($location); + Order::factory()->count(3)->create([ + 'location_id' => 1, + 'status_id' => setting('default_order_status'), + 'order_date' => '2023-01-01', + 'order_time' => '12:00:00', + 'order_type' => Location::DELIVERY, + ]); + $timeslot = '2023-01-01 12:00:00'; + + $workingSchedule = new WorkingSchedule('UTC', [1, 1]); + $workingSchedule->setType(Location::DELIVERY); + $listener = new MaxOrderPerTimeslotReached(); + + $result = $listener->timeslotValid($workingSchedule, $timeslot); + + expect($result)->toBeFalse(); +}); + +it('throws exception when order exceeds max orders for pickup', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders')->andReturnTrue(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders_count', 50)->andReturn(1); + $location->shouldReceive('getOrderTimeInterval')->with(Location::DELIVERY)->andReturn(15); + LocationFacade::shouldReceive('getId')->andReturn(1); + LocationFacade::shouldReceive('current')->andReturn($location); + $orders = Order::factory()->count(3)->create([ + 'location_id' => 1, + 'status_id' => setting('default_order_status'), + 'order_date' => '2023-01-01', + 'order_time' => '12:00:00', + 'order_type' => Location::DELIVERY, + ]); + + $listener = new MaxOrderPerTimeslotReached(); + + expect(fn() => $listener->beforeSaveOrder($orders->last(), []))->toThrow(ApplicationException::class); +}); + +it('returns true when timeslot does not exceed max orders', function() { + $location = mock(Location::class)->makePartial(); + $location->shouldReceive('getSettings')->with('checkout.limit_orders')->andReturnTrue(); + $location->shouldReceive('getOrderTimeInterval')->with(Location::DELIVERY)->andReturn(15); + LocationFacade::shouldReceive('getId')->andReturn(1); + LocationFacade::shouldReceive('current')->andReturn($location); + + $workingSchedule = new WorkingSchedule('UTC', [1, 1]); + $workingSchedule->setType(Location::DELIVERY); + $listener = new MaxOrderPerTimeslotReached(); + $timeslot = '2023-01-01 12:00:00'; + + $result = $listener->timeslotValid($workingSchedule, $timeslot); + + expect($result)->toBeNull(); + + // test coverage for cached timeslots + $listener->timeslotValid($workingSchedule, $timeslot); +}); diff --git a/tests/MainMenuWidgets/LocationPickerTest.php b/tests/MainMenuWidgets/LocationPickerTest.php index 71a8600..1a81929 100644 --- a/tests/MainMenuWidgets/LocationPickerTest.php +++ b/tests/MainMenuWidgets/LocationPickerTest.php @@ -3,6 +3,8 @@ namespace Igniter\Local\Tests\MainMenuWidgets; use Igniter\Admin\Classes\MainMenuItem; +use Igniter\Flame\Geolite\Facades\Geocoder; +use Igniter\Local\Facades\Location as LocationFacade; use Igniter\Local\Http\Controllers\Locations; use Igniter\Local\MainMenuWidgets\LocationPicker; use Igniter\Local\Models\Location; @@ -13,9 +15,8 @@ beforeEach(function() { $this->location = Location::factory()->create(); $menuItem = (new MainMenuItem('test_field', 'Location picker'))->displayAs('locationpicker'); - $this->locationPickerWidget = new LocationPicker($controller = resolve(Locations::class), $menuItem); - $controller->setUser($user = User::factory()->superUser()->create()); - AdminAuth::shouldReceive('user')->andReturn($user); + $this->controller = resolve(Locations::class); + $this->locationPickerWidget = new LocationPicker($this->controller, $menuItem); }); it('initializes correctly', function() { @@ -26,6 +27,10 @@ }); it('prepares variables correctly', function() { + $user = User::factory()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); + $this->locationPickerWidget->prepareVars(); expect($this->locationPickerWidget->vars)->toBeArray() @@ -35,25 +40,76 @@ ->toHaveKey('isSingleMode'); }); -it('loads form correctly', function() { +it('loads form with new record correctly', function() { + $user = User::factory()->create(); + AdminAuth::shouldReceive('user')->andReturn($user); + + expect($this->locationPickerWidget->onLoadForm())->toBeString(); +}); + +it('loads form with existing correctly', function() { + $this->actingAs(User::factory()->superUser()->create(), 'igniter-admin'); + $location = Location::factory()->create(); + request()->request->set('location', $location->getKey()); + expect($this->locationPickerWidget->onLoadForm())->toBeString(); }); it('chooses location correctly', function() { - $this->actingAs($this->locationPickerWidget->getController()->getUser(), 'igniter-admin'); + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); request()->merge(['location' => $this->location->getKey()]); expect($this->locationPickerWidget->onChoose())->toBeInstanceOf(RedirectResponse::class); }); -it('saves record correctly', function() { - $this->actingAs($this->locationPickerWidget->getController()->getUser(), 'igniter-admin'); +it('chooses location and resets session', function() { + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); + request()->merge(['location' => $this->location->getKey()]); + LocationFacade::shouldReceive('current')->andReturn($this->location); + LocationFacade::shouldReceive('resetSession')->once(); + + expect($this->locationPickerWidget->onChoose())->toBeInstanceOf(RedirectResponse::class); +}); + +it('saves new record correctly', function() { + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); + + expect($this->locationPickerWidget->onSaveRecord())->toBeArray(); +}); + +it('saves existing record correctly', function() { + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); + $location = Location::factory()->create(); + request()->request->set('recordId', $location->getKey()); expect($this->locationPickerWidget->onSaveRecord())->toBeArray(); }); +it('flashes error when geocoder fails', function() { + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); + $location = Location::factory()->create(); + request()->request->set('recordId', $location->getKey()); + Geocoder::shouldReceive('getLogs')->andReturn(['Failed to geocode']); + + $result = $this->locationPickerWidget->onSaveRecord(); + + expect($result['#notification'])->toContain('Failed to geocode'); +}); + it('deletes record correctly', function() { - $this->actingAs($this->locationPickerWidget->getController()->getUser(), 'igniter-admin'); + $user = User::factory()->superUser()->create(); + $this->controller->setUser($user); + $this->actingAs($user, 'igniter-admin'); request()->merge(['recordId' => $this->location->getKey()]); expect($this->locationPickerWidget->onDeleteRecord())->toBeArray(); diff --git a/tests/Models/Actions/ReviewActionTest.php b/tests/Models/Actions/ReviewActionTest.php new file mode 100644 index 0000000..71f952d --- /dev/null +++ b/tests/Models/Actions/ReviewActionTest.php @@ -0,0 +1,16 @@ +create(['processed' => 1]); + $model->updateOrderStatus(setting('completed_order_status')[0]); + $reviewAttributes = ['rating' => 5, 'review_text' => 'Great service!']; + + $result = (new ReviewAction($model))->leaveReview($reviewAttributes); + + expect($result->review_text)->toBe($reviewAttributes['review_text']); +}); diff --git a/tests/Models/Concerns/HasDeliveryAreasTest.php b/tests/Models/Concerns/HasDeliveryAreasTest.php new file mode 100644 index 0000000..2ef2a81 --- /dev/null +++ b/tests/Models/Concerns/HasDeliveryAreasTest.php @@ -0,0 +1,127 @@ +create(); + + $lat = 37.7749295; + $lng = -122.4194155; + Geocoder::shouldReceive('geocode')->andReturn(collect([ + GeoliteLocation::createFromArray([ + 'latitude' => $lat, + 'longitude' => $lng, + ]), + ])); + + $location->is_auto_lat_lng = true; + $location->location_lat = null; + $location->location_lng = null; + $location->save(); + + expect($location->location_lat)->toBe($lat) + ->and($location->location_lng)->toBe($lng); +}); + +it('does not geocode address if coordinates already exists and not dirty', function() { + $location = Location::factory()->createQuietly([ + 'location_lat' => 37.7749295, + 'location_lng' => -122.4194155, + 'location_country_id' => 123, + 'is_auto_lat_lng' => true, + ]); + + $location->save(); + + expect($location->location_lat)->toBe(37.7749295) + ->and($location->location_lng)->toBe(-122.4194155); +}); + +it('searchOrDefaultDeliveryArea returns the matching delivery area if found', function() { + $location = Location::factory()->create(); + $area1 = LocationArea::factory()->create([ + 'type' => 'address', + 'conditions' => ['min_total' => 10], + 'boundaries' => [], + 'is_default' => true, + ]); + $area2 = LocationArea::factory()->create([ + 'type' => 'polygon', + 'conditions' => ['min_total' => 20], + 'boundaries' => ['vertices' => '[{"lat":51.525998393642936,"lng":-0.13086516710191232},{"lat":51.506999160557775,"lng":-0.13052184434800607},{"lat":51.50651835413632,"lng":-0.17409930227410442},{"lat":51.526225344669776,"lng":-0.17351994512688762}]'], + ]); + $location->delivery_areas()->saveMany([$area1, $area2]); + $coordinates = GeoliteLocation::createFromArray([ + 'latitude' => 51.50987615, + 'longitude' => -0.1446716, + ])->getCoordinates(); + + $result = $location->searchOrDefaultDeliveryArea($coordinates); + + expect($result->getKey())->toBe($area2->getKey()); +}); + +it('searchOrFirstDeliveryArea returns the matching delivery area if found', function() { + $location = Location::factory()->create(); + $area1 = LocationArea::factory()->create([ + 'type' => 'address', + 'conditions' => ['min_total' => 10], + 'boundaries' => [], + 'is_default' => true, + ]); + $area2 = LocationArea::factory()->create([ + 'type' => 'polygon', + 'conditions' => ['min_total' => 20], + 'boundaries' => ['vertices' => '[{"lat":51.525998393642936,"lng":-0.13086516710191232},{"lat":51.506999160557775,"lng":-0.13052184434800607},{"lat":51.50651835413632,"lng":-0.17409930227410442},{"lat":51.526225344669776,"lng":-0.17351994512688762}]'], + ]); + $location->delivery_areas()->saveMany([$area1, $area2]); + $coordinates = GeoliteLocation::createFromArray([ + 'latitude' => 51.50987615, + 'longitude' => -0.1446716, + ])->getCoordinates(); + + $result = $location->searchOrFirstDeliveryArea($coordinates); + + expect($result->getKey())->toBe($area2->getKey()); +}); + +it('searchOrFirstDeliveryArea returns the first delivery area if matching not found', function() { + $location = Location::factory()->create(); + $area1 = LocationArea::factory()->create([ + 'type' => 'address', + 'conditions' => ['min_total' => 10], + 'boundaries' => [], + ]); + $area2 = LocationArea::factory()->create([ + 'type' => 'polygon', + 'conditions' => ['min_total' => 20], + 'boundaries' => ['vertices' => '[{"lat":51.525998393642936,"lng":-0.13086516710191232},{"lat":51.506999160557775,"lng":-0.13052184434800607},{"lat":51.50651835413632,"lng":-0.17409930227410442},{"lat":51.526225344669776,"lng":-0.17351994512688762}]'], + ]); + $location->delivery_areas()->saveMany([$area1, $area2]); + $coordinates = GeoliteLocation::createFromArray([ + 'latitude' => 51.5086367, + 'longitude' => -0.2200662, + ])->getCoordinates(); + + $result = $location->searchOrFirstDeliveryArea($coordinates); + + expect($result->getKey())->toBe($area1->getKey()); +}); + +it('adds delivery areas on save', function() { + $location = Location::factory()->create(); + + $location->delivery_areas = [ + ['area_id' => 1, 'conditions' => ['min_total' => 10]], + ['area_id' => 2, 'conditions' => ['min_total' => 20]], + ]; + + $location->save(); + + expect($location->delivery_areas()->count())->toBe(2); +}); diff --git a/tests/Models/Concerns/HasWorkingHoursTest.php b/tests/Models/Concerns/HasWorkingHoursTest.php new file mode 100644 index 0000000..4fe4c5f --- /dev/null +++ b/tests/Models/Concerns/HasWorkingHoursTest.php @@ -0,0 +1,164 @@ +create(); + $location->settings()->create([ + 'item' => 'hours', + 'data' => [ + 'opening' => ['type' => '24_7'], + 'delivery' => ['type' => 'daily'], + 'collection' => ['type' => 'flexible'], + ], + ]); + + expect($location->workingHourType('opening'))->toBe('24_7') + ->and($location->workingHourType('delivery'))->toBe('daily') + ->and($location->workingHourType('collection'))->toBe('flexible'); +}); + +it('returns working hours by day', function() { + $location = Location::factory()->create(); + $workingHour1 = $location->working_hours()->create([ + 'weekday' => 1, + 'type' => 'opening', + ]); + $workingHour2 = $location->working_hours()->create([ + 'weekday' => 1, + 'type' => 'delivery', + ]); + $workingHour3 = $location->working_hours()->create([ + 'weekday' => 2, + 'type' => 'opening', + ]); + + $result = $location->getWorkingHoursByDay(1); + + expect($result)->toHaveCount(2) + ->and($result->pluck('id')->all())->toContain($workingHour1->id, $workingHour2->id); +}); + +it('returns the correct working hour by day and type', function() { + $location = Location::factory()->create(); + $workingHour = $location->working_hours()->create([ + 'weekday' => 1, + 'type' => 'opening', + ]); + + $result = $location->getWorkingHourByDayAndType(1, 'opening'); + + expect($result->getKey())->toBe($workingHour->getKey()); +}); + +it('returns the correct working hour by date and type', function() { + $location = Location::factory()->create(); + $workingHour = $location->working_hours()->create([ + 'weekday' => 1, + 'type' => 'opening', + ]); + $date = Carbon::createFromDate(2023, 1, 2)->toDateString(); // Monday + + $result = $location->getWorkingHourByDateAndType($date, 'opening'); + + expect($result->id)->toBe($workingHour->id); +}); + +it('throws exception if working_hours relation does not exist', function() { + $location = Location::factory()->make(); + unset($location->relation['hasMany']['working_hours']); + + expect(fn() => $location->getWorkingHours())->toThrow(RelationNotFoundException::class); +}); + +it('creates default working hours if none exist', function() { + $location = Location::factory()->create(); + + $result = $location->getWorkingHours(); + + expect($result)->not->toBeEmpty(); +}); + +it('creates a new working schedule with valid type and days', function() { + $location = Location::factory()->create(); + $type = 'opening'; + $days = [2, 7]; + + $schedule = $location->newWorkingSchedule($type, $days); + + expect($schedule->getType())->toBe($type) + ->and($schedule->minDays())->toBe(2) + ->and($schedule->days())->toBe(7); +}); + +it('throws exception when creating schedule with invalid type', function() { + $location = Location::factory()->create(); + $invalidType = 'invalid'; + + expect(fn() => $location->newWorkingSchedule($invalidType))->toThrow(WorkingHourException::class); +}); + +it('creates schedule item with valid type and data', function() { + $location = Location::factory()->create(); + $type = 'opening'; + $scheduleData = ['type' => 'daily', 'open' => '09:00', 'close' => '17:00']; + + $scheduleItem = $location->createScheduleItem($type, $scheduleData); + + expect($scheduleItem->name)->toBe($type) + ->and($scheduleItem->type)->toBe('daily'); + + array_map(function($data) { + expect($data[0]['open'])->toBe('09:00') + ->and($data[0]['close'])->toBe('17:00'); + return $data; + }, $scheduleItem->getHours()); +}); + +it('throws exception when creating schedule item with invalid type', function() { + $location = Location::factory()->create(); + $invalidType = 'invalid'; + + expect(fn() => $location->createScheduleItem($invalidType))->toThrow(InvalidArgumentException::class); +}); + +it('adds opening hours for all types when type is null', function() { + $location = Location::factory()->create(); + $data = [ + 'opening' => ['type' => 'daily', 'days' => [1], 'open' => '09:00', 'close' => '17:00', 'status' => 1], + 'delivery' => ['type' => 'daily', 'days' => [2], 'open' => '10:00', 'close' => '18:00', 'status' => 1], + 'collection' => ['type' => 'daily', 'days' => [3], 'open' => '11:00', 'close' => '19:00', 'status' => 1], + ]; + + $result = $location->addOpeningHours($data); + + expect($result)->toBeTrue() + ->and($location->working_hours()->whereIsEnabled()->count())->toBe(3); +}); + +it('adds opening hours for a specific type', function() { + $location = Location::factory()->create(); + $data = ['type' => 'daily', 'days' => [1], 'open' => '09:00', 'close' => '17:00', 'status' => 1]; + + $result = $location->addOpeningHours('opening', $data); + + expect($result)->toBeTrue() + ->and($location->working_hours()->whereIsEnabled()->where('type', 'opening')->count())->toBe(1); +}); + +it('does not add opening hours if schedule data is not an array', function() { + $location = Location::factory()->create(); + $data = ['opening' => 'invalid data']; + + $result = $location->addOpeningHours($data); + + expect($result)->toBeTrue() + ->and($location->working_hours()->count())->toBe(0); +}); + diff --git a/tests/Models/Concerns/LocationHelpersTest.php b/tests/Models/Concerns/LocationHelpersTest.php new file mode 100644 index 0000000..dc0d009 --- /dev/null +++ b/tests/Models/Concerns/LocationHelpersTest.php @@ -0,0 +1,155 @@ +create(['location_name' => 'Test Location']); + + $result = $location->getName(); + + expect($result)->toBe('Test Location'); +}); + +it('returns email in lowercase', function() { + $location = Location::factory()->create(['location_email' => 'TEST@EXAMPLE.COM']); + + $result = $location->getEmail(); + + expect($result)->toBe('test@example.com'); +}); + +it('returns correct telephone number', function() { + $location = Location::factory()->create(['location_telephone' => '123456789']); + + $result = $location->getTelephone(); + + expect($result)->toBe('123456789'); +}); + +it('returns correct description', function() { + $location = Location::factory()->create(['description' => 'Test Description']); + + $result = $location->getDescription(); + + expect($result)->toBe('Test Description'); +}); + +it('returns correct address', function() { + $country = Country::factory()->create([ + 'country_name' => 'Test Country', + 'iso_code_2' => 'TC', + 'iso_code_3' => 'TST', + 'format' => 'Test Format', + ]); + $location = Location::factory()->create([ + 'location_address_1' => 'Address 1', + 'location_address_2' => 'Address 2', + 'location_city' => 'City', + 'location_state' => 'State', + 'location_postcode' => '12345', + 'location_lat' => 12.345678, + 'location_lng' => 98.765432, + 'location_country_id' => $country->getKey(), + ]); + + $result = $location->getAddress(); + + expect($result)->toBe([ + 'address_1' => 'Address 1', + 'address_2' => 'Address 2', + 'city' => 'City', + 'state' => 'State', + 'postcode' => '12345', + 'location_lat' => 12.345678, + 'location_lng' => 98.765432, + 'country_id' => $country->getKey(), + 'country' => 'Test Country', + 'iso_code_2' => 'TC', + 'iso_code_3' => 'TST', + 'format' => 'Test Format', + ]); +}); + +it('calculates correct distance', function() { + $location = Location::factory()->create(['location_lat' => '12.345678', 'location_lng' => '98.765432']); + $position = mock(CoordinatesInterface::class); + $position->shouldReceive('getLatitude')->andReturn('12.345678'); + $position->shouldReceive('getLongitude')->andReturn('98.765432'); + + $result = $location->calculateDistance($position); + + expect($result->getDistance())->toBe(0.0); +}); + +it('returns correct coordinates', function() { + $location = Location::factory()->create([ + 'location_lat' => '12.345678', + 'location_lng' => '98.765432', + ]); + + $result = $location->getCoordinates(); + + expect($result->getLatitude())->toBe(12.345678) + ->and($result->getLongitude())->toBe(98.765432); +}); + +it('sets correct URL with suffix', function() { + $location = Location::factory()->create(['permalink_slug' => 'test-location']); + + $location->setUrl('/test-suffix'); + + expect($location->url)->toBe(page_url('test-location/test-suffix')); +}); + +it('sets correct URL without suffix for single location', function() { + $location = Location::factory()->create(['permalink_slug' => 'test-location']); + config(['igniter-system.locationMode' => 'single']); + + $location->setUrl(); + + expect($location->url)->toBe(page_url('test-location/menus')); +}); + +it('checks if location has gallery', function() { + $location = Location::factory()->create(); + $media = new Media(); + $media->setRelation('attachment', $location); + $media->addFromRaw('raw-content', 'media-file.jpg', 'gallery'); + + $result = $location->hasGallery(); + + expect($result)->toBeTrue(); +}); + +it('returns correct gallery', function() { + $location = Location::factory()->create(); + $media = Media::create(); + $media->setRelation('attachment', $location); + $media->addFromRaw('raw-content', 'media-file.jpg', 'gallery'); + + $result = $location->getGallery(); + + expect($result->first()->id)->toBe($media->id); +}); + +it('returns correct settings', function() { + $location = Location::factory()->create(); + $location->settings()->create(['item' => 'test_item', 'data' => 'test_data']); + + $result = $location->getSettings('test_item'); + + expect($result)->toBe('test_data'); +}); + +it('finds or creates settings', function() { + $location = Location::factory()->create(); + + $result = $location->findSettings('test_item'); + + expect($result->item)->toBe('test_item'); +}); diff --git a/tests/Models/Concerns/LocationableTest.php b/tests/Models/Concerns/LocationableTest.php new file mode 100644 index 0000000..ddac657 --- /dev/null +++ b/tests/Models/Concerns/LocationableTest.php @@ -0,0 +1,64 @@ +create(); + $menu->locations()->attach(Location::factory()->create()); + + $menu->delete(); + + expect($menu->locations()->count())->toBe(0); +}); + +it('throws exception when detaching locations as non-superuser', function() { + $menu = Menu::factory()->create(); + $menu->locations()->attach(Location::factory()->create()); + AdminAuth::shouldReceive('isSuperUser')->andReturnFalse(); + $request = mock(Request::class); + $request->shouldReceive('setUserResolver')->andReturnNull(); + $request->shouldReceive('getScheme')->andReturn('https'); + $request->shouldReceive('root')->andReturn('localhost'); + $request->shouldReceive('route')->andReturnNull(); + $request->shouldReceive('path')->andReturn('admin/menus/edit/1'); + app()->instance('request', $request); + + $this->expectException(SystemException::class); + $this->expectExceptionMessage(lang('igniter::admin.alert_warning_locationable_delete')); + + $menu->delete(); +}); + +it('checks if locationable relation is single type', function() { + $order = Order::factory()->create(); + + expect($order->locationableIsSingleRelationType())->toBeTrue(); +}); + +it('checks if locationable relation is morph type', function() { + $menu = Menu::factory()->create(); + + expect($menu->locationableIsMorphRelationType())->toBeTrue(); +}); + +it('checks if locationable relation exists for single relation type', function() { + $order = Order::factory()->create(); + $order->location()->associate(Location::factory()->create()); + + expect($order->locationableRelationExists())->toBeTrue(); +}); + +it('checks if locationable relation exists for morph relation type', function() { + $menu = Menu::factory()->create(); + $menu->locations()->attach(Location::factory()->create()); + + expect($menu->locationableRelationExists())->toBeTrue(); +}); + diff --git a/tests/Models/LocationAreaTest.php b/tests/Models/LocationAreaTest.php new file mode 100644 index 0000000..504d8da --- /dev/null +++ b/tests/Models/LocationAreaTest.php @@ -0,0 +1,260 @@ +create([ + 'conditions' => [ + ['type' => 'above', 'amount' => 10.0, 'total' => 100.0], + ], + ]); + + $result = $locationArea->conditions; + + expect($result)->toBeArray() + ->and($result[0]['type'])->toBe('above') + ->and($result[0]['amount'])->toBe(10) + ->and($result[0]['total'])->toBe(100); +}); + +it('returns correct vertices attribute', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => ['vertices' => json_encode([['lat' => 12.345678, 'lng' => 98.765432]])], + ]); + + $result = $locationArea->vertices; + + expect($result)->toBeArray() + ->and($result[0]->lat)->toBe(12.345678) + ->and($result[0]->lng)->toBe(98.765432); +}); + +it('returns empty vertices attribute when boundaries are not set', function() { + $locationArea = LocationArea::factory()->create(['boundaries' => []]); + + $result = $locationArea->vertices; + + expect($result)->toBeArray() + ->and($result)->toBeEmpty(); +}); + +it('returns correct circle attribute', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => [ + 'circle' => json_encode(['lat' => 12.345678, 'lng' => 98.765432, 'radius' => 1000]), + ], + ]); + + $result = $locationArea->circle; + + expect($result)->toBeObject() + ->and($result->lat)->toBe(12.345678) + ->and($result->lng)->toBe(98.765432) + ->and($result->radius)->toBe(1000); +}); + +it('returns null circle attribute when boundaries are not set', function() { + $locationArea = LocationArea::factory()->create(['boundaries' => []]); + + $result = $locationArea->circle; + + expect($result)->toBeNull(); +}); + +it('returns correct color attribute when value is set', function() { + $locationArea = LocationArea::factory()->create(['color' => '#FFFFFF']); + + $result = $locationArea->color; + + expect($result)->toBe('#FFFFFF'); +}); + +it('returns random color attribute when value is not set', function() { + $locationArea = LocationArea::factory()->create(['color' => '']); + + $result = $locationArea->color; + + expect($result)->not->toBe('') + ->and(in_array($result, LocationArea::$areaColors))->toBeTrue(); +}); + +it('returns location id attribute', function() { + $locationArea = LocationArea::factory()->create(['location_id' => 123]); + + $result = $locationArea->getLocationId(); + + expect($result)->toBe(123); +}); + +it('checks if boundary is address type', function() { + $locationArea = LocationArea::factory()->create(['type' => 'address']); + + $result = $locationArea->isAddressBoundary(); + + expect($result)->toBeTrue(); +}); + +it('checks if boundary is polygon type', function() { + $locationArea = LocationArea::factory()->create(['type' => 'polygon']); + + $result = $locationArea->isPolygonBoundary(); + + expect($result)->toBeTrue(); +}); + +it('pointInVertices returns false when vertices is empty', function() { + $locationArea = LocationArea::factory()->create(['boundaries' => []]); + $coordinate = mock(CoordinatesInterface::class); + + $result = $locationArea->pointInVertices($coordinate); + + expect($result)->toBeFalse(); +}); + +it('pointInCircle returns false when circle is empty', function() { + $locationArea = LocationArea::factory()->create(['boundaries' => []]); + $coordinate = mock(CoordinatesInterface::class); + + $result = $locationArea->pointInCircle($coordinate); + + expect($result)->toBeFalse(); +}); + +it('checks if point is inside polygon vertices', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => [ + 'vertices' => json_encode([['lat' => 12.345678, 'lng' => 98.765432]]), + ], + ]); + $coordinate = mock(CoordinatesInterface::class); + $coordinate->shouldReceive('getLatitude')->andReturn(12.345678); + $coordinate->shouldReceive('getLongitude')->andReturn(98.765432); + + $result = $locationArea->pointInVertices($coordinate); + + expect($result)->toBeTrue(); +}); + +it('checks if point is outside polygon vertices', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => [ + 'vertices' => json_encode([['lat' => 12.345678, 'lng' => 98.765432]]), + ], + ]); + $coordinate = mock(CoordinatesInterface::class); + $coordinate->shouldReceive('getLatitude')->andReturn(0.0); + $coordinate->shouldReceive('getLongitude')->andReturn(0.0); + + $result = $locationArea->pointInVertices($coordinate); + + expect($result)->toBeFalse(); +}); + +it('checks if point is inside circle boundary', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => [ + 'circle' => json_encode(['lat' => 12.345678, 'lng' => 98.765432, 'radius' => 1000]), + ], + ]); + $coordinate = new Coordinates(12.345678, 98.765432); + + $result = $locationArea->pointInCircle($coordinate); + + expect($result)->toBeTrue(); +}); + +it('checks if point is outside circle boundary', function() { + $locationArea = LocationArea::factory()->create([ + 'boundaries' => [ + 'circle' => json_encode(['lat' => 12.345678, 'lng' => 98.765432, 'radius' => 1000]), + ], + ]); + $coordinate = new Coordinates(0.0, 0.0); + + $result = $locationArea->pointInCircle($coordinate); + + expect($result)->toBeFalse(); +}); + +it('checks if point is inside address boundary', function() { + $locationArea = LocationArea::factory()->create([ + 'type' => 'address', + 'boundaries' => [ + 'components' => [ + ['type' => 'locality', 'value' => 'White City'], + ['type' => 'postal_code', 'value' => 'Country'], + ], + ], + ]); + $coordinate = new Coordinates(51.5086367, -0.2200662); + $address = new GeoliteLocation('google'); + $address->setPostalCode('WC2H 9FA'); + $address->addAdminLevel(2, 'Greater London', 'Greater London'); + $address->setCountryName('United Kingdom'); + $address->setCountryCode('GB'); + Geocoder::shouldReceive('reverse')->with(51.5086367, -0.2200662)->andReturn(collect([$address])); + + $result = $locationArea->checkBoundary($coordinate); + + expect($result)->toBeFalse(); +}); + +it('configures location area model correctly', function() { + $locationArea = new LocationArea; + + expect(class_uses_recursive($locationArea)) + ->toContain(Defaultable::class) + ->toContain(Sortable::class) + ->toContain(Validation::class) + ->and($locationArea->getTable())->toBe('location_areas') + ->and($locationArea->getKeyName())->toBe('area_id') + ->and($locationArea->getFillable())->toEqual([ + 'area_id', 'type', 'name', 'boundaries', 'conditions', 'priority', 'is_default', + ]) + ->and($locationArea->relation)->toEqual([ + 'belongsTo' => [ + 'location' => [LocationModel::class], + ], + ]) + ->and($locationArea->getCasts())->toEqual([ + 'area_id' => 'int', + 'boundaries' => 'array', + 'conditions' => 'array', + 'is_default' => 'boolean', + ]) + ->and($locationArea->getAppends())->toContain('vertices', 'circle') + ->and(LocationArea::$areaColors)->toEqual([ + '#F16745', '#FFC65D', '#7BC8A4', '#4CC3D9', '#93648D', '#404040', + '#F16745', '#FFC65D', '#7BC8A4', '#4CC3D9', '#93648D', '#404040', + '#F16745', '#FFC65D', '#7BC8A4', '#4CC3D9', '#93648D', '#404040', + '#F16745', '#FFC65D', + ]) + ->and($locationArea->rules)->toEqual([ + ['type', 'igniter.local::default.label_area_type', 'sometimes|required|string'], + ['name', 'igniter.local::default.label_area_name', 'sometimes|required|string'], + ['area_id', 'igniter.local::default.label_area_id', 'nullable|integer'], + ['boundaries.components', 'igniter.local::default.label_address_component', 'sometimes|required_if:type,address'], + ['boundaries.components.*.type', 'igniter.local::default.label_address_component_type', 'sometimes|required|string'], + ['boundaries.components.*.value', 'igniter.local::default.label_address_component_value', 'sometimes|required|string'], + ['boundaries.polygon', 'igniter.local::default.label_area_shape', 'sometimes|required_if:type,polygon'], + ['boundaries.circle', 'igniter.local::default.label_area_circle', 'sometimes|required_if:type,circle|json'], + ['boundaries.vertices', 'igniter.local::default.label_area_vertices', 'sometimes|required_unless:type,address|json'], + ['boundaries.distance.*.type', 'igniter.local::default.label_area_distance', 'sometimes|required|string'], + ['boundaries.distance.*.distance', 'igniter.local::default.label_area_distance', 'sometimes|required|numeric'], + ['boundaries.distance.*.charge', 'igniter.local::default.label_area_charge', 'sometimes|required|numeric'], + ['conditions', 'igniter.local::default.label_delivery_condition', 'sometimes|required'], + ['conditions.*.amount', 'igniter.local::default.label_area_charge', 'sometimes|required|numeric'], + ['conditions.*.type', 'igniter.local::default.label_charge_condition', 'sometimes|required|alpha_dash'], + ['conditions.*.total', 'igniter.local::default.label_area_min_amount', 'sometimes|required|numeric'], + ]); +}); diff --git a/tests/Models/LocationSettingsTest.php b/tests/Models/LocationSettingsTest.php new file mode 100644 index 0000000..e7c7372 --- /dev/null +++ b/tests/Models/LocationSettingsTest.php @@ -0,0 +1,144 @@ +create(); + $location->settings()->create(['item' => $settingsCode]); + + $result = LocationSettings::instance($location, $settingsCode); + + expect($result)->toBeInstanceOf(LocationSettings::class) + ->and($result->location_id)->toBe($location->getKey()) + ->and($result->item)->toBe($settingsCode); +}); + +it('returns existing instance from cache', function() { + $settingsCode = 'test_code'; + $location = Location::factory()->create(); + $location->settings()->create(['item' => $settingsCode]); + $instance = LocationSettings::instance($location, $settingsCode); + + $result = LocationSettings::instance($location, $settingsCode); + + expect($result)->toBe($instance); +}); + +it('sets settings value for allowed key', function() { + $locationSettings = new LocationSettings(); + $locationSettings->setSettingsValue('allowed_key', 'value'); + + $result = $locationSettings->getSettingsValue(); + + expect($result['allowed_key'])->toBe('value'); +}); + +it('does not set settings value for disallowed key', function() { + $locationSettings = new LocationSettings(); + $locationSettings->setSettingsValue('id', 'value'); + + $result = $locationSettings->getSettingsValue(); + + expect($result)->not->toHaveKey('id'); +}); + +it('returns default value if key is not set', function() { + $locationSettings = new LocationSettings(); + + $result = $locationSettings->get('non_existent_key', 'default_value'); + + expect($result)->toBe('default_value'); +}); + +it('sets settings values after fetching data', function() { + $locationSettings = new LocationSettings(['data' => ['key' => 'value']]); + $locationSettings->afterFetch(); + + $result = $locationSettings->getSettingsValue(); + + expect($result)->toBeArray() + ->and($result['key'])->toBe('value'); +}); + +it('merges settings values with attributes after fetching data', function() { + $locationSettings = new LocationSettings(['data' => ['key' => 'value'], 'attribute' => 'attr_value']); + $locationSettings->afterFetch(); + + $result = $locationSettings->getAttributes(); + + expect($result)->toHaveKey('key') + ->and($result['key'])->toBe('value') + ->and($result)->toHaveKey('attribute') + ->and($result['attribute'])->toBe('attr_value'); +}); + +it('sets data attribute before saving if settings values are present', function() { + $locationSettings = new LocationSettings(); + $locationSettings->setSettingsValue('key', 'value'); + $locationSettings->beforeSave(); + + $result = $locationSettings->data; + + expect($result)->toBeArray() + ->and($result['key'])->toBe('value'); +}); + +it('does not set data attribute before saving if settings values are empty', function() { + $locationSettings = new LocationSettings(); + $locationSettings->beforeSave(); + + $result = $locationSettings->data; + + expect($result)->toBeNull(); +}); + +it('clears internal cache', function() { + $location = Location::factory()->create(); + $settingsCode = 'test_code'; + LocationSettings::instance($location, $settingsCode); + + LocationSettings::clearInternalCache(); + + $result = LocationSettings::instance($location, $settingsCode); + + expect($result)->not->toBeNull(); +}); + +it('loads registered settings', function() { + $locationSettings = new LocationSettings(); + LocationSettings::registerCallback(function($settings) { + $settings->registerSettingItems('test_extension', [ + 'test_code' => [ + 'label' => 'Test Label', + 'form' => 'test_config', + ], + ]); + }); + + $locationSettings->loadRegisteredSettings(); + + $result = $locationSettings->listRegisteredSettings(); + + expect($result)->toHaveKey('test_code') + ->and($result['test_code']->label)->toBe('Test Label') + ->and($result['test_code']->form)->toBe('test_extension::test_config'); +}); + +it('configures location settings model correctly', function() { + $locationSettings = new LocationSettings; + + expect($locationSettings->getTable())->toBe('location_settings') + ->and($locationSettings->timestamps)->toBeFalse() + ->and($locationSettings->getCasts())->toEqual([ + 'id' => 'int', + 'data' => 'array', + ]); +}); diff --git a/tests/Models/LocationTest.php b/tests/Models/LocationTest.php index c407667..2e21647 100644 --- a/tests/Models/LocationTest.php +++ b/tests/Models/LocationTest.php @@ -4,48 +4,66 @@ use Igniter\Flame\Database\Attach\HasMedia; use Igniter\Flame\Database\Traits\HasPermalink; -use Igniter\Flame\Geolite\Facades\Geocoder; use Igniter\Local\Models\Concerns\HasDeliveryAreas; use Igniter\Local\Models\Concerns\HasWorkingHours; use Igniter\Local\Models\Concerns\LocationHelpers; use Igniter\Local\Models\Location; +use Igniter\Local\Models\LocationArea; use Igniter\Local\Models\Scopes\LocationScope; use Igniter\System\Models\Concerns\Defaultable; use Igniter\System\Models\Concerns\HasCountry; use Igniter\System\Models\Concerns\Switchable; -it('geocodes address on save', function() { - $location = Location::factory()->create(); - - $lat = 37.7749295; - $lng = -122.4194155; - Geocoder::shouldReceive('geocode')->andReturn(collect([ - \Igniter\Flame\Geolite\Model\Location::createFromArray([ - 'latitude' => $lat, - 'longitude' => $lng, - ]), - ])); - - $location->is_auto_lat_lng = true; - $location->location_lat = null; - $location->location_lng = null; - $location->save(); - - expect($location->location_lat)->toBe($lat) - ->and($location->location_lng)->toBe($lng); +it('returns dropdown options for enabled locations', function() { + $location1 = Location::factory()->create(['location_name' => 'Location 1', 'location_status' => true]); + $location2 = Location::factory()->create(['location_name' => 'Location 2', 'location_status' => false]); + + $result = Location::getDropdownOptions(); + + expect($result)->toHaveKey($location1->getKey(), 'Location 1') + ->and($result)->not->toHaveKey($location2->getKey()); }); -it('adds delivery areas on save', function() { - $location = Location::factory()->create(); +it('checks if onboarding is complete with valid default location', function() { + Location::$defaultModels = []; + $location = Location::factory()->create([ + 'location_lat' => '12.345678', + 'location_lng' => '98.765432', + ]); + $location->makeDefault(); + $location->delivery_areas()->save(LocationArea::factory()->create(['is_default' => true])); + + $result = Location::onboardingIsComplete(); - $location->delivery_areas = [ - ['area_id' => 1, 'conditions' => ['min_total' => 10]], - ['area_id' => 2, 'conditions' => ['min_total' => 20]], - ]; + expect($result)->toBeTrue(); +}); + +it('checks if onboarding is incomplete without default location', function() { + Location::$defaultModels = []; + $default = Location::getDefault(); + $default->delete(); + Location::$defaultModels = []; - $location->save(); + $result = Location::onboardingIsComplete(); - expect($location->delivery_areas()->count())->toBe(2); + expect($result)->toBeFalse(); +}); + +it('checks if onboarding is incomplete with default location missing coordinates', function() { + $location = Location::factory()->create(['location_lat' => null, 'location_lng' => null]); + $location->delivery_areas()->create(['is_default' => true]); + + $result = Location::onboardingIsComplete(); + + expect($result)->toBeFalse(); +}); + +it('checks if onboarding is incomplete with default location missing delivery areas', function() { + Location::factory()->create(['location_lat' => '12.345678', 'location_lng' => '98.765432']); + + $result = Location::onboardingIsComplete(); + + expect($result)->toBeFalse(); }); it('applies filters to query builder', function() { @@ -65,6 +83,7 @@ it('configures location model correctly', function() { $location = new Location; + $location->location_name = 'Location Name'; expect(class_uses_recursive($location)) ->toContain(Defaultable::class) @@ -93,5 +112,6 @@ ->and($location->mediable)->toBe([ 'thumb', 'gallery' => ['multiple' => true], - ]); + ]) + ->and($location->defaultableName())->toBe('Location Name'); }); diff --git a/tests/Models/ReviewSettingsTest.php b/tests/Models/ReviewSettingsTest.php new file mode 100644 index 0000000..9a24fbf --- /dev/null +++ b/tests/Models/ReviewSettingsTest.php @@ -0,0 +1,79 @@ +toBeTrue(); +}); + +it('returns false when reviews are not allowed', function() { + ReviewSettings::set('allow_reviews', false); + + $result = ReviewSettings::allowReviews(); + + expect($result)->toBeFalse(); +}); + +it('returns true when reviews are auto approved', function() { + ReviewSettings::set('approve_reviews', true); + + $result = ReviewSettings::autoApproveReviews(); + + expect($result)->toBeTrue(); +}); + +it('returns false when reviews are not auto approved', function() { + ReviewSettings::set('approve_reviews', false); + + $result = ReviewSettings::autoApproveReviews(); + + expect($result)->toBeFalse(); +}); + +it('returns default hints when no custom hints are set', function() { + $result = ReviewSettings::getHints(); + + expect($result)->toBeArray() + ->and($result)->toContain('Poor') + ->and($result)->toContain('Average') + ->and($result)->toContain('Good') + ->and($result)->toContain('Very Good') + ->and($result)->toContain('Excellent'); +}); + +it('returns custom hints when they are set', function() { + $customHints = [ + ['value' => 'Bad'], + ['value' => 'Okay'], + ['value' => 'Great'], + ]; + ReviewSettings::set('hints', $customHints); + + $result = ReviewSettings::getHints(); + + expect($result)->toBeArray() + ->and($result)->toContain('Bad') + ->and($result)->toContain('Okay') + ->and($result)->toContain('Great'); +}); + +it('configures review settings model correctly', function() { + $reviewSettings = new ReviewSettings; + + expect($reviewSettings->implement)->toContain(\Igniter\System\Actions\SettingsModel::class) + ->and($reviewSettings->settingsCode)->toBe('igniter_review_settings') + ->and($reviewSettings->settingsFieldsConfig)->toBe('reviewsettings') + ->and(ReviewSettings::$defaultHints)->toEqual([ + ['value' => 'Poor'], + ['value' => 'Average'], + ['value' => 'Good'], + ['value' => 'Very Good'], + ['value' => 'Excellent'], + ]); +}); diff --git a/tests/Models/ReviewTest.php b/tests/Models/ReviewTest.php new file mode 100644 index 0000000..b6117d3 --- /dev/null +++ b/tests/Models/ReviewTest.php @@ -0,0 +1,171 @@ +create(); + setting()->set(['completed_order_status' => [$status->getKey()]]); + $reviewable = Order::factory()->create([ + 'processed' => 1, + 'location_id' => 1, + 'customer_id' => 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + $reviewable->updateOrderStatus($status->getKey()); + + $data = ['quality' => 5, 'delivery' => 4, 'service' => 3, 'review_text' => 'Great service!']; + + $review = Review::leaveReview($reviewable, $data); + + expect($review)->toBeInstanceOf(Review::class) + ->and($review->quality)->toBe(5) + ->and($review->delivery)->toBe(4) + ->and($review->service)->toBe(3) + ->and($review->review_text)->toBe('Great service!'); +}); + +it('throws exception when creating review for incomplete sale', function() { + $reviewable = Order::factory()->create(); + + $data = ['quality' => 5, 'delivery' => 4, 'service' => 3, 'review_text' => 'Great service!']; + + expect(fn() => Review::leaveReview($reviewable, $data))->toThrow(ApplicationException::class); +}); + +it('throws exception when creating duplicate review', function() { + $customer = Customer::factory()->create(); + $status = Status::factory()->create(); + setting()->set(['completed_order_status' => [$status->getKey()]]); + $reviewable = Order::factory()->create([ + 'processed' => 1, + 'location_id' => 1, + 'customer_id' => $customer->getKey(), + 'first_name' => 'John', + 'last_name' => 'Doe', + ]); + $reviewable->updateOrderStatus($status->getKey()); + Review::factory()->create([ + 'customer_id' => $customer->getKey(), + 'reviewable_id' => $reviewable->getKey(), + 'reviewable_type' => $reviewable->getMorphClass(), + ]); + + $data = ['quality' => 5, 'delivery' => 4, 'service' => 3, 'review_text' => 'Great service!']; + + expect(fn() => Review::leaveReview($reviewable, $data))->toThrow(ApplicationException::class); +}); + +it('returns correct reviewable type options', function() { + $result = Review::getReviewableTypeOptions(); + + expect($result)->toBeArray() + ->and($result)->toHaveKey('orders') + ->and($result)->toHaveKey('reservations'); +}); + +it('finds reviewable by sale type and id', function() { + $order = Order::factory()->create(); + + $result = Review::findBy('orders', $order->getKey()); + + expect($result)->toBeInstanceOf(Order::class) + ->and($result->getKey())->toBe($order->getKey()); +}); + +it('throws exception when sale type is not defined', function() { + $order = Order::factory()->create(); + + expect(fn() => Review::findBy('invalid-sale-type', $order->getKey()))->toThrow(ModelNotFoundException::class); +}); + +it('applies approved query scope correctly', function() { + $query = Review::query()->isApproved(); + + expect($query->toSql())->toContain('`review_status` = ?'); +}); + +it('applies reviewed query scope correctly', function() { + $order = Order::factory()->create(); + $query = Review::query()->hasBeenReviewed($order, 123); + + expect($query->toSql())->toContain('`customer_id` = ?'); +}); + +it('applies reviewable query scope correctly', function() { + $order = Order::factory()->create(); + $query = Review::query()->whereReviewable($order); + + expect($query->toSql())->toContain('`reviewable_type` = ? and `reviewable_id` = ?'); +}); + +it('returns review dates as an array', function() { + $review = Review::factory()->create(['created_at' => now()]); + + $result = $review->getReviewDates(); + + expect($result)->toBeArray() + ->and($result)->toContain($review->created_at->format('F Y')); +}); + +it('returns null if location id is not provided for score calculation', function() { + $result = Review::getScoreForLocation(null); + + expect($result)->toBeNull(); +}); + +it('calculates correct score for location', function() { + $location = Location::factory()->create(); + Review::factory()->create(['location_id' => $location->getKey(), 'quality' => 5, 'delivery' => 4, 'service' => 3]); + + $result = Review::getScoreForLocation($location->getKey()); + + expect($result)->toBeNumeric() + ->and($result)->toBeGreaterThan(0); +}); + +it('configures review model correctly', function() { + $review = new Review; + + expect(class_uses_recursive($review)) + ->toContain(Locationable::class) + ->toContain(Switchable::class) + ->and(Review::SWITCHABLE_COLUMN)->toBe('review_status') + ->and($review->getTable())->toBe('igniter_reviews') + ->and($review->getKeyName())->toBe('review_id') + ->and($review->timestamps)->toBeTrue() + ->and($review->getGuarded())->toBe([]) + ->and($review->getCasts())->toBe([ + 'review_id' => 'int', + 'customer_id' => 'integer', + 'reviewable_id' => 'integer', + 'location_id' => 'integer', + 'quality' => 'integer', + 'service' => 'integer', + 'delivery' => 'integer', + 'review_status' => 'boolean', + ]) + ->and($review->relation)->toBe([ + 'belongsTo' => [ + 'location' => [Location::class, 'scope' => 'isEnabled'], + 'customer' => \Igniter\User\Models\Customer::class, + ], + 'morphTo' => [ + 'reviewable' => ['name' => 'sale'], + ], + ]) + ->and(Review::$relatedSaleTypes)->toEqual([ + 'orders' => Order::class, + 'reservations' => \Igniter\Reservation\Models\Reservation::class, + ]); +}); diff --git a/tests/Models/Scopes/LocationScopeTest.php b/tests/Models/Scopes/LocationScopeTest.php new file mode 100644 index 0000000..8172742 --- /dev/null +++ b/tests/Models/Scopes/LocationScopeTest.php @@ -0,0 +1,44 @@ +shouldReceive('selectDistance')->with(12.345678, 98.765432)->andReturnSelf(); + + $applyPosition = (new LocationScope())->addApplyPosition(); + $result = $applyPosition($builder, ['latitude' => 12.345678, 'longitude' => 98.765432]); + + expect($result)->toBe($builder); +}); + +it('selects distance in kilometers when distance unit is km', function() { + setting()->set('distance_unit', 'km'); + $builder = mock(Builder::class); + $builder->shouldReceive('selectRaw')->with( + '( 6371 * acos( cos( radians(?) ) * cos( radians( location_lat ) ) * cos( radians( location_lng ) - radians(?) ) + sin( radians(?) ) * sin( radians( location_lat ) ) ) ) AS distance', + [12.345678, 98.765432, 12.345678], + )->andReturnSelf(); + + $selectDistance = (new LocationScope())->addSelectDistance(); + $result = $selectDistance($builder, 12.345678, 98.765432); + + expect($result)->toBe($builder); +}); + +it('selects distance in miles when distance unit is miles', function() { + setting()->set('distance_unit', 'miles'); + $builder = mock(Builder::class); + $builder->shouldReceive('selectRaw')->with( + '( 3959 * acos( cos( radians(?) ) * cos( radians( location_lat ) ) * cos( radians( location_lng ) - radians(?) ) + sin( radians(?) ) * sin( radians( location_lat ) ) ) ) AS distance', + [12.345678, 98.765432, 12.345678], + )->andReturnSelf(); + + $selectDistance = (new LocationScope())->addSelectDistance(); + $result = $selectDistance($builder, 12.345678, 98.765432); + + expect($result)->toBe($builder); +}); diff --git a/tests/Models/Scopes/LocationableScopeTest.php b/tests/Models/Scopes/LocationableScopeTest.php new file mode 100644 index 0000000..7d3a1fc --- /dev/null +++ b/tests/Models/Scopes/LocationableScopeTest.php @@ -0,0 +1,97 @@ +create(); + $builder = mock(Builder::class); + $builder->shouldReceive('withoutGlobalScope')->andReturnSelf(); + $builder->shouldReceive('getModel->locationableRelationName')->andReturn('location'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('getModel->locationableIsSingleRelationType')->andReturn(true); + $builder->shouldReceive('whereIn')->with('id', [$location->getKey()])->andReturnSelf(); + + $whereHasLocation = (new LocationableScope)->addWhereHasLocation(); + $result = $whereHasLocation($builder, $location); + + expect($result)->toBe($builder); +}); + +it('applies where has location with multiple location ids', function() { + $builder = mock(Builder::class); + $builder->shouldReceive('withoutGlobalScope')->andReturnSelf(); + $builder->shouldReceive('getModel->locationableRelationName')->andReturn('location'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getTable')->andReturn('menus'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getParent->getTable')->andReturn('menus'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('getModel->locationableIsSingleRelationType')->andReturnFalse(); + $builder->shouldReceive('getModel->locationableIsMorphRelationType')->andReturnTrue(); + $builder->shouldReceive('whereHas')->andReturnSelf(); + $builder->shouldReceive('whereIn')->with('id', [1, 2])->andReturnSelf(); + + $whereHasLocation = (new LocationableScope)->addWhereHasLocation(); + $result = $whereHasLocation($builder, [1, 2]); + + expect($result)->toBe($builder); +}); + +it('applies where has location with multiple location ids and nor morph type', function() { + $builder = mock(Builder::class); + $builder->shouldReceive('withoutGlobalScope')->andReturnSelf(); + $builder->shouldReceive('getModel->locationableRelationName')->andReturn('location'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getTable')->andReturn('menus'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getParent->getTable')->andReturn('menus'); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('getModel->locationableIsSingleRelationType')->andReturnFalse(); + $builder->shouldReceive('getModel->locationableIsMorphRelationType')->andReturnFalse(); + $builder->shouldReceive('whereHas')->andReturnSelf(); + $builder->shouldReceive('whereIn')->with('id', [1, 2])->andReturnSelf(); + + $whereHasLocation = (new LocationableScope)->addWhereHasLocation(); + $result = $whereHasLocation($builder, [1, 2]); + + expect($result)->toBe($builder); +}); + +it('applies where has or doesnt have location with single location id', function() { + $builder = mock(Builder::class); + $builder->shouldReceive('withoutGlobalScope')->andReturnSelf(); + $builder->shouldReceive('where')->andReturnUsing(function($callback) use ($builder) { + $callback($builder); + return $builder; + }); + $builder->shouldReceive('whereHasLocation')->with(1)->andReturnSelf(); + $builder->shouldReceive('getModel->locationableIsSingleRelationType')->andReturn(true); + $builder->shouldReceive('getModel->getLocationableRelationObject->getRelated->getKeyName')->andReturn('id'); + $builder->shouldReceive('orWhereNull')->with('id')->andReturnSelf(); + + $whereHasOrDoesntHaveLocation = (new LocationableScope)->addWhereHasOrDoesntHaveLocation(); + $result = $whereHasOrDoesntHaveLocation($builder, 1); + + expect($result)->toBe($builder); +}); + +it('applies where has or doesnt have location with multiple location ids', function() { + $builder = mock(Builder::class); + $builder->shouldReceive('withoutGlobalScope')->andReturnSelf(); + $builder->shouldReceive('where')->andReturnUsing(function($callback) use ($builder) { + $callback($builder); + return $builder; + }); + $builder->shouldReceive('whereHasLocation')->with([1, 2])->andReturnSelf(); + $builder->shouldReceive('getModel->locationableIsSingleRelationType')->andReturnFalse(); + $builder->shouldReceive('getModel->locationableRelationName')->andReturn('locations'); + $builder->shouldReceive('orWhereNull')->with('id')->andReturnSelf(); + $builder->shouldReceive('orDoesntHave')->with('locations')->andReturnSelf(); + + $whereHasOrDoesntHaveLocation = (new LocationableScope)->addWhereHasOrDoesntHaveLocation(); + $result = $whereHasOrDoesntHaveLocation($builder, [1, 2]); + + expect($result)->toBe($builder); +}); diff --git a/tests/Models/WorkingHourTest.php b/tests/Models/WorkingHourTest.php new file mode 100644 index 0000000..f3d1a34 --- /dev/null +++ b/tests/Models/WorkingHourTest.php @@ -0,0 +1,101 @@ + 'value']; + + $result = $workingHour->getTimesheetOptions($value, []); + + expect($result)->toBeObject() + ->and($result->timesheet)->toBe($value) + ->and(array_column($result->daysOfWeek, 'name'))->toEqual([ + 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', + ]); +}); + +it('returns correct day attribute', function() { + $workingHour = WorkingHour::create(['weekday' => 1]); + + $result = $workingHour->day; + + expect($result->format('l'))->toBe('Tuesday'); +}); + +it('returns correct open attribute', function() { + $workingHour = WorkingHour::create(['weekday' => 1, 'opening_time' => '08:00']); + + $result = $workingHour->open; + + expect($result->format('H:i'))->toBe('08:00'); +}); + +it('returns correct close attribute', function() { + $workingHour = WorkingHour::create(['weekday' => 1, 'opening_time' => '22:00', 'closing_time' => '02:00']); + + $result = $workingHour->close; + + expect($result->format('H:i'))->toBe('02:00') + ->and($result->format('l'))->toBe('Wednesday'); +}); + +it('returns true when open all day', function() { + $workingHour = WorkingHour::create(['opening_time' => '00:00', 'closing_time' => '23:59']); + + $result = $workingHour->isOpenAllDay(); + + expect($result)->toBeTrue(); +}); + +it('returns false when not open all day', function() { + $workingHour = WorkingHour::create(['opening_time' => '08:00', 'closing_time' => '17:00']); + + $result = $workingHour->isOpenAllDay(); + + expect($result)->toBeFalse(); +}); + +it('returns true when past midnight', function() { + $workingHour = WorkingHour::create(['opening_time' => '22:00', 'closing_time' => '02:00']); + + $result = $workingHour->isPastMidnight(); + + expect($result)->toBeTrue(); +}); + +it('returns false when not past midnight', function() { + $workingHour = WorkingHour::create(['opening_time' => '08:00', 'closing_time' => '17:00']); + + $result = $workingHour->isPastMidnight(); + + expect($result)->toBeFalse(); +}); + +it('configures working hour model correctly', function() { + $workingHour = new WorkingHour; + + expect(class_uses_recursive($workingHour))->toContain(Switchable::class) + ->and($workingHour->getTable())->toBe('working_hours') + ->and($workingHour->getKeyName())->toBe('id') + ->and($workingHour->relation)->toEqual([ + 'belongsTo' => [ + 'location' => [\Igniter\Local\Models\Location::class], + ], + ]) + ->and($workingHour->getAppends())->toEqual(['day', 'open', 'close']) + ->and($workingHour->attributes)->toEqual([ + 'opening_time' => '00:00', + 'closing_time' => '23:59', + ]) + ->and($workingHour->getCasts())->toEqual([ + 'id' => 'int', + 'weekday' => 'integer', + 'opening_time' => 'time', + 'closing_time' => 'time', + ]) + ->and(WorkingHour::$weekDays)->toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); +}); diff --git a/tests/Traits/LocationAwareWidgetTest.php b/tests/Traits/LocationAwareWidgetTest.php new file mode 100644 index 0000000..4413b7b --- /dev/null +++ b/tests/Traits/LocationAwareWidgetTest.php @@ -0,0 +1,96 @@ +traitObject = new class + { + use \Igniter\Local\Traits\LocationAwareWidget; + + public function testIsLocationAware(array $config) + { + return $this->isLocationAware($config); + } + + public function testLocationApplyScope($query, array $config = []) + { + return $this->locationApplyScope($query, $config); + } + }; +}); + +it('returns true when location is aware and location check passes', function() { + LocationFacade::shouldReceive('check')->andReturn(true); + $config = ['locationAware' => true]; + + $result = $this->traitObject->testIsLocationAware($config); + + expect($result)->toBeTrue(); +}); + +it('returns false when location is not aware', function() { + $config = ['locationAware' => false]; + + $result = $this->traitObject->testIsLocationAware($config); + + expect($result)->toBeFalse(); +}); + +it('applies location scope to location model', function() { + $location = Location::factory()->create(); + LocationFacade::shouldReceive('currentOrAssigned')->andReturn([$location->id]); + $query = Location::query(); + $config = ['locationAware' => true]; + + $this->traitObject->testLocationApplyScope($query, $config); + + expect($query->toSql())->toContain('where `location_id` in (?)'); +}); + +it('does not apply location scope when model is not locationable', function() { + $query = Status::query(); + $config = ['locationAware' => true]; + + $this->traitObject->testLocationApplyScope($query, $config); + + expect($query->toSql())->not->toContain('where `location_id` in (?)'); +}); + +it('does not apply location scope when location is not current or assigned', function() { + LocationFacade::shouldReceive('currentOrAssigned')->andReturn([]); + $query = Menu::query(); + $config = ['locationAware' => true]; + + $this->traitObject->testLocationApplyScope($query, $config); + + expect($query->toSql())->not->toContain('where `location_id` in (?)'); +}); + +it('applies location scope to assigned only', function() { + $location = \Igniter\Local\Models\Location::factory()->create(); + LocationFacade::shouldReceive('currentOrAssigned')->andReturn([$location->id]); + + $query = Menu::query(); + $config = ['locationAware' => 'assignedOnly']; + + $this->traitObject->testLocationApplyScope($query, $config); + + expect($query->toSql())->toContain('where exists'); +}); + +it('applies location scope', function() { + $location = \Igniter\Local\Models\Location::factory()->create(); + LocationFacade::shouldReceive('currentOrAssigned')->andReturn([$location->id]); + + $query = Menu::query(); + $config = ['locationAware' => true]; + + $this->traitObject->testLocationApplyScope($query, $config); + + expect($query->toSql())->toContain('`locationables`.`locationable_type` = ? and `locationables`.`location_id` in (?)) or not exists'); +});