diff --git a/backend/tests/Unit/Controller/FeedbackControllerTest.php b/backend/tests/Unit/Controller/FeedbackControllerTest.php new file mode 100644 index 0000000..a682818 --- /dev/null +++ b/backend/tests/Unit/Controller/FeedbackControllerTest.php @@ -0,0 +1,180 @@ +em = $this->createStub(EntityManagerInterface::class); + } + + private function makeController(bool $exhaust = false, ?EntityManagerInterface $em = null): FeedbackController + { + $storage = new InMemoryStorage(); + $factory = new RateLimiterFactory([ + 'id' => 'feedback_test', + 'policy' => 'token_bucket', + 'limit' => 10, + 'rate' => ['interval' => '1 minute'], + ], $storage); + + if ($exhaust) { + // Vider les tokens avec la même clé que le controller (l'IP du request) + $limiter = $factory->create('1.2.3.4'); + for ($i = 0; $i < 11; $i++) { + $limiter->consume(); + } + } + + $controller = new FeedbackController($em ?? $this->em, $factory); + + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(false); + $controller->setContainer($container); + + return $controller; + } + + private function makeRequest(string $body, string $ip = '1.2.3.4'): Request + { + $request = Request::create('/api/feedback', 'POST', [], [], [], [], $body); + $request->server->set('REMOTE_ADDR', $ip); + return $request; + } + + // ── Taille de payload ───────────────────────────────────────────────────── + + public function testBodyTooLargeReturns413(): void + { + $body = json_encode(['lat' => 14.65, 'lng' => -61.0, 'hasSeaweed' => true, 'pad' => str_repeat('x', 4100)]); + self::assertSame(413, $this->makeController()->create($this->makeRequest($body))->getStatusCode()); + } + + // ── Validation JSON ─────────────────────────────────────────────────────── + + public function testInvalidJsonReturns400(): void + { + self::assertSame(400, $this->makeController()->create($this->makeRequest('not-json'))->getStatusCode()); + } + + // ── Champs obligatoires ─────────────────────────────────────────────────── + + public function testMissingLatReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testMissingLngReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testMissingHasSeaweedReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => -61.0])) + )->getStatusCode()); + } + + // ── Validation lat/lng ──────────────────────────────────────────────────── + + public function testInvalidLatStringReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => 'abc', 'lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testLatAboveRangeReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => 91.0, 'lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testLatBelowRangeReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => -91.0, 'lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testLngAboveRangeReturns422(): void + { + self::assertSame(422, $this->makeController()->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => 181.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + // ── Frontières exactes ──────────────────────────────────────────────────── + + public function testExactBoundaryLatLngIsAccepted(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + self::assertSame(201, $this->makeController(em: $em)->create( + $this->makeRequest(json_encode(['lat' => 90.0, 'lng' => -180.0, 'hasSeaweed' => false])) + )->getStatusCode()); + } + + // ── Requête valide ──────────────────────────────────────────────────────── + + public function testValidRequestReturns201(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + self::assertSame(201, $this->makeController(em: $em)->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } + + public function testValidDensityIsAccepted(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + self::assertSame(201, $this->makeController(em: $em)->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => -61.0, 'hasSeaweed' => true, 'density' => 'high'])) + )->getStatusCode()); + } + + public function testInvalidDensityIsIgnoredAndRequestSucceeds(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + self::assertSame(201, $this->makeController(em: $em)->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => -61.0, 'hasSeaweed' => true, 'density' => 'extreme'])) + )->getStatusCode()); + } + + // ── Rate limiting ───────────────────────────────────────────────────────── + + public function testRateLimitExceededReturns429(): void + { + self::assertSame(429, $this->makeController(exhaust: true)->create( + $this->makeRequest(json_encode(['lat' => 14.65, 'lng' => -61.0, 'hasSeaweed' => true])) + )->getStatusCode()); + } +} diff --git a/backend/tests/Unit/Controller/PushControllerTest.php b/backend/tests/Unit/Controller/PushControllerTest.php new file mode 100644 index 0000000..6db8d7f --- /dev/null +++ b/backend/tests/Unit/Controller/PushControllerTest.php @@ -0,0 +1,137 @@ +em = $this->createStub(EntityManagerInterface::class); + } + + private function makeController(bool $exhaust = false, ?EntityManagerInterface $em = null): PushController + { + $storage = new InMemoryStorage(); + $factory = new RateLimiterFactory([ + 'id' => 'push_test', + 'policy' => 'token_bucket', + 'limit' => 5, + 'rate' => ['interval' => '1 minute'], + ], $storage); + + if ($exhaust) { + $limiter = $factory->create('1.2.3.4'); + for ($i = 0; $i < 6; $i++) { + $limiter->consume(); + } + } + + $controller = new PushController($em ?? $this->em, $factory, 'fake-vapid-public-key'); + + $container = $this->createStub(ContainerInterface::class); + $container->method('has')->willReturn(false); + $controller->setContainer($container); + + return $controller; + } + + private function makeRequest(string $method, string $body, string $ip = '1.2.3.4'): Request + { + $request = Request::create('/api/push/subscribe', $method, [], [], [], [], $body); + $request->server->set('REMOTE_ADDR', $ip); + return $request; + } + + // ── subscribe ───────────────────────────────────────────────────────────── + + public function testSubscribeBodyTooLargeReturns413(): void + { + $body = json_encode([ + 'endpoint' => 'https://push.example.com/sub', + 'keys' => ['auth' => 'a', 'p256dh' => 'b'], + 'pad' => str_repeat('x', 4100), + ]); + self::assertSame(413, $this->controller()->subscribe($this->makeRequest('POST', $body))->getStatusCode()); + } + + public function testSubscribeMissingFieldsReturns422(): void + { + self::assertSame(422, $this->controller()->subscribe( + $this->makeRequest('POST', json_encode(['endpoint' => 'https://push.example.com'])) + )->getStatusCode()); + } + + public function testSubscribeInvalidEndpointUrlReturns422(): void + { + $body = json_encode(['endpoint' => 'not-a-url', 'keys' => ['auth' => 'a', 'p256dh' => 'b']]); + self::assertSame(422, $this->controller()->subscribe($this->makeRequest('POST', $body))->getStatusCode()); + } + + public function testSubscribeValidRequestReturns201(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->willReturn( + $this->createConfiguredStub(\Doctrine\ORM\EntityRepository::class, ['findOneBy' => null]) + ); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + $body = json_encode([ + 'endpoint' => 'https://fcm.googleapis.com/fcm/send/abc123', + 'keys' => ['auth' => 'authtoken', 'p256dh' => 'p256key'], + ]); + self::assertSame(201, $this->makeController(em: $em)->subscribe($this->makeRequest('POST', $body))->getStatusCode()); + } + + public function testSubscribeRateLimitExceededReturns429(): void + { + self::assertSame(429, $this->makeController(exhaust: true)->subscribe( + $this->makeRequest('POST', json_encode([ + 'endpoint' => 'https://push.example.com/sub', + 'keys' => ['auth' => 'a', 'p256dh' => 'b'], + ])) + )->getStatusCode()); + } + + // ── unsubscribe ─────────────────────────────────────────────────────────── + + public function testUnsubscribeMissingEndpointReturns422(): void + { + self::assertSame(422, $this->controller()->unsubscribe( + $this->makeRequest('DELETE', json_encode([])) + )->getStatusCode()); + } + + public function testUnsubscribeUnknownEndpointReturns204(): void + { + $this->em->method('getRepository')->willReturn( + $this->createConfiguredStub(\Doctrine\ORM\EntityRepository::class, ['findOneBy' => null]) + ); + + self::assertSame(204, $this->controller()->unsubscribe( + $this->makeRequest('DELETE', json_encode(['endpoint' => 'https://push.example.com/sub'])) + )->getStatusCode()); + } + + public function testUnsubscribeRateLimitExceededReturns429(): void + { + self::assertSame(429, $this->makeController(exhaust: true)->unsubscribe( + $this->makeRequest('DELETE', json_encode(['endpoint' => 'https://push.example.com/sub'])) + )->getStatusCode()); + } + + private function controller(): PushController + { + return $this->makeController(); + } +} diff --git a/backend/tests/Unit/Service/DriftSimulationServiceTest.php b/backend/tests/Unit/Service/DriftSimulationServiceTest.php new file mode 100644 index 0000000..49945ab --- /dev/null +++ b/backend/tests/Unit/Service/DriftSimulationServiceTest.php @@ -0,0 +1,121 @@ +service = new DriftSimulationService( + $this->createStub(OpenMeteoClient::class), + $this->createStub(OceanCurrentClient::class), + $this->createStub(Connection::class), + $this->createStub(EntityManagerInterface::class), + new NullLogger(), + ); + } + + // ── computeConfidence ───────────────────────────────────────────────────── + + #[\PHPUnit\Framework\Attributes\DataProvider('confidenceProvider')] + public function testComputeConfidence(int $horizon, float $expected): void + { + $method = new \ReflectionMethod(DriftSimulationService::class, 'computeConfidence'); + $result = $method->invoke($this->service, $horizon); + + self::assertSame($expected, $result); + } + + /** @return array */ + public static function confidenceProvider(): array + { + return [ + 'H+6 → 0.85' => [6, 0.85], + 'H+12 → 0.75' => [12, 0.75], + 'H+24 → 0.60' => [24, 0.60], + 'H+48 → 0.45' => [48, 0.45], + 'inconnu → 0.50' => [72, 0.50], + ]; + } + + // ── avgDriftVector ──────────────────────────────────────────────────────── + + public function testAvgDriftVectorWithNoWindNoOcean(): void + { + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, [], 0, 6, 0.0, 0.0); + + self::assertSame(0.0, $result['lat']); + self::assertSame(0.0, $result['lng']); + } + + public function testAvgDriftVectorWithPureEastwardOceanCurrent(): void + { + // Courant purement Est (u=1.0 m/s), vent nul, 6 heures + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, [], 0, 6, 1.0, 0.0); + + self::assertSame(1.0, $result['lng']); // Est-Ouest = 1 m/s + self::assertSame(0.0, $result['lat']); // Nord-Sud = 0 + } + + public function testAvgDriftVectorWithNorthwardOceanCurrent(): void + { + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, [], 0, 6, 0.0, 1.0); + + self::assertSame(0.0, $result['lng']); + self::assertSame(1.0, $result['lat']); + } + + public function testAvgDriftVectorWithNorthWind(): void + { + // Vent venant du Nord (direction=0°), vitesse=10 m/s, courant nul + // sin(0°)=0 (pas de composante E-O), cos(0°)=1 (composante N-S) + // Stokes = 3% → 10 * 0.03 * cos(0) = 0.3 m/s vers Nord + $windData = array_fill(0, 6, ['speed' => 10.0, 'direction' => 0.0]); + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, $windData, 0, 6, 0.0, 0.0); + + self::assertEqualsWithDelta(0.0, $result['lng'], 1e-10); + self::assertEqualsWithDelta(0.3, $result['lat'], 1e-10); + } + + public function testAvgDriftVectorWithEastWind(): void + { + // Vent venant de l'Est (direction=90°), vitesse=10 m/s + // sin(90°)=1 → composante Est = 10*0.03 = 0.3 m/s + // cos(90°)=0 → composante Nord = 0 + $windData = array_fill(0, 6, ['speed' => 10.0, 'direction' => 90.0]); + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, $windData, 0, 6, 0.0, 0.0); + + self::assertEqualsWithDelta(0.3, $result['lng'], 1e-10); + self::assertEqualsWithDelta(0.0, $result['lat'], 1e-10); + } + + public function testAvgDriftVectorUsesOnlyRequestedHourRange(): void + { + // windData[0..5] = vent Nord 10 m/s, windData[6..11] = vent Est 10 m/s + // Si on demande fromHour=6, toHour=12 → seules les heures 6..11 comptent + $windData = array_merge( + array_fill(0, 6, ['speed' => 10.0, 'direction' => 0.0]), // heures 0-5 : Nord + array_fill(6, 6, ['speed' => 10.0, 'direction' => 90.0]), // heures 6-11 : Est + ); + $method = new \ReflectionMethod(DriftSimulationService::class, 'avgDriftVector'); + $result = $method->invoke($this->service, $windData, 6, 12, 0.0, 0.0); + + self::assertEqualsWithDelta(0.3, $result['lng'], 1e-10); // Est + self::assertEqualsWithDelta(0.0, $result['lat'], 1e-10); + } +} diff --git a/backend/tests/Unit/Service/IngestionServiceTest.php b/backend/tests/Unit/Service/IngestionServiceTest.php new file mode 100644 index 0000000..54f344f --- /dev/null +++ b/backend/tests/Unit/Service/IngestionServiceTest.php @@ -0,0 +1,164 @@ +service = new IngestionService( + $this->createStub(SentinelHubClient::class), + $this->createStub(EntityManagerInterface::class), + new NullLogger(), + sys_get_temp_dir(), + ); + } + + // ── buildMultiPolygon ───────────────────────────────────────────────────── + + private function callBuildMultiPolygon(string $geojsonContent): ?array + { + $file = tempnam(sys_get_temp_dir(), 'phpunit_ingestion_') . '.geojson'; + file_put_contents($file, $geojsonContent); + + try { + $method = new \ReflectionMethod(IngestionService::class, 'buildMultiPolygon'); + return $method->invoke($this->service, $file); + } finally { + @unlink($file); + } + } + + private static function makePolygon(int $points = 10, int $dn = 1): array + { + // Carré simple autour de 0,0 — répété $points fois pour avoir assez de coordonnées + $coords = []; + for ($i = 0; $i < $points; $i++) { + $coords[] = [$i * 0.01, $i * 0.01]; + } + $coords[] = $coords[0]; // fermer l'anneau + + return [ + 'type' => 'Feature', + 'properties' => ['DN' => $dn], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [$coords], + ], + ]; + } + + public function testReturnsNullWhenFileDoesNotExist(): void + { + $method = new \ReflectionMethod(IngestionService::class, 'buildMultiPolygon'); + $result = $method->invoke($this->service, '/tmp/nonexistent_phpunit.geojson'); + + self::assertNull($result); + } + + public function testReturnsNullForEmptyFeatureCollection(): void + { + $geojson = json_encode(['type' => 'FeatureCollection', 'features' => []]); + self::assertNull($this->callBuildMultiPolygon($geojson)); + } + + public function testReturnsNullForInvalidJson(): void + { + self::assertNull($this->callBuildMultiPolygon('not-json')); + } + + public function testReturnsNullWhenAllFeaturesHaveWrongDN(): void + { + $geojson = json_encode([ + 'type' => 'FeatureCollection', + 'features' => [self::makePolygon(10, 0), self::makePolygon(10, 2)], + ]); + self::assertNull($this->callBuildMultiPolygon($geojson)); + } + + public function testReturnsNullWhenPolygonHasTooFewPoints(): void + { + // MIN_RING_POINTS = 8 → 5 points doit être ignoré + $geojson = json_encode([ + 'type' => 'FeatureCollection', + 'features' => [self::makePolygon(5, 1)], + ]); + self::assertNull($this->callBuildMultiPolygon($geojson)); + } + + public function testReturnMultiPolygonForValidFeature(): void + { + $geojson = json_encode([ + 'type' => 'FeatureCollection', + 'features' => [self::makePolygon(10, 1)], + ]); + $result = $this->callBuildMultiPolygon($geojson); + + self::assertNotNull($result); + self::assertSame('MultiPolygon', $result['type']); + self::assertCount(1, $result['coordinates']); + } + + public function testHandlesMultipleValidFeatures(): void + { + $geojson = json_encode([ + 'type' => 'FeatureCollection', + 'features' => [ + self::makePolygon(10, 1), + self::makePolygon(12, 1), + self::makePolygon(5, 1), // trop petit → ignoré + ], + ]); + $result = $this->callBuildMultiPolygon($geojson); + + self::assertNotNull($result); + self::assertCount(2, $result['coordinates']); // 2 valides sur 3 + } + + public function testHandlesMultiPolygonGeometryType(): void + { + $coords1 = array_fill(0, 11, [0.0, 0.0]); + $coords1[] = $coords1[0]; + $coords2 = array_fill(0, 11, [1.0, 1.0]); + $coords2[] = $coords2[0]; + + $feature = [ + 'type' => 'Feature', + 'properties' => ['DN' => 1], + 'geometry' => [ + 'type' => 'MultiPolygon', + 'coordinates' => [[$coords1], [$coords2]], + ], + ]; + + $geojson = json_encode(['type' => 'FeatureCollection', 'features' => [$feature]]); + $result = $this->callBuildMultiPolygon($geojson); + + self::assertNotNull($result); + self::assertSame('MultiPolygon', $result['type']); + self::assertCount(2, $result['coordinates']); + } + + public function testIgnoresFeaturesWithDNZeroAndKeepsOthers(): void + { + $geojson = json_encode([ + 'type' => 'FeatureCollection', + 'features' => [ + self::makePolygon(10, 0), // DN=0 → ignoré + self::makePolygon(10, 1), // valide + ], + ]); + $result = $this->callBuildMultiPolygon($geojson); + + self::assertNotNull($result); + self::assertCount(1, $result['coordinates']); + } +}