From cf0d34790a67c12308756fa92f6f1dc919c0c698 Mon Sep 17 00:00:00 2001 From: Jean-Kevin KPADEY Date: Wed, 27 May 2026 18:38:19 +0200 Subject: [PATCH] feat: add custom user agents when calling external services Add mock for external services tests --- metarParser-services/pom.xml | 4 +- .../provider/AbstractWeatherProvider.java | 48 +++++++++++++-- .../provider/AviationWeatherProvider.java | 25 ++++++++ .../service/provider/NOAAWeatherProvider.java | 25 ++++++++ .../AbstractWeatherCodeServiceTest.java | 13 ++--- .../mivek/service/FakeWeatherProvider.java | 40 +++++++++++++ .../mivek/service/MetarServiceTest.java | 15 +---- .../github/mivek/service/TAFServiceTest.java | 15 ++--- .../provider/AviationWeatherProviderTest.java | 58 ++++++++++++++++++- .../provider/NOAAWeatherProviderTest.java | 56 +++++++++++++++++- pom.xml | 7 +++ 11 files changed, 261 insertions(+), 45 deletions(-) create mode 100644 metarParser-services/src/test/java/io/github/mivek/service/FakeWeatherProvider.java diff --git a/metarParser-services/pom.xml b/metarParser-services/pom.xml index 266c9e03..6472967b 100644 --- a/metarParser-services/pom.xml +++ b/metarParser-services/pom.xml @@ -12,9 +12,9 @@ metarParser-services - 0.94 + 0.93 1 - 0.90 + 0.88 diff --git a/metarParser-services/src/main/java/io/github/mivek/service/provider/AbstractWeatherProvider.java b/metarParser-services/src/main/java/io/github/mivek/service/provider/AbstractWeatherProvider.java index 060d0ddd..4ed55f63 100644 --- a/metarParser-services/src/main/java/io/github/mivek/service/provider/AbstractWeatherProvider.java +++ b/metarParser-services/src/main/java/io/github/mivek/service/provider/AbstractWeatherProvider.java @@ -23,10 +23,49 @@ public abstract class AbstractWeatherProvider implements WeatherProvider { /** The required length of a valid ICAO code. */ static final int ICAO_LENGTH = 4; + /** The default User-Agent string sent with every HTTP request. */ + static final String DEFAULT_USER_AGENT = "MetarParser"; + + /** The User-Agent string sent with every HTTP request. */ + private final String userAgent; + + /** The HTTP client used to execute requests. */ + private final HttpClient httpClient; + /** - * Protected constructor. + * Protected constructor. Uses the default User-Agent and a default {@link HttpClient}. */ protected AbstractWeatherProvider() { + this(DEFAULT_USER_AGENT, HttpClient.newBuilder().build()); + } + + /** + * Protected constructor with a custom User-Agent string and a default {@link HttpClient}. + * + * @param userAgent the User-Agent header value to send with every HTTP request. + */ + protected AbstractWeatherProvider(final String userAgent) { + this(userAgent, HttpClient.newBuilder().build()); + } + + /** + * Package-private constructor for testing. Accepts an injectable {@link HttpClient}. + * + * @param httpClient the HTTP client to use for all requests. + */ + AbstractWeatherProvider(final HttpClient httpClient) { + this(DEFAULT_USER_AGENT, httpClient); + } + + /** + * Private canonical constructor used by all other constructors. + * + * @param userAgent the User-Agent header value to send with every HTTP request. + * @param httpClient the HTTP client to use for all requests. + */ + private AbstractWeatherProvider(final String userAgent, final HttpClient httpClient) { + this.userAgent = userAgent; + this.httpClient = httpClient; } /** @@ -42,7 +81,7 @@ protected final void checkIcao(final String icao) throws ParseException { } /** - * Builds an HTTP GET request for the given URL. + * Builds an HTTP GET request for the given URL, including the configured {@code User-Agent} header. * * @param url the URL to request. * @return the constructed {@link HttpRequest}. @@ -53,6 +92,7 @@ protected final HttpRequest buildRequest(final String url) throws URISyntaxExcep .uri(new URI(url)) .GET() .version(HttpClient.Version.HTTP_2) + .header("User-Agent", userAgent) .build(); } @@ -70,9 +110,7 @@ protected final HttpRequest buildRequest(final String url) throws URISyntaxExcep protected final HttpResponse> getHttpResponse(final String url) throws IOException, URISyntaxException, InterruptedException, ParseException { HttpRequest request = buildRequest(url); - HttpResponse> response = HttpClient.newBuilder() - .build() - .send(request, HttpResponse.BodyHandlers.ofLines()); + HttpResponse> response = httpClient.send(request, HttpResponse.BodyHandlers.ofLines()); if (response.statusCode() != HttpURLConnection.HTTP_OK) { throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO); } diff --git a/metarParser-services/src/main/java/io/github/mivek/service/provider/AviationWeatherProvider.java b/metarParser-services/src/main/java/io/github/mivek/service/provider/AviationWeatherProvider.java index 91aa71d0..95e26332 100644 --- a/metarParser-services/src/main/java/io/github/mivek/service/provider/AviationWeatherProvider.java +++ b/metarParser-services/src/main/java/io/github/mivek/service/provider/AviationWeatherProvider.java @@ -35,6 +35,31 @@ public final class AviationWeatherProvider extends AbstractWeatherProvider { /** The "SPECI " report-type prefix returned by the API. */ private static final String SPECI_PREFIX = "SPECI "; + /** + * Default constructor. Uses the default User-Agent. + */ + public AviationWeatherProvider() { + super(); + } + + /** + * Constructor with a custom User-Agent string. + * + * @param userAgent the User-Agent header value to send with every HTTP request. + */ + public AviationWeatherProvider(final String userAgent) { + super(userAgent); + } + + /** + * Package-private constructor for testing. Accepts an injectable {@link java.net.http.HttpClient}. + * + * @param httpClient the HTTP client to use for all requests. + */ + AviationWeatherProvider(final java.net.http.HttpClient httpClient) { + super(httpClient); + } + @Override public String retrieveMetar(final String icao) throws ParseException, IOException, URISyntaxException, InterruptedException { checkIcao(icao); diff --git a/metarParser-services/src/main/java/io/github/mivek/service/provider/NOAAWeatherProvider.java b/metarParser-services/src/main/java/io/github/mivek/service/provider/NOAAWeatherProvider.java index b7ec1daf..272a601e 100644 --- a/metarParser-services/src/main/java/io/github/mivek/service/provider/NOAAWeatherProvider.java +++ b/metarParser-services/src/main/java/io/github/mivek/service/provider/NOAAWeatherProvider.java @@ -35,6 +35,31 @@ public final class NOAAWeatherProvider extends AbstractWeatherProvider { /** The AMD TAF token that appears as a second line in amended NOAA TAF responses. */ private static final String AMD_TAF_TOKEN = "AMD TAF"; + /** + * Default constructor. Uses the default User-Agent. + */ + public NOAAWeatherProvider() { + super(); + } + + /** + * Constructor with a custom User-Agent string. + * + * @param userAgent the User-Agent header value to send with every HTTP request. + */ + public NOAAWeatherProvider(final String userAgent) { + super(userAgent); + } + + /** + * Package-private constructor for testing. Accepts an injectable {@link java.net.http.HttpClient}. + * + * @param httpClient the HTTP client to use for all requests. + */ + NOAAWeatherProvider(final java.net.http.HttpClient httpClient) { + super(httpClient); + } + @Override public String retrieveMetar(final String icao) throws ParseException, IOException, URISyntaxException, InterruptedException { checkIcao(icao); diff --git a/metarParser-services/src/test/java/io/github/mivek/service/AbstractWeatherCodeServiceTest.java b/metarParser-services/src/test/java/io/github/mivek/service/AbstractWeatherCodeServiceTest.java index 82293943..df63a703 100644 --- a/metarParser-services/src/test/java/io/github/mivek/service/AbstractWeatherCodeServiceTest.java +++ b/metarParser-services/src/test/java/io/github/mivek/service/AbstractWeatherCodeServiceTest.java @@ -5,9 +5,6 @@ import io.github.mivek.model.AbstractWeatherCode; import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.net.URISyntaxException; - import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; @@ -15,24 +12,24 @@ import static org.junit.jupiter.api.Assertions.assertThrows; abstract class AbstractWeatherCodeServiceTest { - protected abstract AbstractWeatherCodeService getService(); + protected abstract AbstractWeatherCodeService getService(FakeWeatherProvider provider); @Test void testRetrieveFromAirportInvalid() { - ParseException e = assertThrows(ParseException.class, () -> getService().retrieveFromAirport("RandomIcao")); + ParseException e = assertThrows(ParseException.class, () -> getService(new FakeWeatherProvider()).retrieveFromAirport("RandomIcao")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } @Test - void testRetrieveFromAirport() throws IOException, ParseException, URISyntaxException, InterruptedException { - T res = getService().retrieveFromAirport("LFPG"); + void testRetrieveFromAirport() throws Exception { + T res = getService(new FakeWeatherProvider()).retrieveFromAirport("LFPG"); assertThat(res, notNullValue()); assertThat(res.getAirport().getIcao(), is("LFPG")); } @Test void testRetrieveFromAirportNotFound() { - ParseException e = assertThrows(ParseException.class, () -> getService().retrieveFromAirport("lftm")); + ParseException e = assertThrows(ParseException.class, () -> getService(new FakeWeatherProvider()).retrieveFromAirport("lftm")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } } diff --git a/metarParser-services/src/test/java/io/github/mivek/service/FakeWeatherProvider.java b/metarParser-services/src/test/java/io/github/mivek/service/FakeWeatherProvider.java new file mode 100644 index 00000000..0cb14900 --- /dev/null +++ b/metarParser-services/src/test/java/io/github/mivek/service/FakeWeatherProvider.java @@ -0,0 +1,40 @@ +package io.github.mivek.service; + +import io.github.mivek.exception.ErrorCodes; +import io.github.mivek.exception.ParseException; +import io.github.mivek.service.provider.WeatherProvider; + +/** + * Test-only {@link WeatherProvider} implementation that returns hardcoded weather strings. + * Used to avoid real HTTP calls in service-layer tests. + */ +final class FakeWeatherProvider implements WeatherProvider { + + /** Hardcoded METAR string for LFPG. */ + static final String LFPG_METAR = "LFPG 251830Z 17013KT 9999 OVC006 04/03 Q1012 NOSIG"; + + /** Hardcoded TAF string for LFPG. */ + static final String LFPG_TAF = "TAF LFPG 121700Z 1218/1324 13003KT CAVOK TX09/1315Z TN00/1306Z\nTEMPO 1303/1308 4000 BR"; + + @Override + public String retrieveMetar(final String icao) throws ParseException { + if (icao.length() != 4) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO); + } + if (!"LFPG".equalsIgnoreCase(icao)) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO); + } + return LFPG_METAR; + } + + @Override + public String retrieveTaf(final String icao) throws ParseException { + if (icao.length() != 4) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO); + } + if (!"LFPG".equalsIgnoreCase(icao)) { + throw new ParseException(ErrorCodes.ERROR_CODE_INVALID_ICAO); + } + return LFPG_TAF; + } +} diff --git a/metarParser-services/src/test/java/io/github/mivek/service/MetarServiceTest.java b/metarParser-services/src/test/java/io/github/mivek/service/MetarServiceTest.java index 6693dfb8..e6cef426 100644 --- a/metarParser-services/src/test/java/io/github/mivek/service/MetarServiceTest.java +++ b/metarParser-services/src/test/java/io/github/mivek/service/MetarServiceTest.java @@ -3,12 +3,8 @@ import io.github.mivek.exception.ParseException; import io.github.mivek.internationalization.Messages; import io.github.mivek.model.Metar; -import io.github.mivek.service.provider.AviationWeatherProvider; import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.net.URISyntaxException; - import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.*; @@ -45,15 +41,8 @@ void testDecodeValidMetar() throws ParseException { } - @Test - void testRetrieveFromAirportWithAviationWeatherProvider() throws ParseException, IOException, URISyntaxException, InterruptedException { - Metar result = MetarService.withProvider(new AviationWeatherProvider()).retrieveFromAirport("LFPG"); - assertNotNull(result); - assertEquals("LFPG", result.getAirport().getIcao()); - } - @Override - protected AbstractWeatherCodeService getService() { - return MetarService.getInstance(); + protected AbstractWeatherCodeService getService(final FakeWeatherProvider provider) { + return MetarService.withProvider(provider); } } diff --git a/metarParser-services/src/test/java/io/github/mivek/service/TAFServiceTest.java b/metarParser-services/src/test/java/io/github/mivek/service/TAFServiceTest.java index 470fa349..9a8bb2f1 100644 --- a/metarParser-services/src/test/java/io/github/mivek/service/TAFServiceTest.java +++ b/metarParser-services/src/test/java/io/github/mivek/service/TAFServiceTest.java @@ -1,27 +1,20 @@ package io.github.mivek.service; -import io.github.mivek.exception.ParseException; import io.github.mivek.model.TAF; -import io.github.mivek.service.provider.AviationWeatherProvider; import org.junit.jupiter.api.Test; -import java.io.IOException; -import java.net.URISyntaxException; - import static org.junit.jupiter.api.Assertions.assertNotNull; class TAFServiceTest extends AbstractWeatherCodeServiceTest { - private final TAFService sut = TAFService.getInstance(); - @Override - protected AbstractWeatherCodeService getService() { - return sut; + protected AbstractWeatherCodeService getService(final FakeWeatherProvider provider) { + return TAFService.withProvider(provider); } @Test - void testRetrieveFromAirportWithAviationWeatherProvider() throws ParseException, IOException, URISyntaxException, InterruptedException { - TAF result = TAFService.withProvider(new AviationWeatherProvider()).retrieveFromAirport("LFPG"); + void testRetrieveFromAirportWithFakeProvider() throws Exception { + TAF result = TAFService.withProvider(new FakeWeatherProvider()).retrieveFromAirport("LFPG"); assertNotNull(result); assertNotNull(result.getAirport()); } diff --git a/metarParser-services/src/test/java/io/github/mivek/service/provider/AviationWeatherProviderTest.java b/metarParser-services/src/test/java/io/github/mivek/service/provider/AviationWeatherProviderTest.java index a8fb9e37..d0e7f1e9 100644 --- a/metarParser-services/src/test/java/io/github/mivek/service/provider/AviationWeatherProviderTest.java +++ b/metarParser-services/src/test/java/io/github/mivek/service/provider/AviationWeatherProviderTest.java @@ -2,19 +2,43 @@ import io.github.mivek.exception.ErrorCodes; import io.github.mivek.exception.ParseException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class AviationWeatherProviderTest { - private final AviationWeatherProvider sut = new AviationWeatherProvider(); + private HttpClient mockHttpClient; + private HttpResponse> mockResponse = mock(HttpResponse.class); + private AviationWeatherProvider sut; + + @BeforeEach + void setUp() { + mockHttpClient = mock(HttpClient.class); + mockResponse = mock(HttpResponse.class); + sut = new AviationWeatherProvider(mockHttpClient); + } + + @Test + void testConstructors() { + assertNotNull(new AviationWeatherProvider()); + assertNotNull(new AviationWeatherProvider("Custom-Agent")); + } @Test void testRetrieveMetarInvalidIcao() { @@ -23,14 +47,32 @@ void testRetrieveMetarInvalidIcao() { } @Test - void testRetrieveMetarNotFound() { + void testRetrieveMetarNotFound() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(404); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + ParseException e = assertThrows(ParseException.class, () -> sut.retrieveMetar("lftm")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } + @Test + void testRetrieveMetarEmptyBody() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(Stream.empty()); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + + ParseException e = assertThrows(ParseException.class, () -> sut.retrieveMetar("LFPG")); + assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); + } + @Test void testRetrieveMetar() throws ParseException, IOException, URISyntaxException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(Stream.of("LFPG 251830Z 17013KT 9999 OVC006 04/03 Q1012 NOSIG")); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + String result = sut.retrieveMetar("LFPG"); + assertNotNull(result); assertTrue(result.contains("LFPG")); } @@ -42,14 +84,24 @@ void testRetrieveTafInvalidIcao() { } @Test - void testRetrieveTafNotFound() { + void testRetrieveTafNotFound() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(404); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + ParseException e = assertThrows(ParseException.class, () -> sut.retrieveTaf("lftm")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } @Test void testRetrieveTaf() throws ParseException, IOException, URISyntaxException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(Stream.of( + "TAF LFPG 121700Z 1218/1324 13003KT CAVOK TX09/1315Z TN00/1306Z", + "TEMPO 1303/1308 4000 BR")); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + String result = sut.retrieveTaf("LFPG"); + assertNotNull(result); assertTrue(result.contains("LFPG")); } diff --git a/metarParser-services/src/test/java/io/github/mivek/service/provider/NOAAWeatherProviderTest.java b/metarParser-services/src/test/java/io/github/mivek/service/provider/NOAAWeatherProviderTest.java index 69cfcc77..95b8f2b6 100644 --- a/metarParser-services/src/test/java/io/github/mivek/service/provider/NOAAWeatherProviderTest.java +++ b/metarParser-services/src/test/java/io/github/mivek/service/provider/NOAAWeatherProviderTest.java @@ -2,19 +2,43 @@ import io.github.mivek.exception.ErrorCodes; import io.github.mivek.exception.ParseException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class NOAAWeatherProviderTest { - private final NOAAWeatherProvider sut = new NOAAWeatherProvider(); + private HttpClient mockHttpClient; + private HttpResponse> mockResponse = mock(HttpResponse.class); + private NOAAWeatherProvider sut; + + @BeforeEach + void setUp() { + mockHttpClient = mock(HttpClient.class); + mockResponse = mock(HttpResponse.class); + sut = new NOAAWeatherProvider(mockHttpClient); + } + + @Test + void testConstructors() { + assertNotNull(new NOAAWeatherProvider()); + assertNotNull(new NOAAWeatherProvider("Custom-Agent")); + } @Test void testRetrieveMetarInvalidIcao() { @@ -23,14 +47,24 @@ void testRetrieveMetarInvalidIcao() { } @Test - void testRetrieveMetarNotFound() { + void testRetrieveMetarNotFound() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(404); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + ParseException e = assertThrows(ParseException.class, () -> sut.retrieveMetar("lftm")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } @Test void testRetrieveMetar() throws ParseException, IOException, URISyntaxException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(Stream.of( + "2024/01/01 18:30", + "LFPG 251830Z 17013KT 9999 OVC006 04/03 Q1012 NOSIG")); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + String result = sut.retrieveMetar("LFPG"); + assertNotNull(result); assertTrue(result.contains("LFPG")); } @@ -42,14 +76,25 @@ void testRetrieveTafInvalidIcao() { } @Test - void testRetrieveTafNotFound() { + void testRetrieveTafNotFound() throws IOException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(404); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + ParseException e = assertThrows(ParseException.class, () -> sut.retrieveTaf("lftm")); assertEquals(ErrorCodes.ERROR_CODE_INVALID_ICAO, e.getErrorCode()); } @Test void testRetrieveTaf() throws ParseException, IOException, URISyntaxException, InterruptedException { + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn(Stream.of( + "2024/01/01 18:30", + "TAF LFPG 121700Z 1218/1324 13003KT CAVOK TX09/1315Z TN00/1306Z", + "TEMPO 1303/1308 4000 BR")); + doReturn(mockResponse).when(mockHttpClient).send(any(HttpRequest.class), any()); + String result = sut.retrieveTaf("LFPG"); + assertNotNull(result); assertTrue(result.contains("LFPG")); } @@ -117,4 +162,9 @@ void testFormat() throws ParseException { assertNotNull(result); assertEquals(formatted, result); } + + @Test + void testDefaultUserAgent() { + assertEquals("MetarParser", AbstractWeatherProvider.DEFAULT_USER_AGENT); + } } diff --git a/pom.xml b/pom.xml index 61b61450..2dfd82d4 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,7 @@ 1.14.1 1.2.3 1.23.0 + 5.17.0 2.0.17 4.9.8.2 mivek-github @@ -115,6 +116,12 @@ ${archunit-junit5.version} test + + org.mockito + mockito-core + ${mockito.version} + test + org.slf4j slf4j-nop