From 30d6fb28917808fff34dfa9f41c4eb74d9bf21a8 Mon Sep 17 00:00:00 2001 From: wychoong <67364036+wychoong@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:30:09 +0800 Subject: [PATCH 1/7] add postcode resolver config --- packages/table-rate-shipping/config/shipping-tables.php | 6 ++++++ .../src/Resolvers/ShippingZoneResolver.php | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/table-rate-shipping/config/shipping-tables.php b/packages/table-rate-shipping/config/shipping-tables.php index 1e2ce9a3f9..f08c83397c 100644 --- a/packages/table-rate-shipping/config/shipping-tables.php +++ b/packages/table-rate-shipping/config/shipping-tables.php @@ -1,5 +1,11 @@ env('LUNAR_SHIPPING_TABLES_ENABLED', true), + + 'resolvers' => [ + 'postcode' => PostcodeResolver::class, + ], ]; diff --git a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php index 1683b1f304..2670013c55 100644 --- a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php +++ b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php @@ -96,9 +96,11 @@ public function get(): Collection } if ($this->postcodeLookup) { - $builder->orWhere(function ($qb) { - $qb->whereHas('postcodes', function ($query) { - $postcodeParts = (new PostcodeResolver)->getParts( + $postcodeResolver = config('lunar.shipping-tables.resolvers.postcode', PostcodeResolver::class); + + $builder->orWhere(function ($qb) use ($postcodeResolver) { + $qb->whereHas('postcodes', function ($query) use ($postcodeResolver) { + $postcodeParts = (new $postcodeResolver)->getParts( $this->postcodeLookup->postcode ); $query->whereIn('postcode', $postcodeParts); From a14857d505c46a156761d65aca7c8c20382f6a63 Mon Sep 17 00:00:00 2001 From: wychoong <67364036+wychoong@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:30:50 +0800 Subject: [PATCH 2/7] add test --- .../Resolvers/TestCustomPostcodeResolver.php | 21 +++++++++ .../Resolvers/ShippingZoneResolverTest.php | 47 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php diff --git a/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php b/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php new file mode 100644 index 0000000000..eeeb67d0d7 --- /dev/null +++ b/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php @@ -0,0 +1,21 @@ +filter()->unique()->values(); + } +} diff --git a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php index 98d51c6a8f..15652e1feb 100644 --- a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php +++ b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php @@ -1,12 +1,15 @@ group('shipping', 'shipping-zone'); +use Illuminate\Support\Facades\Config; use Lunar\Models\Country; use Lunar\Models\State; use Lunar\Shipping\DataTransferObjects\PostcodeLookup; use Lunar\Shipping\Models\ShippingZone; use Lunar\Shipping\Resolvers\ShippingZoneResolver; +use Lunar\Tests\Shipping\Stubs\Resolvers\TestCustomPostcodeResolver; uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); @@ -109,3 +112,45 @@ expect($zones->first()->id)->toEqual($shippingZone->id); }); + +test('can use custom postcode resolver', function () { + $country = Country::factory()->create(); + + $shippingZone = ShippingZone::factory()->create([ + 'type' => 'postcodes', + ]); + + $shippingZone->countries()->attach($country); + + $shippingZone->postcodes()->createMany([[ + 'postcode' => '390*', + ], [ + 'postcode' => '391*', + ]]); + + $shippingZone2 = ShippingZone::factory()->create([ + 'type' => 'postcodes', + ]); + + $shippingZone2->countries()->attach($country); + + $shippingZone2->postcodes()->create([ + 'postcode' => '393*', + ]); + + expect($shippingZone->refresh()->countries)->toHaveCount(1); + expect($shippingZone->refresh()->postcodes)->toHaveCount(2); + + $postcode = new PostcodeLookup( + $country, + '39100' + ); + + Config::set('lunar.shipping-tables.resolvers.postcode', TestCustomPostcodeResolver::class); + + $zones = (new ShippingZoneResolver)->postcode($postcode)->get(); + + expect($zones)->toHaveCount(1); + + expect($zones->first()->id)->toEqual($shippingZone->id); +})->group('shipping-postcode'); From 8d33afb74ea11692a8913c615f1e683bc01dee91 Mon Sep 17 00:00:00 2001 From: wychoong <67364036+wychoong@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:31:33 +0800 Subject: [PATCH 3/7] update shipping tests group --- .../shipping/Unit/Drivers/ShippingMethods/CollectionTest.php | 3 ++- tests/shipping/Unit/Drivers/ShippingMethods/FlatRateTest.php | 3 ++- .../Unit/Drivers/ShippingMethods/FreeShippingTest.php | 3 ++- tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php | 3 ++- tests/shipping/Unit/Managers/ShippingManagerTest.php | 3 ++- tests/shipping/Unit/Models/ShippingZonePostcodeTest.php | 3 ++- tests/shipping/Unit/Observers/OrderObserverTest.php | 3 ++- tests/shipping/Unit/Resolvers/PostcodeResolverTest.php | 3 ++- tests/shipping/Unit/Resolvers/ShippingOptionResolverTest.php | 3 ++- tests/shipping/Unit/Resolvers/ShippingRateResolverTest.php | 3 ++- tests/shipping/Unit/ShippingModifierTest.php | 5 +++-- 11 files changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/shipping/Unit/Drivers/ShippingMethods/CollectionTest.php b/tests/shipping/Unit/Drivers/ShippingMethods/CollectionTest.php index c4c83582af..952a4ff340 100644 --- a/tests/shipping/Unit/Drivers/ShippingMethods/CollectionTest.php +++ b/tests/shipping/Unit/Drivers/ShippingMethods/CollectionTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-driver', 'shipping-driver-collection'); use Lunar\DataTypes\ShippingOption; use Lunar\Models\Currency; diff --git a/tests/shipping/Unit/Drivers/ShippingMethods/FlatRateTest.php b/tests/shipping/Unit/Drivers/ShippingMethods/FlatRateTest.php index 9c1fba6e42..9aae879625 100644 --- a/tests/shipping/Unit/Drivers/ShippingMethods/FlatRateTest.php +++ b/tests/shipping/Unit/Drivers/ShippingMethods/FlatRateTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-driver', 'shipping-driver-flatrate'); use Lunar\DataTypes\ShippingOption; use Lunar\Models\Currency; diff --git a/tests/shipping/Unit/Drivers/ShippingMethods/FreeShippingTest.php b/tests/shipping/Unit/Drivers/ShippingMethods/FreeShippingTest.php index 904bf6907c..7bb68dfdd0 100644 --- a/tests/shipping/Unit/Drivers/ShippingMethods/FreeShippingTest.php +++ b/tests/shipping/Unit/Drivers/ShippingMethods/FreeShippingTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-driver', 'shipping-driver-freeshiping'); use Lunar\DataTypes\ShippingOption; use Lunar\Models\Currency; diff --git a/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php b/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php index feb8f1fc8b..9a1c617d92 100644 --- a/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php +++ b/tests/shipping/Unit/Drivers/ShippingMethods/ShipByTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-driver', 'shipping-driver-shipby'); use Lunar\DataTypes\ShippingOption; use Lunar\Models\Currency; diff --git a/tests/shipping/Unit/Managers/ShippingManagerTest.php b/tests/shipping/Unit/Managers/ShippingManagerTest.php index fc836a0792..64322a78b4 100644 --- a/tests/shipping/Unit/Managers/ShippingManagerTest.php +++ b/tests/shipping/Unit/Managers/ShippingManagerTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-manager'); use Lunar\Models\CartAddress; use Lunar\Models\Country; diff --git a/tests/shipping/Unit/Models/ShippingZonePostcodeTest.php b/tests/shipping/Unit/Models/ShippingZonePostcodeTest.php index 854899b92a..6e6c6c666b 100644 --- a/tests/shipping/Unit/Models/ShippingZonePostcodeTest.php +++ b/tests/shipping/Unit/Models/ShippingZonePostcodeTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-zone-postcode'); use Lunar\Shipping\Models\ShippingZone; use Lunar\Shipping\Models\ShippingZonePostcode; diff --git a/tests/shipping/Unit/Observers/OrderObserverTest.php b/tests/shipping/Unit/Observers/OrderObserverTest.php index 226d55f175..b5af181b81 100644 --- a/tests/shipping/Unit/Observers/OrderObserverTest.php +++ b/tests/shipping/Unit/Observers/OrderObserverTest.php @@ -3,7 +3,8 @@ use Lunar\Models\Order; use Lunar\Shipping\Observers\OrderObserver; -uses(\Lunar\Tests\Shipping\TestCase::class); +uses(\Lunar\Tests\Shipping\TestCase::class) + ->group('shipping', 'shipping-order'); uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); uses(\Lunar\Tests\Shipping\TestUtils::class); diff --git a/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php b/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php index e8622ed00e..ed161845a4 100644 --- a/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php +++ b/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-postcode'); use Lunar\Shipping\Resolvers\PostcodeResolver; diff --git a/tests/shipping/Unit/Resolvers/ShippingOptionResolverTest.php b/tests/shipping/Unit/Resolvers/ShippingOptionResolverTest.php index 68861802cf..df09ede477 100644 --- a/tests/shipping/Unit/Resolvers/ShippingOptionResolverTest.php +++ b/tests/shipping/Unit/Resolvers/ShippingOptionResolverTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-option'); use Lunar\Models\CartAddress; use Lunar\Models\Country; diff --git a/tests/shipping/Unit/Resolvers/ShippingRateResolverTest.php b/tests/shipping/Unit/Resolvers/ShippingRateResolverTest.php index 2f95882a77..75daa1030d 100644 --- a/tests/shipping/Unit/Resolvers/ShippingRateResolverTest.php +++ b/tests/shipping/Unit/Resolvers/ShippingRateResolverTest.php @@ -1,6 +1,7 @@ group('shipping', 'shipping-rate'); use Lunar\Models\CartAddress; use Lunar\Models\Country; diff --git a/tests/shipping/Unit/ShippingModifierTest.php b/tests/shipping/Unit/ShippingModifierTest.php index f0f9ceb24b..ed84d984be 100644 --- a/tests/shipping/Unit/ShippingModifierTest.php +++ b/tests/shipping/Unit/ShippingModifierTest.php @@ -6,7 +6,8 @@ use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingZone; -uses(\Lunar\Tests\Shipping\TestCase::class); +uses(\Lunar\Tests\Shipping\TestCase::class) + ->group('shipping', 'shipping-modifier'); uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); uses(\Lunar\Tests\Shipping\TestUtils::class); @@ -76,4 +77,4 @@ $option = $cart->refresh()->getShippingOption(); expect($option->price->value)->toBe(0); -})->group('shipping-modifier'); +}); From d6463df21970e3c5582f3adcdf887e823a991bcb Mon Sep 17 00:00:00 2001 From: wychoong <67364036+wychoong@users.noreply.github.com> Date: Tue, 19 Nov 2024 13:40:28 +0800 Subject: [PATCH 4/7] add postcode resolver interface --- .../src/Interfaces/PostcodeResolverInterface.php | 10 ++++++++++ .../src/Resolvers/PostcodeResolver.php | 3 ++- .../Stubs/Resolvers/TestCustomPostcodeResolver.php | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/table-rate-shipping/src/Interfaces/PostcodeResolverInterface.php diff --git a/packages/table-rate-shipping/src/Interfaces/PostcodeResolverInterface.php b/packages/table-rate-shipping/src/Interfaces/PostcodeResolverInterface.php new file mode 100644 index 0000000000..e47e31b7cf --- /dev/null +++ b/packages/table-rate-shipping/src/Interfaces/PostcodeResolverInterface.php @@ -0,0 +1,10 @@ + Date: Wed, 20 Nov 2024 11:02:21 +0800 Subject: [PATCH 5/7] cleanup --- .../src/Resolvers/ShippingZoneResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php index 2670013c55..749017251b 100644 --- a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php +++ b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php @@ -96,10 +96,10 @@ public function get(): Collection } if ($this->postcodeLookup) { - $postcodeResolver = config('lunar.shipping-tables.resolvers.postcode', PostcodeResolver::class); + $builder->orWhere(function ($qb) { + $qb->whereHas('postcodes', function ($query) { + $postcodeResolver = config('lunar.shipping-tables.resolvers.postcode', PostcodeResolver::class); - $builder->orWhere(function ($qb) use ($postcodeResolver) { - $qb->whereHas('postcodes', function ($query) use ($postcodeResolver) { $postcodeParts = (new $postcodeResolver)->getParts( $this->postcodeLookup->postcode ); From 48b5fb23c9ca04031f8057eb5e8c7f9104d0f038 Mon Sep 17 00:00:00 2001 From: Author Date: Wed, 15 Apr 2026 07:44:19 +0000 Subject: [PATCH 6/7] chore: fix code style --- tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php index e8b7887a4c..1080c2acb0 100644 --- a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php +++ b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php @@ -7,8 +7,8 @@ use Lunar\Shipping\DataTransferObjects\PostcodeLookup; use Lunar\Shipping\Models\ShippingZone; use Lunar\Shipping\Resolvers\ShippingZoneResolver; -use Lunar\Tests\Shipping\TestCase; use Lunar\Tests\Shipping\Stubs\Resolvers\TestCustomPostcodeResolver; +use Lunar\Tests\Shipping\TestCase; uses(TestCase::class) ->group('shipping', 'shipping-zone'); From c3ba1a1706b774b28261987c0dc50c8efb78f362 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 15 Apr 2026 14:06:16 +0100 Subject: [PATCH 7/7] Add postcode resolver logic --- .../config/shipping-tables.php | 6 - .../DataTransferObjects/PostcodeLookup.php | 15 ++- .../NoPostcodeResolverException.php | 16 +++ .../src/Facades/Postcode.php | 24 ++++ .../Interfaces/PostcodeResolverInterface.php | 13 +- .../src/Managers/PostcodeManager.php | 88 +++++++++++++ .../src/Resolvers/PostcodeResolver.php | 17 ++- .../src/Resolvers/ShippingZoneResolver.php | 7 +- .../src/ShippingServiceProvider.php | 9 ++ .../Resolvers/TestCustomPostcodeResolver.php | 16 ++- .../PostcodeLookupTest.php | 38 ++++++ .../Unit/Managers/PostcodeManagerTest.php | 124 ++++++++++++++++++ .../Unit/Resolvers/PostcodeResolverTest.php | 46 ++++--- .../Resolvers/ShippingZoneResolverTest.php | 119 ++++++++++++++--- .../Unit/ShippingServiceProviderTest.php | 19 +++ 15 files changed, 504 insertions(+), 53 deletions(-) create mode 100644 packages/table-rate-shipping/src/Exceptions/NoPostcodeResolverException.php create mode 100644 packages/table-rate-shipping/src/Facades/Postcode.php create mode 100644 packages/table-rate-shipping/src/Managers/PostcodeManager.php create mode 100644 tests/shipping/Unit/DataTransferObjects/PostcodeLookupTest.php create mode 100644 tests/shipping/Unit/Managers/PostcodeManagerTest.php create mode 100644 tests/shipping/Unit/ShippingServiceProviderTest.php diff --git a/packages/table-rate-shipping/config/shipping-tables.php b/packages/table-rate-shipping/config/shipping-tables.php index 8a8117d787..fa50996221 100644 --- a/packages/table-rate-shipping/config/shipping-tables.php +++ b/packages/table-rate-shipping/config/shipping-tables.php @@ -1,7 +1,5 @@ env('LUNAR_SHIPPING_TABLES_ENABLED', true), @@ -13,8 +11,4 @@ */ 'shipping_rate_tax_calculation' => 'default', - 'resolvers' => [ - 'postcode' => PostcodeResolver::class, - ], - ]; diff --git a/packages/table-rate-shipping/src/DataTransferObjects/PostcodeLookup.php b/packages/table-rate-shipping/src/DataTransferObjects/PostcodeLookup.php index cbd8021d6c..6de2e4f7e3 100644 --- a/packages/table-rate-shipping/src/DataTransferObjects/PostcodeLookup.php +++ b/packages/table-rate-shipping/src/DataTransferObjects/PostcodeLookup.php @@ -2,17 +2,26 @@ namespace Lunar\Shipping\DataTransferObjects; +use Illuminate\Support\Collection; use Lunar\Models\Contracts\Country as CountryContract; +use Lunar\Shipping\Facades\Postcode; class PostcodeLookup { - /** - * Initialise the postcode lookup class. - */ public function __construct( public CountryContract $country, public string $postcode ) { // } + + /** + * Return the postcode parts for this lookup, delegating to the country-matched resolver. + * + * @return Collection + */ + public function getParts(): Collection + { + return Postcode::country($this->country)->getParts($this->postcode, $this->country); + } } diff --git a/packages/table-rate-shipping/src/Exceptions/NoPostcodeResolverException.php b/packages/table-rate-shipping/src/Exceptions/NoPostcodeResolverException.php new file mode 100644 index 0000000000..4af6596f92 --- /dev/null +++ b/packages/table-rate-shipping/src/Exceptions/NoPostcodeResolverException.php @@ -0,0 +1,16 @@ + + */ + public function getParts(string $postcode, CountryContract $country): Collection; } diff --git a/packages/table-rate-shipping/src/Managers/PostcodeManager.php b/packages/table-rate-shipping/src/Managers/PostcodeManager.php new file mode 100644 index 0000000000..053fc7ba60 --- /dev/null +++ b/packages/table-rate-shipping/src/Managers/PostcodeManager.php @@ -0,0 +1,88 @@ + + */ + protected Collection $resolvers; + + public function __construct() + { + $this->resolvers = collect(); + } + + /** + * Register a resolver. Class strings are resolved lazily via the container. + */ + public function addResolver(string|PostcodeResolverInterface $resolver): self + { + $this->resolvers->push($resolver); + + return $this; + } + + /** + * Return the matching resolver for the given country. Iterates in reverse registration + * order — the last-registered resolver that supports the country wins. + */ + public function country(CountryContract $country): PostcodeResolverInterface + { + // Collection::reverse() preserves keys, so $index is the original slot in + // $this->resolvers — which resolveInstance() relies on for in-place caching. + foreach ($this->resolvers->reverse() as $index => $resolver) { + $instance = $this->resolveInstance($index, $resolver); + + if ($instance->supportsCountry($country)) { + return $instance; + } + } + + throw NoPostcodeResolverException::forCountry($country->iso2); + } + + /** + * Access the raw resolver collection — mostly for diagnostic use. + * + * @return Collection + */ + public function getResolvers(): Collection + { + return $this->resolvers; + } + + /** + * Resolve a collection entry to a concrete interface instance, caching in place so + * subsequent calls reuse the same instance for the rest of the request. + */ + protected function resolveInstance(int $index, string|PostcodeResolverInterface $resolver): PostcodeResolverInterface + { + if ($resolver instanceof PostcodeResolverInterface) { + return $resolver; + } + + $instance = app()->make($resolver); + + if (! $instance instanceof PostcodeResolverInterface) { + throw new \InvalidArgumentException(sprintf( + 'Postcode resolver [%s] must implement %s.', + $resolver, + PostcodeResolverInterface::class + )); + } + + $this->resolvers->put($index, $instance); + + return $instance; + } +} diff --git a/packages/table-rate-shipping/src/Resolvers/PostcodeResolver.php b/packages/table-rate-shipping/src/Resolvers/PostcodeResolver.php index 0bfc347160..e47e7fcbeb 100644 --- a/packages/table-rate-shipping/src/Resolvers/PostcodeResolver.php +++ b/packages/table-rate-shipping/src/Resolvers/PostcodeResolver.php @@ -3,11 +3,26 @@ namespace Lunar\Shipping\Resolvers; use Illuminate\Support\Collection; +use Lunar\Models\Contracts\Country as CountryContract; use Lunar\Shipping\Interfaces\PostcodeResolverInterface; class PostcodeResolver implements PostcodeResolverInterface { - public function getParts($postcode): Collection + /** + * ISO-2 country codes this resolver handles. An empty array matches every country, + * making this resolver a safe catch-all when registered first. + * + * @var array + */ + protected array $countries = []; + + public function supportsCountry(CountryContract $country): bool + { + return empty($this->countries) + || in_array($country->iso2, $this->countries, true); + } + + public function getParts(string $postcode, CountryContract $country): Collection { $postcode = str_replace(' ', '', strtoupper($postcode)); diff --git a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php index 011280d632..4a374fc5a4 100644 --- a/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php +++ b/packages/table-rate-shipping/src/Resolvers/ShippingZoneResolver.php @@ -100,12 +100,7 @@ public function get(): Collection if ($this->postcodeLookup) { $builder->orWhere(function ($qb) { $qb->whereHas('postcodes', function ($query) { - $postcodeResolver = config('lunar.shipping-tables.resolvers.postcode', PostcodeResolver::class); - - $postcodeParts = (new $postcodeResolver)->getParts( - $this->postcodeLookup->postcode - ); - $query->whereIn('postcode', $postcodeParts); + $query->whereIn('postcode', $this->postcodeLookup->getParts()); })->where(function ($qb) { $qb->whereHas('countries', function ($query) { $query->where('country_id', $this->postcodeLookup->country->id); diff --git a/packages/table-rate-shipping/src/ShippingServiceProvider.php b/packages/table-rate-shipping/src/ShippingServiceProvider.php index b0116b5072..7ed93f7309 100644 --- a/packages/table-rate-shipping/src/ShippingServiceProvider.php +++ b/packages/table-rate-shipping/src/ShippingServiceProvider.php @@ -17,6 +17,7 @@ use Lunar\Shipping\Database\State\MigrateCutoffToSchedule; use Lunar\Shipping\DiscountTypes\ShippingDiscount; use Lunar\Shipping\Interfaces\ShippingMethodManagerInterface; +use Lunar\Shipping\Managers\PostcodeManager; use Lunar\Shipping\Managers\ShippingManager; use Lunar\Shipping\Models\ShippingExclusion; use Lunar\Shipping\Models\ShippingExclusionList; @@ -25,12 +26,20 @@ use Lunar\Shipping\Models\ShippingZone; use Lunar\Shipping\Models\ShippingZonePostcode; use Lunar\Shipping\Observers\OrderObserver; +use Lunar\Shipping\Resolvers\PostcodeResolver; class ShippingServiceProvider extends ServiceProvider { public function register() { $this->mergeConfigFrom(__DIR__.'/../config/shipping-tables.php', 'lunar.shipping-tables'); + + $this->app->singleton(PostcodeManager::class, function () { + $manager = new PostcodeManager; + $manager->addResolver(PostcodeResolver::class); + + return $manager; + }); } public function boot(ShippingModifiers $shippingModifiers) diff --git a/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php b/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php index 5a8e1656bc..3c586d0dd8 100644 --- a/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php +++ b/tests/shipping/Stubs/Resolvers/TestCustomPostcodeResolver.php @@ -3,11 +3,25 @@ namespace Lunar\Tests\Shipping\Stubs\Resolvers; use Illuminate\Support\Collection; +use Lunar\Models\Contracts\Country as CountryContract; use Lunar\Shipping\Interfaces\PostcodeResolverInterface; class TestCustomPostcodeResolver implements PostcodeResolverInterface { - public function getParts($postcode): Collection + /** + * ISO-2 codes this test resolver claims. Override via subclass if you need a different set. + * + * @var array + */ + protected array $countries = []; + + public function supportsCountry(CountryContract $country): bool + { + return empty($this->countries) + || in_array($country->iso2, $this->countries, true); + } + + public function getParts(string $postcode, CountryContract $country): Collection { $postcode = str_replace(' ', '', strtoupper($postcode)); diff --git a/tests/shipping/Unit/DataTransferObjects/PostcodeLookupTest.php b/tests/shipping/Unit/DataTransferObjects/PostcodeLookupTest.php new file mode 100644 index 0000000000..5987e31883 --- /dev/null +++ b/tests/shipping/Unit/DataTransferObjects/PostcodeLookupTest.php @@ -0,0 +1,38 @@ +group('shipping', 'shipping-postcode'); + +uses(RefreshDatabase::class); + +test('getParts delegates to the resolver matched for the lookup country', function () { + $country = Country::factory()->create(['iso2' => 'GB']); + + $stubbed = new class implements PostcodeResolverInterface + { + public function supportsCountry(CountryContract $country): bool + { + return $country->iso2 === 'GB'; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect([sprintf('STUB:%s:%s', $country->iso2, $postcode)]); + } + }; + + Postcode::addResolver($stubbed); + + $lookup = new PostcodeLookup($country, 'SW1A 1AA'); + + expect($lookup->getParts()->all())->toBe(['STUB:GB:SW1A 1AA']); +}); diff --git a/tests/shipping/Unit/Managers/PostcodeManagerTest.php b/tests/shipping/Unit/Managers/PostcodeManagerTest.php new file mode 100644 index 0000000000..5e1c4376f3 --- /dev/null +++ b/tests/shipping/Unit/Managers/PostcodeManagerTest.php @@ -0,0 +1,124 @@ +group('shipping', 'shipping-postcode'); +uses(RefreshDatabase::class); + +test('addResolver pushes the resolver onto the collection', function () { + $manager = new PostcodeManager; + + $manager->addResolver(PostcodeResolver::class); + + expect($manager->getResolvers())->toBeInstanceOf(Collection::class); + expect($manager->getResolvers())->toHaveCount(1); +}); + +test('addResolver returns the manager for fluent chaining', function () { + $manager = new PostcodeManager; + + expect($manager->addResolver(PostcodeResolver::class))->toBe($manager); +}); + +test('country returns the last-registered matching resolver', function () { + $gb = Country::factory()->create(['iso2' => 'GB']); + + $resolverA = new class implements PostcodeResolverInterface + { + public string $label = 'A'; + + public function supportsCountry(CountryContract $country): bool + { + return $country->iso2 === 'GB'; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect([$postcode]); + } + }; + + $resolverB = new class implements PostcodeResolverInterface + { + public string $label = 'B'; + + public function supportsCountry(CountryContract $country): bool + { + return $country->iso2 === 'GB'; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect([$postcode]); + } + }; + + $manager = new PostcodeManager; + $manager->addResolver($resolverA); + $manager->addResolver($resolverB); + + expect($manager->country($gb)->label)->toBe('B'); +}); + +test('country falls through to an earlier resolver when later ones do not support it', function () { + $us = Country::factory()->create(['iso2' => 'US']); + + $gbOnly = new class implements PostcodeResolverInterface + { + public function supportsCountry(CountryContract $country): bool + { + return $country->iso2 === 'GB'; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect([$postcode]); + } + }; + + $manager = new PostcodeManager; + $manager->addResolver(PostcodeResolver::class); // catch-all, registered first + $manager->addResolver($gbOnly); // GB-only, registered last + + expect($manager->country($us))->toBeInstanceOf(PostcodeResolver::class); +}); + +test('country throws when no resolver claims the country', function () { + $fr = Country::factory()->create(['iso2' => 'FR']); + + $manager = new PostcodeManager; // no resolvers registered + + $manager->country($fr); +})->throws(NoPostcodeResolverException::class, 'FR'); + +test('country resolves class-string registrations via the container on first use', function () { + $gb = Country::factory()->create(['iso2' => 'GB']); + + $manager = new PostcodeManager; + $manager->addResolver(PostcodeResolver::class); + + $resolved = $manager->country($gb); + + expect($resolved)->toBeInstanceOf(PostcodeResolver::class); +}); + +test('country rejects a registered class that does not implement the interface', function () { + $gb = Country::factory()->create(['iso2' => 'GB']); + + $manager = new PostcodeManager; + $manager->addResolver(stdClass::class); + + $manager->country($gb); +})->throws( + InvalidArgumentException::class, + PostcodeResolverInterface::class +); diff --git a/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php b/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php index 1ca39c01a4..7846e5f888 100644 --- a/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php +++ b/tests/shipping/Unit/Resolvers/PostcodeResolverTest.php @@ -1,30 +1,44 @@ group('shipping', 'shipping-postcode'); +uses(TestCase::class) + ->group('shipping', 'shipping-postcode'); +uses(RefreshDatabase::class); -test('can get postcode query parts', function () { - $postcode = 'ABC 123'; +test('splits a UK postcode into queryable parts', function () { + $country = Country::factory()->create(['iso2' => 'GB']); - $parts = (new PostcodeResolver)->getParts($postcode); + $parts = (new PostcodeResolver)->getParts('SW1A 1AA', $country); - expect($parts)->toContain('ABC123'); - expect($parts)->toContain('ABC'); - expect($parts)->toContain('AB'); + expect($parts)->toContain('SW1A1AA'); + expect($parts)->toContain('SW1'); + expect($parts)->toContain('SW'); + expect($parts)->toContain('S'); +}); + +test('supportsCountry returns true for every country when the countries array is empty', function () { + $gb = Country::factory()->create(['iso2' => 'GB']); + $us = Country::factory()->create(['iso2' => 'US']); - $postcode = 'NW1 1TX'; + $resolver = new PostcodeResolver; - $parts = (new PostcodeResolver)->getParts($postcode); + expect($resolver->supportsCountry($gb))->toBeTrue(); + expect($resolver->supportsCountry($us))->toBeTrue(); +}); - expect($parts)->toContain('NW11TX'); - expect($parts)->toContain('NW1'); - expect($parts)->toContain('NW'); +test('supportsCountry restricts to listed iso2 codes when the property is set on a subclass', function () { + $gb = Country::factory()->create(['iso2' => 'GB']); + $us = Country::factory()->create(['iso2' => 'US']); - $postcode = 90210; + $resolver = new class extends PostcodeResolver + { + protected array $countries = ['GB']; + }; - $parts = (new PostcodeResolver)->getParts($postcode); - expect($parts)->toContain('90210'); - expect($parts)->toContain('90'); + expect($resolver->supportsCountry($gb))->toBeTrue(); + expect($resolver->supportsCountry($us))->toBeFalse(); }); diff --git a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php index 1080c2acb0..bc241241cd 100644 --- a/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php +++ b/tests/shipping/Unit/Resolvers/ShippingZoneResolverTest.php @@ -1,10 +1,14 @@ group('shipping', 'shipping-zone'); - uses(RefreshDatabase::class); +// Reset the PostcodeManager singleton between tests so facade-registered resolvers from one +// test don't leak into the next. The service provider's register() closure re-runs on next +// resolve, rebuilding the manager with only the default PostcodeResolver. +beforeEach(function () { + $this->app->forgetInstance(PostcodeManager::class); +}); + test('can fetch shipping zones by country', function () { $countryA = Country::factory()->create(); $countryB = Country::factory()->create(); @@ -115,7 +125,7 @@ expect($zones->first()->id)->toEqual($shippingZone->id); }); -test('can use custom postcode resolver', function () { +test('a resolver registered via the Postcode facade takes precedence over the default', function () { $country = Country::factory()->create(); $shippingZone = ShippingZone::factory()->create([ @@ -124,35 +134,106 @@ $shippingZone->countries()->attach($country); - $shippingZone->postcodes()->createMany([[ - 'postcode' => '390*', - ], [ - 'postcode' => '391*', - ]]); + $shippingZone->postcodes()->createMany([ + ['postcode' => '390*'], + ['postcode' => '391*'], + ]); - $shippingZone2 = ShippingZone::factory()->create([ + $unmatchedZone = ShippingZone::factory()->create([ 'type' => 'postcodes', ]); - $shippingZone2->countries()->attach($country); + $unmatchedZone->countries()->attach($country); - $shippingZone2->postcodes()->create([ + $unmatchedZone->postcodes()->create([ 'postcode' => '393*', ]); - expect($shippingZone->refresh()->countries)->toHaveCount(1); - expect($shippingZone->refresh()->postcodes)->toHaveCount(2); + Postcode::addResolver(TestCustomPostcodeResolver::class); - $postcode = new PostcodeLookup( - $country, - '39100' - ); + $zones = (new ShippingZoneResolver) + ->postcode(new PostcodeLookup($country, '39100')) + ->get(); - Config::set('lunar.shipping-tables.resolvers.postcode', TestCustomPostcodeResolver::class); + expect($zones)->toHaveCount(1); + expect($zones->first()->id)->toEqual($shippingZone->id); +})->group('shipping-postcode'); - $zones = (new ShippingZoneResolver)->postcode($postcode)->get(); +test('the last-registered matching resolver wins over earlier custom resolvers', function () { + $country = Country::factory()->create(); + + $shippingZone = ShippingZone::factory()->create([ + 'type' => 'postcodes', + ]); + $shippingZone->countries()->attach($country); + $shippingZone->postcodes()->create(['postcode' => 'LAST-WON']); + + $firstCustom = new class implements PostcodeResolverInterface + { + public function supportsCountry(CountryContract $country): bool + { + return true; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect(['FIRST-WON']); + } + }; + + $secondCustom = new class implements PostcodeResolverInterface + { + public function supportsCountry(CountryContract $country): bool + { + return true; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect(['LAST-WON']); + } + }; + + Postcode::addResolver($firstCustom); + Postcode::addResolver($secondCustom); + + $zones = (new ShippingZoneResolver) + ->postcode(new PostcodeLookup($country, 'irrelevant')) + ->get(); expect($zones)->toHaveCount(1); + expect($zones->first()->id)->toEqual($shippingZone->id); +})->group('shipping-postcode'); +test('a resolver whose supportsCountry returns false is skipped and the default handles the lookup', function () { + $country = Country::factory()->create(['iso2' => 'GB']); + + $shippingZone = ShippingZone::factory()->create([ + 'type' => 'postcodes', + ]); + $shippingZone->countries()->attach($country); + // Default resolver's UK parsing will produce 'SW1A', 'SW', 'S' etc. Match on 'SW'. + $shippingZone->postcodes()->create(['postcode' => 'SW']); + + $usOnly = new class implements PostcodeResolverInterface + { + public function supportsCountry(CountryContract $country): bool + { + return $country->iso2 === 'US'; + } + + public function getParts(string $postcode, CountryContract $country): Collection + { + return collect(['NEVER-CALLED']); + } + }; + + Postcode::addResolver($usOnly); + + $zones = (new ShippingZoneResolver) + ->postcode(new PostcodeLookup($country, 'SW1A 1AA')) + ->get(); + + expect($zones)->toHaveCount(1); expect($zones->first()->id)->toEqual($shippingZone->id); })->group('shipping-postcode'); diff --git a/tests/shipping/Unit/ShippingServiceProviderTest.php b/tests/shipping/Unit/ShippingServiceProviderTest.php new file mode 100644 index 0000000000..8d62c3f7b1 --- /dev/null +++ b/tests/shipping/Unit/ShippingServiceProviderTest.php @@ -0,0 +1,19 @@ +group('shipping', 'shipping-postcode'); + +test('the service provider binds PostcodeManager as a singleton with the default resolver pre-registered', function () { + $manager = app(PostcodeManager::class); + + expect($manager)->toBeInstanceOf(PostcodeManager::class); + expect(app(PostcodeManager::class))->toBe($manager); // singleton — same instance + + $resolvers = $manager->getResolvers(); + expect($resolvers)->toHaveCount(1); + expect($resolvers->first())->toBe(PostcodeResolver::class); +});