From d7abc0e66e7c0f09918b9c119c9727fa3b74c849 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 08:49:46 +0200 Subject: [PATCH 1/7] Load tests bootstrap from any working directory --- tests/TestsBootstrap.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/TestsBootstrap.php b/tests/TestsBootstrap.php index 0c5cfcc..cc24ed0 100644 --- a/tests/TestsBootstrap.php +++ b/tests/TestsBootstrap.php @@ -9,13 +9,13 @@ declare(strict_types=1); /** - * Bootstrap tests file for Galette Auto plugin + * Bootstrap tests file for Galette Maps plugin * * @author Johan Cwiklinski */ define('GALETTE_PLUGINS_PATH', __DIR__ . '/../../'); -$basepath = '../../../galette/'; // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable -- used from Core testBootstrap +$basepath = __DIR__ . '/../../../'; // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable -- used from Core testBootstrap -include_once '../../../tests/TestsBootstrap.php'; +include_once __DIR__ . '/../../../../tests/TestsBootstrap.php'; require_once __DIR__ . '/../_config.inc.php'; From 543700ea120425012338cbc514f6e033f5c4bfa1 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 08:50:13 +0200 Subject: [PATCH 2/7] Raise PHPStan to level 6, check tests CS --- .github/workflows/ci-linux.yml | 2 +- phpstan.neon | 3 ++- tests/GaletteMaps/tests/units/NominatimTowns.php | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 53551e7..1620117 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -89,7 +89,7 @@ jobs: - name: CS run: | cd galette-core/galette/plugins/plugin-maps - ../../vendor/bin/phpcs lib/ ./*.php + ../../vendor/bin/phpcs lib/ tests/ ./*.php - name: CS Fixer if: matrix.php-is-min diff --git a/phpstan.neon b/phpstan.neon index 07a6ed4..a266ae2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,9 +1,10 @@ parameters: parallel: maximumNumberOfProcesses: 2 - level: 3 + level: 6 paths: - lib/ + - tests/ scanFiles: - _config.inc.php - ../../includes/sys_config/paths.inc.php diff --git a/tests/GaletteMaps/tests/units/NominatimTowns.php b/tests/GaletteMaps/tests/units/NominatimTowns.php index 8e83743..c0619bc 100644 --- a/tests/GaletteMaps/tests/units/NominatimTowns.php +++ b/tests/GaletteMaps/tests/units/NominatimTowns.php @@ -10,6 +10,7 @@ namespace GaletteMaps\tests\units; +use Analog\Analog; use Galette\Tests\GaletteTestCase; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; @@ -61,8 +62,8 @@ public function testSearch(): void ], $search->search('Valenciennes', 'France') ); - $this->expectLogEntry(\Analog::INFO, 'Town is already in list, ignore.'); - $this->expectLogEntry(\Analog::INFO, 'Nominatim result "Rue de Valenciennes" is not a town'); + $this->expectLogEntry(Analog::INFO, 'Town is already in list, ignore.'); + $this->expectLogEntry(Analog::INFO, 'Nominatim result "Rue de Valenciennes" is not a town'); $this->assertCount(1, $this->requests); /** @var Request $request */ From 84ea3e1aecd5c3d6d35cb6ac55d7aac7888838ba Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 08:54:54 +0200 Subject: [PATCH 3/7] Test plugin menus, dashboards and actions --- .../tests/units/PluginGaletteMaps.php | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/GaletteMaps/tests/units/PluginGaletteMaps.php diff --git a/tests/GaletteMaps/tests/units/PluginGaletteMaps.php b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php new file mode 100644 index 0000000..b80ec41 --- /dev/null +++ b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php @@ -0,0 +1,120 @@ + + */ +class PluginGaletteMaps extends GaletteTestCase +{ + protected int $seed = 20260925101512; + + /** + * Cleanup after each test method + */ + public function tearDown(): void + { + $this->login->logout(); + parent::tearDown(); + } + + /** + * Get plugin instance + */ + private function getPlugin(): \GaletteMaps\PluginGaletteMaps + { + return $this->container->get(\GaletteMaps\PluginGaletteMaps::class); + } + + /** + * Get routes names of menus entries + * + * @param array $menus Menus + * + * @return array> + */ + private function getMenusRoutes(array $menus): array + { + $routes = []; + foreach ($menus as $section => $menu) { + $routes[$section] = array_map( + fn(array $item): string => $item['route']['name'], + $menu['items'] + ); + } + return $routes; + } + + /** + * Menus depend on logged-in user + */ + public function testMenus(): void + { + $plugin = $this->getPlugin(); + $this->assertSame([], $plugin->getMenus()); + + $this->getMemberOne(); + $this->assertTrue($this->login->login($this->dataAdherentOne()['login_adh'], $this->dataAdherentOne()['mdp_adh'])); + $this->assertSame(['myaccount' => ['maps_mymap']], $this->getMenusRoutes($plugin->getMenus())); + $this->login->logout(); + + //superadmin does not live anywhere + $this->logSuperAdmin(); + $this->assertSame(['configuration' => ['maps_preferences']], $this->getMenusRoutes($plugin->getMenus())); + + $public = $plugin->getPublicMenus(); + $this->assertCount(1, $public); + $this->assertSame('maps_map', $public[0]['route']['name']); + } + + /** + * Dashboards and actions + */ + public function testDashboardsAndActions(): void + { + $plugin = $this->getPlugin(); + $member = $this->getMemberOne(); + + $this->logSuperAdmin(); + $this->assertSame([], $plugin->getMyDashboards()); + $this->assertSame([], $plugin->getDashboards()); + $this->assertSame([], $plugin->getBatchActions()); + $this->login->logout(); + + $this->assertTrue($this->login->login($this->dataAdherentOne()['login_adh'], $this->dataAdherentOne()['mdp_adh'])); + $dashboards = $plugin->getMyDashboards(); + $this->assertCount(1, $dashboards); + $this->assertSame( + ['name' => 'maps_localize_member', 'args' => ['id' => $member->id]], + $dashboards[0]['route'] + ); + + $actions = $plugin->getListActions($member); + $this->assertCount(1, $actions); + $this->assertSame( + ['name' => 'maps_localize_member', 'args' => ['id' => $member->id]], + $actions[0]['route'] + ); + $this->assertSame($actions, $plugin->getDetailedActions($member)); + } + + /** + * Plugin is installed in tests database + */ + public function testIsInstalled(): void + { + $this->assertTrue($this->getPlugin()->isInstalled()); + } +} From 98ec43e2ecea3a485eb71e7af4cf097e03d0a00d Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 08:54:54 +0200 Subject: [PATCH 4/7] Redirect superadmin and missing members localization instead of failing --- .../Controllers/MapsController.php | 35 ++++++++++++++-- .../tests/units/MapsController.php | 41 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/lib/GaletteMaps/Controllers/MapsController.php b/lib/GaletteMaps/Controllers/MapsController.php index 804b01c..f14c709 100644 --- a/lib/GaletteMaps/Controllers/MapsController.php +++ b/lib/GaletteMaps/Controllers/MapsController.php @@ -48,6 +48,20 @@ private function getMemberDeps(): array ]; } + /** + * Message for a member that does not exist + * + * @param int $id Requested member ID + */ + private function getNoMemberMessage(int $id): string + { + return sprintf( + //TRANS: parameter is the member identifier + _T('No member #%1$s.'), + $id + ); + } + /** * Main route * @@ -91,10 +105,22 @@ public function map(Request $request, Response $response): Response */ public function localizeMember(Request $request, Response $response, ?int $id = null): Response { - if ($id === null) { - $id = (int)$this->login->id; + if ($id === null && $this->login->isSuperAdmin()) { + return $this->redirectWithErrors( + response: $response, + errors: [_T('Superadmin cannot be localized.', 'maps')], + redirect_url: $this->routeparser->urlFor('slash') + ); } + $id ??= (int)$this->login->id; $member = new Adherent($this->zdb, $id, $this->getMemberDeps()); + if ($member->id === null) { + return $this->redirectWithErrors( + response: $response, + errors: [$this->getNoMemberMessage($id)], + redirect_url: $this->routeparser->urlFor('slash') + ); + } if (!$member->canShow($this->login)) { Analog::log( @@ -273,7 +299,10 @@ public function ILiveHere(Request $request, Response $response, ?int $id = null) } else { $id ??= (int)$this->login->id; $member = new Adherent($this->zdb, $id, $this->getMemberDeps()); - if (!$member->canEdit($this->login)) { + if ($member->id === null) { + $error = $this->getNoMemberMessage($id); + $status = 404; + } elseif (!$member->canEdit($this->login)) { Analog::log( 'Logged in member ' . $this->login->login . ' has tried to change coordinates of member #' . $id diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index a7596b1..1d5dca6 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -305,6 +305,47 @@ public function testMapEscapesNames(): void $this->assertStringContainsString('\u0026lt\u003Bimg\u0020src', $body); } + /** + * Superadmin has no own localization page + */ + public function testSuperAdminOwnPage(): void + { + $this->logSuperAdmin(); + $test_response = $this->app->handle($this->createRequest('maps_mymap')); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('slash')]], + $test_response->getHeaders() + ); + $this->assertSame(301, $test_response->getStatusCode()); + $this->expectFlashData(['error_detected' => ['Superadmin cannot be localized.']]); + } + + /** + * Localization of a member that does not exist + */ + public function testMissingMember(): void + { + $this->logSuperAdmin(); + $test_response = $this->app->handle($this->createRequest('maps_localize_member', ['id' => '999999'])); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('slash')]], + $test_response->getHeaders() + ); + $this->assertSame(301, $test_response->getStatusCode()); + $this->expectFlashData(['error_detected' => ['No member #999999.']]); + //logged by Adherent on load + $this->expectLogEntry(Analog::ERROR, 'No member #999999'); + + $test_response = $this->postCoords(999999); + $this->assertSame(404, $test_response->getStatusCode()); + $this->assertSame( + ['res' => false, 'message' => 'No member #999999.'], + json_decode((string)$test_response->getBody(), true) + ); + $this->expectLogEntry(Analog::ERROR, 'No member #999999'); + $this->expectNoLogEntry(); + } + /** * Superadmin changes coordinates of any member, but has none */ From 528b8787191b23214cf554153304e4f4d3712732 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 08:55:27 +0200 Subject: [PATCH 5/7] Fetch plugin again after login in dashboards test --- tests/GaletteMaps/tests/units/PluginGaletteMaps.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/GaletteMaps/tests/units/PluginGaletteMaps.php b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php index b80ec41..7315d9f 100644 --- a/tests/GaletteMaps/tests/units/PluginGaletteMaps.php +++ b/tests/GaletteMaps/tests/units/PluginGaletteMaps.php @@ -94,6 +94,8 @@ public function testDashboardsAndActions(): void $this->login->logout(); $this->assertTrue($this->login->login($this->dataAdherentOne()['login_adh'], $this->dataAdherentOne()['mdp_adh'])); + //fetched again, or PHPStan keeps the empty result asserted above + $plugin = $this->getPlugin(); $dashboards = $plugin->getMyDashboards(); $this->assertCount(1, $dashboards); $this->assertSame( From a5d1ba8a8530074a25acbc13e138131e8a54b8db Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 09:02:38 +0200 Subject: [PATCH 6/7] Test public map, own localization page and preferences access --- .../tests/units/MapsController.php | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index 1d5dca6..8c9ae3c 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -32,6 +32,7 @@ public function tearDown(): void { $this->login->logout(); $this->preferences->pref_bool_groupsmanagers_edit_member = false; + $this->preferences->pref_bool_publicpages = true; $this->zdb->execute($this->zdb->delete(MAPS_PREFIX . Coordinates::TABLE)); parent::tearDown(); } @@ -305,6 +306,81 @@ public function testMapEscapesNames(): void $this->assertStringContainsString('\u0026lt\u003Bimg\u0020src', $body); } + /** + * Map is displayed to visitors only when public pages allow it + */ + public function testPublicMap(): void + { + $member_one = $this->getMemberOne(); + $this->assertTrue((new Coordinates())->setCoords($member_one->id, 48.85, 2.35)); + $request = $this->createRequest('maps_map'); + + $this->preferences->pref_bool_publicpages = false; + $test_response = $this->app->handle($request); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('slash')]], + $test_response->getHeaders() + ); + $this->assertSame(302, $test_response->getStatusCode()); + $this->expectFlashData(['error_detected' => ['Unauthorized']]); + + $this->preferences->pref_bool_publicpages = true; + $this->preferences->pref_publicpages_visibility_generic = \Galette\Core\Preferences::PUBLIC_PAGES_VISIBILITY_PUBLIC; + $test_response = $this->app->handle($request); + $this->assertSame(200, $test_response->getStatusCode()); + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('_mapsBinded', $body); + //member one does not display its information + $this->assertStringNotContainsString('48.850000', $body); + } + + /** + * Own localization page, with and without coordinates + */ + public function testOwnPage(): void + { + $member_one = $this->getMemberOne(); + $this->logMember($this->dataAdherentOne()); + $request = $this->createRequest('maps_mymap'); + + //no town search without a town + $update = $this->zdb->update(Adherent::TABLE); + $update->set(['ville_adh' => ''])->where([Adherent::PK => $member_one->id]); + $this->zdb->execute($update); + $test_response = $this->app->handle($request); + $this->assertSame(200, $test_response->getStatusCode()); + $body = (string)$test_response->getBody(); + $this->assertStringNotContainsString('id="possible_towns"', $body); + $this->assertStringNotContainsString('id="removecoords"', $body); + $this->assertStringContainsString('onMapClick', $body); + + $this->assertTrue((new Coordinates())->setCoords($member_one->id, 48.85, 2.35)); + $test_response = $this->app->handle($request); + $this->assertSame(200, $test_response->getStatusCode()); + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('var _lat = 48.850000;', $body); + $this->assertStringContainsString('id="removecoords"', $body); + $this->assertStringContainsString('I\\u0020live\\u0020here\\u0021', $body); + } + + /** + * Only administrators reach preferences + */ + public function testPreferencesAccess(): void + { + $this->getMemberOne(); + $this->logMember($this->dataAdherentOne()); + $this->expectAuthMiddlewareRefused($this->app->handle($this->createRequest('maps_preferences'))); + $this->login->logout(); + + $this->logSuperAdmin(); + $test_response = $this->app->handle($this->createRequest('maps_preferences')); + $this->assertSame(200, $test_response->getStatusCode()); + $body = (string)$test_response->getBody(); + $this->assertStringContainsString('id="pref_maps_tiles_provider"', $body); + $this->assertStringContainsString('OpenFreeMap, light grey', $body); + } + /** * Superadmin has no own localization page */ From 3b595d2f832613db5587883320e6e83136923e80 Mon Sep 17 00:00:00 2001 From: Johan Cwiklinski Date: Fri, 25 Sep 2026 09:02:38 +0200 Subject: [PATCH 7/7] Report every refused maps setting, not only the last one --- .../Controllers/MapsController.php | 11 ++-- .../tests/units/MapsController.php | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/GaletteMaps/Controllers/MapsController.php b/lib/GaletteMaps/Controllers/MapsController.php index f14c709..9234efd 100644 --- a/lib/GaletteMaps/Controllers/MapsController.php +++ b/lib/GaletteMaps/Controllers/MapsController.php @@ -255,18 +255,21 @@ public function storePreferences(Request $request, Response $response): Response } } - $stored = true; + //each call resets errors of the previous one + $errors = []; foreach ($values as $name => $value) { - $stored = $this->preferences->setValue($name, $value, $this->login) && $stored; + if (!$this->preferences->setValue($name, $value, $this->login)) { + $errors = array_merge($errors, $this->preferences->getErrors()); + } } - if ($stored) { + if (count($errors) === 0) { $this->flash->addMessage( 'success_detected', _T('Maps settings have been saved.', 'maps') ); } else { - foreach ($this->preferences->getErrors() as $error) { + foreach ($errors as $error) { $this->flash->addMessage('error_detected', $error); } } diff --git a/tests/GaletteMaps/Controllers/tests/units/MapsController.php b/tests/GaletteMaps/Controllers/tests/units/MapsController.php index 8c9ae3c..c46a246 100644 --- a/tests/GaletteMaps/Controllers/tests/units/MapsController.php +++ b/tests/GaletteMaps/Controllers/tests/units/MapsController.php @@ -14,6 +14,7 @@ use Galette\Entity\Adherent; use Galette\Tests\GaletteRoutingTestCase; use GaletteMaps\Coordinates; +use GaletteMaps\TileProviders; /** * Maps controller tests @@ -381,6 +382,68 @@ public function testPreferencesAccess(): void $this->assertStringContainsString('OpenFreeMap, light grey', $body); } + /** + * Store preferences + */ + public function testStorePreferences(): void + { + $this->logSuperAdmin(); + $store = function (array $data): \Psr\Http\Message\ResponseInterface { + $request = $this->createRequest('maps_store_preferences', [], 'POST')->withParsedBody($data); + $test_response = $this->app->handle($request); + $this->assertSame( + ['Location' => [$this->routeparser->urlFor('maps_preferences')]], + $test_response->getHeaders() + ); + $this->assertSame(302, $test_response->getStatusCode()); + return $test_response; + }; + + try { + $store([TileProviders::PREF_PROVIDER => 'osm']); + $this->expectFlashData(['success_detected' => ['Maps settings have been saved.']]); + $this->preferences->load(); + $this->assertSame('osm', TileProviders::resolve($this->preferences)['id']); + + //own values need an address + $store([TileProviders::PREF_PROVIDER => TileProviders::CUSTOM, TileProviders::PREF_URL => ' ']); + $this->expectFlashData(['error_detected' => ['An address is required to use your own background map.']]); + $this->preferences->load(); + $this->assertSame('osm', TileProviders::resolve($this->preferences)['id']); + + //unticked vector box is not posted + $store([ + TileProviders::PREF_PROVIDER => TileProviders::CUSTOM, + TileProviders::PREF_URL => 'https://tiles.example.org/{z}/{x}/{y}.png', + TileProviders::PREF_ATTRIBUTION => 'Example', + TileProviders::PREF_MAXZOOM => '17', + TileProviders::PREF_SUBDOMAINS => '', + ]); + $this->expectFlashData(['success_detected' => ['Maps settings have been saved.']]); + $this->preferences->load(); + $tiles = TileProviders::resolve($this->preferences); + $this->assertSame(TileProviders::CUSTOM, $tiles['id']); + $this->assertFalse($tiles['vector']); + $this->assertSame('https://tiles.example.org/{z}/{x}/{y}.png', $tiles['url']); + $this->assertSame(17, $tiles['maxzoom']); + + //out of range zoom is refused + $store([ + TileProviders::PREF_PROVIDER => TileProviders::CUSTOM, + TileProviders::PREF_URL => 'https://tiles.example.org/{z}/{x}/{y}.png', + TileProviders::PREF_MAXZOOM => '30', + ]); + //core has no range error message + $this->expectFlashData(['error_detected' => ['- Value for \'pref_maps_tiles_maxzoom\' must be a positive number!']]); + $this->preferences->load(); + $this->assertSame(17, TileProviders::resolve($this->preferences)['maxzoom']); + } finally { + foreach (TileProviders::getSchema() as $name => $schema) { + $this->preferences->setValue($name, $schema['default'], $this->login); + } + } + } + /** * Superadmin has no own localization page */