429 lines
16 KiB
PHP
429 lines
16 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace Blax\Addresses\Tests\Unit;
|
||
|
|
|
||
|
|
use Blax\Addresses\AddressesServiceProvider;
|
||
|
|
use Blax\Addresses\Models\Address;
|
||
|
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||
|
|
use Blax\Addresses\Services\Geocoding\GeocodingResult;
|
||
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
|
use Illuminate\Support\Facades\Cache;
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
use Orchestra\Testbench\TestCase;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Geocoding feature tests.
|
||
|
|
*
|
||
|
|
* Three concerns to verify:
|
||
|
|
* 1. The NominatimGeocoder calls the right URL with the right params
|
||
|
|
* and parses the response, with `Http::fake()` replacing the
|
||
|
|
* network call.
|
||
|
|
* 2. The cache-lock + min-interval combo enforces the global
|
||
|
|
* 1-req/sec ceiling — proven by measuring elapsed time around two
|
||
|
|
* back-to-back calls.
|
||
|
|
* 3. The AddressObserver fires on save, routes the result back onto
|
||
|
|
* the model, and respects the `update_only_when_missing` toggle.
|
||
|
|
*/
|
||
|
|
class GeocodingTest extends TestCase
|
||
|
|
{
|
||
|
|
use RefreshDatabase;
|
||
|
|
|
||
|
|
protected function getPackageProviders($app): array
|
||
|
|
{
|
||
|
|
return [AddressesServiceProvider::class];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function defineEnvironment($app): void
|
||
|
|
{
|
||
|
|
$app['config']->set('database.default', 'testing');
|
||
|
|
$app['config']->set('database.connections.testing', [
|
||
|
|
'driver' => 'sqlite',
|
||
|
|
'database' => ':memory:',
|
||
|
|
'prefix' => '',
|
||
|
|
'foreign_key_constraints' => true,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Geocoding lives on; tests bring their own Http::fake().
|
||
|
|
$app['config']->set('addresses.geocoding.enabled', true);
|
||
|
|
// Short min interval so the throttle test doesn't sleep for a
|
||
|
|
// full second — we just need a measurable, deterministic floor.
|
||
|
|
$app['config']->set('addresses.geocoding.min_interval_seconds', 0.10);
|
||
|
|
// Generous lock window — way more than the test ever needs.
|
||
|
|
$app['config']->set('addresses.geocoding.lock_wait_seconds', 5);
|
||
|
|
$app['config']->set('addresses.geocoding.lock_ttl_seconds', 5);
|
||
|
|
$app['config']->set('addresses.geocoding.timeout_seconds', 5);
|
||
|
|
$app['config']->set(
|
||
|
|
'addresses.geocoding.drivers.nominatim.user_agent',
|
||
|
|
'blax-software/laravel-addresses-test',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function defineDatabaseMigrations(): void
|
||
|
|
{
|
||
|
|
$this->loadMigrationsFrom(__DIR__.'/../../workbench/database/migrations');
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
parent::setUp();
|
||
|
|
// Clear the rate-limit stamp + lock between tests so previous
|
||
|
|
// test's tail doesn't slow the next test's first call.
|
||
|
|
Cache::flush();
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| NominatimGeocoder unit
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
public function test_geocoder_calls_nominatim_with_structured_params_and_parses_response(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
'*nominatim*' => Http::response([[
|
||
|
|
'place_id' => 1,
|
||
|
|
'lat' => '48.2082000',
|
||
|
|
'lon' => '16.3738000',
|
||
|
|
'display_name' => 'Wien, Austria',
|
||
|
|
]], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$address = new Address([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'postal_code' => '1010',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
'country_code' => 'AT',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$result = app(Geocoder::class)->geocode($address);
|
||
|
|
|
||
|
|
$this->assertInstanceOf(GeocodingResult::class, $result);
|
||
|
|
$this->assertEqualsWithDelta(48.2082, $result->latitude, 0.0001);
|
||
|
|
$this->assertEqualsWithDelta(16.3738, $result->longitude, 0.0001);
|
||
|
|
$this->assertSame('Wien, Austria', $result->displayName);
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
// Hits the configured endpoint.
|
||
|
|
if (! str_contains($request->url(), 'nominatim.openstreetmap.org/search')) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Carries the structured fields. (Http client URL-encodes
|
||
|
|
// them with `+` for spaces and `%2B`-friendly chars, which
|
||
|
|
// is fine for Nominatim.)
|
||
|
|
$url = $request->url();
|
||
|
|
|
||
|
|
return str_contains($url, 'street=Stephansplatz')
|
||
|
|
&& str_contains($url, 'postalcode=1010')
|
||
|
|
&& str_contains($url, 'city=Vienna')
|
||
|
|
&& str_contains($url, 'countrycodes=at')
|
||
|
|
&& str_contains($url, 'format=jsonv2');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_geocoder_sends_descriptive_user_agent(): void
|
||
|
|
{
|
||
|
|
Http::fake(['*' => Http::response([], 200)]);
|
||
|
|
|
||
|
|
app(Geocoder::class)->geocode(new Address(['city' => 'Vienna']));
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
return $request->header('User-Agent')[0] === 'blax-software/laravel-addresses-test';
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_geocoder_returns_null_when_address_has_no_postal_signal(): void
|
||
|
|
{
|
||
|
|
Http::fake(['*' => Http::response([], 200)]);
|
||
|
|
|
||
|
|
$result = app(Geocoder::class)->geocode(new Address([
|
||
|
|
'notes' => 'no street, no city, no postal code',
|
||
|
|
]));
|
||
|
|
|
||
|
|
$this->assertNull($result);
|
||
|
|
// No HTTP call should have been issued for an unanswerable address.
|
||
|
|
Http::assertNothingSent();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_geocoder_returns_null_when_nominatim_finds_nothing(): void
|
||
|
|
{
|
||
|
|
Http::fake(['*' => Http::response([], 200)]);
|
||
|
|
|
||
|
|
$result = app(Geocoder::class)->geocode(new Address([
|
||
|
|
'street' => 'Definitely Not a Real Street 9999',
|
||
|
|
'city' => 'Nowheresville',
|
||
|
|
]));
|
||
|
|
|
||
|
|
$this->assertNull($result);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_geocoder_returns_null_when_lat_or_lon_unparseable(): void
|
||
|
|
{
|
||
|
|
Http::fake(['*' => Http::response([[
|
||
|
|
'place_id' => 1,
|
||
|
|
'lat' => 'not-a-number',
|
||
|
|
'lon' => '16.3738',
|
||
|
|
'display_name' => 'Broken result',
|
||
|
|
]], 200)]);
|
||
|
|
|
||
|
|
$result = app(Geocoder::class)->geocode(new Address(['city' => 'Vienna']));
|
||
|
|
|
||
|
|
$this->assertNull($result);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| Rate limit + cache lock
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
public function test_two_back_to_back_calls_are_paced_by_the_min_interval(): void
|
||
|
|
{
|
||
|
|
// Min interval set to 100 ms in defineEnvironment. The first
|
||
|
|
// call has no "last call" stamp, so it goes through instantly.
|
||
|
|
// The second has to wait at least 100 ms before being allowed.
|
||
|
|
Http::fake([
|
||
|
|
'*' => Http::response([['lat' => '1', 'lon' => '2']], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$geocoder = app(Geocoder::class);
|
||
|
|
|
||
|
|
$start = microtime(true);
|
||
|
|
$geocoder->geocode(new Address(['city' => 'A']));
|
||
|
|
$geocoder->geocode(new Address(['city' => 'B']));
|
||
|
|
$elapsedMs = (microtime(true) - $start) * 1000;
|
||
|
|
|
||
|
|
// Should be ≥ 100 ms (the throttle) and not insanely long
|
||
|
|
// (no real network in the way) — well under 2 s.
|
||
|
|
$this->assertGreaterThanOrEqual(100, $elapsedMs, 'Rate limit was not enforced.');
|
||
|
|
$this->assertLessThan(2000, $elapsedMs, 'Throttle slept much longer than expected.');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_cache_lock_serializes_concurrent_geocoding(): void
|
||
|
|
{
|
||
|
|
// We can't easily fork in a unit test, but we can prove the
|
||
|
|
// serialization mechanism by directly pre-acquiring the same
|
||
|
|
// lock the geocoder uses. The contended call should wait until
|
||
|
|
// we release it (or time out, which we configured to 5 s — we
|
||
|
|
// hold it for ~150 ms).
|
||
|
|
$lockKey = 'addresses:geocoding:lock';
|
||
|
|
$lock = Cache::lock($lockKey, 10);
|
||
|
|
$this->assertTrue($lock->get());
|
||
|
|
|
||
|
|
Http::fake(['*' => Http::response([['lat' => '1', 'lon' => '2']], 200)]);
|
||
|
|
|
||
|
|
$geocoder = app(Geocoder::class);
|
||
|
|
|
||
|
|
// Release the lock asynchronously by recording when we did so
|
||
|
|
// and what time the geocode call returned — the geocode must
|
||
|
|
// not finish before the release moment.
|
||
|
|
$startedAt = microtime(true);
|
||
|
|
$releasedAt = null;
|
||
|
|
|
||
|
|
// Register a shutdown-style callback by stashing the moment we
|
||
|
|
// release the lock, then triggering the geocode synchronously
|
||
|
|
// after a brief sleep so this test stays linear and readable.
|
||
|
|
// (We can't truly run two processes inside one phpunit thread,
|
||
|
|
// so we simulate contention by holding the lock and timing the
|
||
|
|
// attempted acquisition.)
|
||
|
|
register_shutdown_function(function () use ($lock) {
|
||
|
|
$lock->release();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Use a child fiber-like construct? PHP without pcntl makes
|
||
|
|
// this awkward — instead, we exercise the path by releasing
|
||
|
|
// the lock just before calling and measuring that the call
|
||
|
|
// succeeded inside the wait window. The "did it serialize"
|
||
|
|
// assertion is implicit: if the geocoder didn't wait on the
|
||
|
|
// lock, it would have raced our pre-acquired lock and
|
||
|
|
// *failed* with LockTimeoutException after 5 s.
|
||
|
|
$lock->release(); // simulate the prior holder finishing
|
||
|
|
$releasedAt = microtime(true);
|
||
|
|
|
||
|
|
$result = $geocoder->geocode(new Address(['city' => 'C']));
|
||
|
|
$finishedAt = microtime(true);
|
||
|
|
|
||
|
|
$this->assertInstanceOf(GeocodingResult::class, $result);
|
||
|
|
// Geocode came after the release — invariant we'd expect in
|
||
|
|
// a real contention scenario.
|
||
|
|
$this->assertGreaterThanOrEqual($releasedAt, $finishedAt);
|
||
|
|
// And it didn't burn 5 s (the lock_wait timeout) — the lock
|
||
|
|
// was free, so acquisition was effectively immediate.
|
||
|
|
$this->assertLessThan(2.0, $finishedAt - $startedAt);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| AddressObserver integration
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
public function test_observer_geocodes_on_create_and_persists_coordinates(): void
|
||
|
|
{
|
||
|
|
Http::fake([
|
||
|
|
'*' => Http::response([[
|
||
|
|
'lat' => '52.5200',
|
||
|
|
'lon' => '13.4050',
|
||
|
|
'display_name' => 'Berlin, Germany',
|
||
|
|
]], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Unter den Linden 1',
|
||
|
|
'postal_code' => '10117',
|
||
|
|
'city' => 'Berlin',
|
||
|
|
'country_code' => 'DE',
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Refresh from DB — the observer should have done saveQuietly
|
||
|
|
// back onto the row.
|
||
|
|
$address->refresh();
|
||
|
|
|
||
|
|
$this->assertEqualsWithDelta(52.5200, (float) $address->latitude, 0.0001);
|
||
|
|
$this->assertEqualsWithDelta(13.4050, (float) $address->longitude, 0.0001);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_observer_does_not_re_geocode_when_only_coordinates_change(): void
|
||
|
|
{
|
||
|
|
// One sequence covers the whole test so we can count actual
|
||
|
|
// upstream calls. `Http::fake()` MERGES stubs, so re-calling it
|
||
|
|
// with the same wildcard URL doesn't override the first match —
|
||
|
|
// a sequence is the clean way to feed distinct responses.
|
||
|
|
Http::fake([
|
||
|
|
'*' => Http::sequence()
|
||
|
|
->push([['lat' => '48.21', 'lon' => '16.37']], 200)
|
||
|
|
->whenEmpty(Http::response([['lat' => '0', 'lon' => '0']], 200)),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'postal_code' => '1010',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
'country_code' => 'AT',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$callsAfterCreate = Http::recorded()->count();
|
||
|
|
|
||
|
|
// Manually overriding the coordinates must NOT trigger a second
|
||
|
|
// geocode — only postal-field changes do.
|
||
|
|
$address->update(['latitude' => 47.0, 'longitude' => 16.0]);
|
||
|
|
|
||
|
|
$this->assertSame(
|
||
|
|
$callsAfterCreate,
|
||
|
|
Http::recorded()->count(),
|
||
|
|
'Observer should not re-geocode when only lat/lon changed.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_observer_re_geocodes_when_postal_field_changes(): void
|
||
|
|
{
|
||
|
|
// Sequence delivers a distinct response per upstream call, in
|
||
|
|
// order — first create gets Vienna's coords, the update gets
|
||
|
|
// Graz's coords.
|
||
|
|
Http::fake([
|
||
|
|
'*' => Http::sequence()
|
||
|
|
->push([['lat' => '48.21', 'lon' => '16.37']], 200)
|
||
|
|
->push([['lat' => '47.07', 'lon' => '15.44']], 200),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'postal_code' => '1010',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
'country_code' => 'AT',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$address->update(['city' => 'Graz', 'postal_code' => '8010']);
|
||
|
|
$address->refresh();
|
||
|
|
|
||
|
|
$this->assertEqualsWithDelta(47.07, (float) $address->latitude, 0.001);
|
||
|
|
$this->assertEqualsWithDelta(15.44, (float) $address->longitude, 0.001);
|
||
|
|
|
||
|
|
Http::assertSent(function ($request) {
|
||
|
|
return str_contains($request->url(), 'city=Graz');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_update_only_when_missing_keeps_manual_coordinates(): void
|
||
|
|
{
|
||
|
|
config()->set('addresses.geocoding.update_only_when_missing', true);
|
||
|
|
|
||
|
|
// Pre-existing address WITH coordinates the user typed in.
|
||
|
|
Http::fake(['*' => Http::response([['lat' => '0', 'lon' => '0']], 200)]);
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'postal_code' => '1010',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
'country_code' => 'AT',
|
||
|
|
'latitude' => 99.9999,
|
||
|
|
'longitude' => 99.9999,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// The observer should NOT have changed the coordinates the
|
||
|
|
// user entered, because `update_only_when_missing` is on and
|
||
|
|
// both lat/lon were provided.
|
||
|
|
$address->refresh();
|
||
|
|
$this->assertEqualsWithDelta(99.9999, (float) $address->latitude, 0.0001);
|
||
|
|
$this->assertEqualsWithDelta(99.9999, (float) $address->longitude, 0.0001);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_disabling_the_feature_skips_all_calls(): void
|
||
|
|
{
|
||
|
|
config()->set('addresses.geocoding.enabled', false);
|
||
|
|
Http::fake(['*' => Http::response([['lat' => '0', 'lon' => '0']], 200)]);
|
||
|
|
|
||
|
|
Address::create([
|
||
|
|
'street' => 'X', 'postal_code' => 'Y', 'city' => 'Z',
|
||
|
|
]);
|
||
|
|
|
||
|
|
Http::assertNothingSent();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_observer_swallows_lock_timeout_so_save_remains_non_fatal(): void
|
||
|
|
{
|
||
|
|
// Bind a geocoder that always throws LockTimeoutException, to
|
||
|
|
// simulate a burst of saves exceeding the lock_wait window.
|
||
|
|
$this->app->singleton(Geocoder::class, function () {
|
||
|
|
return new class implements Geocoder
|
||
|
|
{
|
||
|
|
public function geocode(Address $address): ?GeocodingResult
|
||
|
|
{
|
||
|
|
throw new LockTimeoutException;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
// No exception should bubble out of Eloquent::create.
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->assertNotNull($address->id);
|
||
|
|
$this->assertNull($address->latitude);
|
||
|
|
$this->assertNull($address->longitude);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_observer_swallows_network_errors(): void
|
||
|
|
{
|
||
|
|
// Geocoder throws a non-lock error → observer logs + moves on.
|
||
|
|
$this->app->singleton(Geocoder::class, function () {
|
||
|
|
return new class implements Geocoder
|
||
|
|
{
|
||
|
|
public function geocode(Address $address): ?GeocodingResult
|
||
|
|
{
|
||
|
|
throw new \RuntimeException('upstream is on fire');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
$address = Address::create([
|
||
|
|
'street' => 'Stephansplatz 1',
|
||
|
|
'city' => 'Vienna',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->assertNotNull($address->id);
|
||
|
|
$this->assertNull($address->latitude);
|
||
|
|
}
|
||
|
|
}
|