From 848e5718929fb8d73d9aed4c11c43a0b3352c22e Mon Sep 17 00:00:00 2001 From: Simon Grimme <9295182+grimsi@users.noreply.github.com> Date: Sun, 16 Oct 2022 01:05:47 +0200 Subject: [PATCH 01/15] Update Spring Boot version to 2.7.4 --- backend/pom.xml | 25 ++++++++ .../de/grimsi/gameyfin/igdb/IgdbWrapper.java | 11 +++- .../src/main/resources/config/gameyfin.yml | 3 + .../grimsi/gameyfin/igdb/IgdbWrapperTest.java | 60 +++++++++++++++++++ .../gameyfin/mapper/CompanyMapperTest.java | 54 +++++++++++++++++ .../gameyfin/mapper/GenreMapperTest.java | 37 ++++++++++++ .../gameyfin/mapper/RandomMapperTest.java | 49 +++++++++++++++ .../src/test/resources/application-test.yml | 7 +++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 10 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java create mode 100644 backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java create mode 100644 backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java create mode 100644 backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java create mode 100644 backend/src/test/resources/application-test.yml diff --git a/backend/pom.xml b/backend/pom.xml index 0842744..2b5633f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -21,6 +21,8 @@ 1.21 3.11.4 3.21.7 + 5.0.0 + 0.4.0 3.1.0 @@ -117,6 +119,29 @@ spring-boot-starter-test test + + org.jeasy + easy-random-core + ${easy-random.version} + test + + + io.github.murdos + easy-random-protobuf + ${easy-random-protobuf.version} + test + + + com.squareup.okhttp3 + okhttp + test + + + com.squareup.okhttp3 + mockwebserver + test + + diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index c4bc180..7903073 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -37,6 +37,12 @@ public class IgdbWrapper { @Value("${gameyfin.igdb.config.preferred-platforms:6}") private String preferredPlatforms; + @Value("${gameyfin.igdb.api.endpoints.base}") + private String igdbApiBaseUrl; + + @Value("${gameyfin.igdb.api.endpoints.auth}") + private String twitchAuthUrl; + private final WebClient.Builder webclientBuilder; private final WebClientConfig webClientConfig; private final GameMapper gameMapper; @@ -58,7 +64,8 @@ public class IgdbWrapper { log.info("Authenticating on Twitch API..."); URI url = UriComponentsBuilder - .fromHttpUrl("https://id.twitch.tv/oauth2/token?client_id={client_id}&client_secret={client_secret}&grant_type=client_credentials") + .fromHttpUrl(twitchAuthUrl) + .query("client_id={client_id}").query("client_secret={client_secret}").query("grant_type=client_credentials") .buildAndExpand(clientId, clientSecret) .toUri(); @@ -163,7 +170,7 @@ public class IgdbWrapper { } igdbApiClient = webclientBuilder - .baseUrl("https://api.igdb.com/v4/") + .baseUrl(igdbApiBaseUrl) .defaultHeader("Client-ID", clientId) .defaultHeader("Authorization", "Bearer %s".formatted(accessToken.getAccessToken())) .filter(WebClientConfig.fixProtobufContentTypeInterceptor()) diff --git a/backend/src/main/resources/config/gameyfin.yml b/backend/src/main/resources/config/gameyfin.yml index 1d7eef8..e82b5ac 100644 --- a/backend/src/main/resources/config/gameyfin.yml +++ b/backend/src/main/resources/config/gameyfin.yml @@ -4,6 +4,9 @@ gameyfin: file-extensions: iso, zip, rar, 7z, exe igdb: api: + endpoints: + auth: https://id.twitch.tv/oauth2/token + base: https://api.igdb.com/v4/ client-id: client-secret: max-concurrent-requests: 2 diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java new file mode 100644 index 0000000..0b083db --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -0,0 +1,60 @@ +package de.grimsi.gameyfin.igdb; + +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class IgdbWrapperTest { + + private static final MockWebServer igdbApiMock = new MockWebServer(); + private static final MockWebServer twitchApiMock = new MockWebServer(); + + private IgdbWrapper target; + + @DynamicPropertySource + static void setupProperties(DynamicPropertyRegistry registry) { + registry.add("gameyfin.igdb.api.endpoints.base", () -> "http://localhost:%s".formatted(igdbApiMock.getPort())); + registry.add("gameyfin.igdb.api.endpoints.auth", () -> "http://localhost:%s".formatted(twitchApiMock.getPort())); + } + + @BeforeAll + static void setup() throws IOException { + igdbApiMock.start(); + twitchApiMock.start(); + } + + @AfterAll + static void tearDown() throws IOException { + igdbApiMock.shutdown(); + twitchApiMock.start(); + } + + @Test + void authenticate() { + } + + @Test + void getGameById() { + } + + @Test + void getGameBySlug() { + } + + @Test + void findPossibleMatchingTitles() { + } + + @Test + void searchForGameByTitle() { + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java new file mode 100644 index 0000000..129c388 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/CompanyMapperTest.java @@ -0,0 +1,54 @@ +package de.grimsi.gameyfin.mapper; + +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.entities.Company; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class CompanyMapperTest extends RandomMapperTest { + + @Test + @Disabled + void toCompany() { + Igdb.InvolvedCompany input = generateRandomInput(); + + Company output = CompanyMapper.toCompany(input); + + assertThat(output.getSlug()).isEqualTo(input.getCompany().getSlug()); + assertThat(output.getName()).isEqualTo(input.getCompany().getName()); + assertThat(output.getLogoId()).isEqualTo(input.getCompany().getLogo().getImageId()); + } + + @Test + @Disabled + void toCompanies() { + List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput()); + + List output = CompanyMapper.toCompanies(input); + + for (int i = 0; i < output.size(); i++) { + assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getCompany().getSlug()); + assertThat(output.get(i).getName()).isEqualTo(input.get(i).getCompany().getName()); + assertThat(output.get(i).getLogoId()).isEqualTo(input.get(i).getCompany().getLogo().getImageId()); + } + } + + private static Igdb.InvolvedCompany generateRandomInvolvedCompany() { + Igdb.Company c = Igdb.Company.newBuilder() + .setSlug(UUID.randomUUID().toString()) + .setName(UUID.randomUUID().toString()) + .setLogo(Igdb.CompanyLogo.newBuilder() + .setImageId(UUID.randomUUID().toString()) + .build()) + .build(); + + return Igdb.InvolvedCompany.newBuilder() + .setCompany(c) + .build(); + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java new file mode 100644 index 0000000..e60cab8 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/GenreMapperTest.java @@ -0,0 +1,37 @@ +package de.grimsi.gameyfin.mapper; + +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.entities.Genre; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenreMapperTest extends RandomMapperTest { + + @Test + @Disabled + void toGenre() { + Igdb.Genre input = generateRandomInput(); + + Genre output = GenreMapper.toGenre(input); + + assertThat(output.getSlug()).isEqualTo(input.getSlug()); + assertThat(output.getName()).isEqualTo(input.getName()); + } + + @Test + @Disabled + void toGenres() { + List input = List.of(generateRandomInput(), generateRandomInput(), generateRandomInput()); + + List output = GenreMapper.toGenres(input); + + for (int i = 0; i < output.size(); i++) { + assertThat(output.get(i).getSlug()).isEqualTo(input.get(i).getSlug()); + assertThat(output.get(i).getName()).isEqualTo(input.get(i).getName()); + } + } +} diff --git a/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java new file mode 100644 index 0000000..172b853 --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/mapper/RandomMapperTest.java @@ -0,0 +1,49 @@ +package de.grimsi.gameyfin.mapper; + +import com.google.protobuf.Message; +import org.jeasy.random.EasyRandom; +import org.jeasy.random.EasyRandomParameters; +import org.springframework.core.GenericTypeResolver; + +import java.util.List; + +public class RandomMapperTest { + + private static final int DEFAULT_COUNT = 5; + private final EasyRandom easyRandom = new EasyRandom(); + + private final Class inputClass; + private final Class outputClass; + + @SuppressWarnings("unchecked") + public RandomMapperTest() { + Class[] typeArguments = GenericTypeResolver.resolveTypeArguments(getClass(), RandomMapperTest.class); + assert typeArguments != null; + inputClass = (Class) typeArguments[0]; + outputClass = (Class) typeArguments[1]; + } + + protected Input generateRandomInput() { + return easyRandom.nextObject(inputClass); + } + + protected List generateRandomInputs() { + return easyRandom.objects(inputClass, DEFAULT_COUNT).toList(); + } + + protected List generateRandomInputs(int count) { + return easyRandom.objects(inputClass, count).toList(); + } + + protected Output generateRandomOutput() { + return easyRandom.nextObject(outputClass); + } + + protected List generateRandomOutputs() { + return easyRandom.objects(outputClass, DEFAULT_COUNT).toList(); + } + + protected List generateRandomOutputs(int count) { + return easyRandom.objects(outputClass, count).toList(); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..8702211 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,7 @@ +gameyfin: + igdb: + api: + client-id: igdb_client_id + client-secret: igdb_client_secret + config: + preferred-platforms: 6 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef089f2..0c909b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "dependencies": { "@angular/animations": "^14.0.0", "@angular/cdk": "^14.1.0", diff --git a/frontend/package.json b/frontend/package.json index cf5b2db..6734fcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "scripts": { "ng": "ng", "start": "ng serve", From 98a04be16db6e97f4054e78b4e8339582db77b3d Mon Sep 17 00:00:00 2001 From: Simon Grimme <9295182+grimsi@users.noreply.github.com> Date: Sun, 16 Oct 2022 01:16:03 +0200 Subject: [PATCH 02/15] Fix IgdbWrapperTest tearDown --- .../src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java index 0b083db..3c00e2f 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -35,7 +35,7 @@ class IgdbWrapperTest { @AfterAll static void tearDown() throws IOException { igdbApiMock.shutdown(); - twitchApiMock.start(); + twitchApiMock.shutdown(); } @Test From 23054c7754f744d485f1cd4edbec142886e2dd08 Mon Sep 17 00:00:00 2001 From: Simon Grimme <9295182+grimsi@users.noreply.github.com> Date: Sun, 16 Oct 2022 13:23:30 +0200 Subject: [PATCH 03/15] Implemented some new test cases --- .../de/grimsi/gameyfin/igdb/IgdbWrapper.java | 2 +- .../igdb/dto/TwitchOAuthTokenDto.java | 2 + .../grimsi/gameyfin/igdb/IgdbWrapperTest.java | 106 +++++++++++++++--- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index 7903073..9b30bc5 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -60,7 +60,7 @@ public class IgdbWrapper { initIgdbClient(); } - public void authenticate() { + private void authenticate() { log.info("Authenticating on Twitch API..."); URI url = UriComponentsBuilder diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java index b971883..64fb1fa 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/dto/TwitchOAuthTokenDto.java @@ -2,6 +2,8 @@ package de.grimsi.gameyfin.igdb.dto; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java index 3c00e2f..7942f25 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -1,35 +1,65 @@ package de.grimsi.gameyfin.igdb; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.Message; +import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.config.WebClientConfig; +import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto; +import de.grimsi.gameyfin.mapper.GameMapper; +import io.github.resilience4j.bulkhead.Bulkhead; +import io.github.resilience4j.bulkhead.BulkheadConfig; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; +import org.jeasy.random.EasyRandom; +import org.jeasy.random.EasyRandomParameters; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; import java.io.IOException; +import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -@SpringBootTest +@ExtendWith(MockitoExtension.class) class IgdbWrapperTest { private static final MockWebServer igdbApiMock = new MockWebServer(); private static final MockWebServer twitchApiMock = new MockWebServer(); + private static final EasyRandom easyRandom = new EasyRandom(); + private static final ObjectMapper objectMapper = new ObjectMapper(); - private IgdbWrapper target; - - @DynamicPropertySource - static void setupProperties(DynamicPropertyRegistry registry) { - registry.add("gameyfin.igdb.api.endpoints.base", () -> "http://localhost:%s".formatted(igdbApiMock.getPort())); - registry.add("gameyfin.igdb.api.endpoints.auth", () -> "http://localhost:%s".formatted(twitchApiMock.getPort())); - } + private static IgdbWrapper target; @BeforeAll static void setup() throws IOException { + WebClientConfig webClientConfigMock = mock(WebClientConfig.class); + GameMapper gameMapperMock = mock(GameMapper.class); + + target = new IgdbWrapper(WebClient.builder(), webClientConfigMock, gameMapperMock); + igdbApiMock.start(); twitchApiMock.start(); + + ReflectionTestUtils.setField(target, "clientId", "client_id_value"); + ReflectionTestUtils.setField(target, "clientSecret", "client_secret_value"); + ReflectionTestUtils.setField(target, "igdbApiBaseUrl", "http://localhost:%s".formatted(igdbApiMock.getPort())); + ReflectionTestUtils.setField(target, "twitchAuthUrl", "http://localhost:%s/oauth2/token".formatted(twitchApiMock.getPort())); + + when(webClientConfigMock.getIgdbConcurrencyLimiter()).thenReturn(Bulkhead.of("test_bulkhead", BulkheadConfig.ofDefaults())); + when(webClientConfigMock.getIgdbRateLimiter()).thenReturn(RateLimiter.of("test_ratelimiter", RateLimiterConfig.ofDefaults())); } @AfterAll @@ -39,11 +69,53 @@ class IgdbWrapperTest { } @Test - void authenticate() { + @Order(0) + void init() throws JsonProcessingException, InterruptedException { + TwitchOAuthTokenDto mockToken = easyRandom.nextObject(TwitchOAuthTokenDto.class); + + twitchApiMock.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(mockToken)) + .addHeader("Content-Type", "application/json")); + + target.init(); + + RecordedRequest r = twitchApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/oauth2/token"); + assertThat(r.getRequestUrl().queryParameter("client_id")).isEqualTo("client_id_value"); + assertThat(r.getRequestUrl().queryParameter("client_secret")).isEqualTo("client_secret_value"); + assertThat(r.getRequestUrl().queryParameter("grant_type")).isEqualTo("client_credentials"); } @Test - void getGameById() { + void getGameById() throws InterruptedException { + //Igdb.GameResult gameResult = easyRandom.nextObject(Igdb.GameResult.class); + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addGames(Igdb.Game.newBuilder().setId(easyRandom.nextLong())) + .build(); + + Long gameId = gameResult.getGames(0).getId(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.getGameById(gameId); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getId()).isEqualTo(gameId); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "fields %s; where id = %d; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameId); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); } @Test @@ -57,4 +129,10 @@ class IgdbWrapperTest { @Test void searchForGameByTitle() { } + + private static Buffer toBuffer(Message input) { + Buffer b = new Buffer(); + b.write(input.toByteArray()); + return b; + } } From 8525f09c717f4c2d80d4d5e2e8a55ff3c1f0ee34 Mon Sep 17 00:00:00 2001 From: Simon Grimme Date: Mon, 17 Oct 2022 08:12:19 +0200 Subject: [PATCH 04/15] Implemented additional testcase --- .../grimsi/gameyfin/igdb/IgdbWrapperTest.java | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java index 7942f25..5407cff 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -1,10 +1,11 @@ package de.grimsi.gameyfin.igdb; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.protobuf.Message; +import com.google.protobuf.Timestamp; import com.igdb.proto.Igdb; import de.grimsi.gameyfin.config.WebClientConfig; +import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto; import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto; import de.grimsi.gameyfin.mapper.GameMapper; import io.github.resilience4j.bulkhead.Bulkhead; @@ -16,10 +17,8 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import okio.Buffer; import org.jeasy.random.EasyRandom; -import org.jeasy.random.EasyRandomParameters; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @@ -27,6 +26,7 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.reactive.function.client.WebClient; import java.io.IOException; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -44,7 +44,7 @@ class IgdbWrapperTest { private static IgdbWrapper target; @BeforeAll - static void setup() throws IOException { + static void setup() throws IOException, InterruptedException { WebClientConfig webClientConfigMock = mock(WebClientConfig.class); GameMapper gameMapperMock = mock(GameMapper.class); @@ -57,20 +57,11 @@ class IgdbWrapperTest { ReflectionTestUtils.setField(target, "clientSecret", "client_secret_value"); ReflectionTestUtils.setField(target, "igdbApiBaseUrl", "http://localhost:%s".formatted(igdbApiMock.getPort())); ReflectionTestUtils.setField(target, "twitchAuthUrl", "http://localhost:%s/oauth2/token".formatted(twitchApiMock.getPort())); + ReflectionTestUtils.setField(target, "preferredPlatforms", "preferred_platforms"); when(webClientConfigMock.getIgdbConcurrencyLimiter()).thenReturn(Bulkhead.of("test_bulkhead", BulkheadConfig.ofDefaults())); when(webClientConfigMock.getIgdbRateLimiter()).thenReturn(RateLimiter.of("test_ratelimiter", RateLimiterConfig.ofDefaults())); - } - @AfterAll - static void tearDown() throws IOException { - igdbApiMock.shutdown(); - twitchApiMock.shutdown(); - } - - @Test - @Order(0) - void init() throws JsonProcessingException, InterruptedException { TwitchOAuthTokenDto mockToken = easyRandom.nextObject(TwitchOAuthTokenDto.class); twitchApiMock.enqueue(new MockResponse() @@ -87,11 +78,20 @@ class IgdbWrapperTest { assertThat(r.getRequestUrl().queryParameter("grant_type")).isEqualTo("client_credentials"); } + @AfterAll + static void tearDown() throws IOException { + igdbApiMock.shutdown(); + twitchApiMock.shutdown(); + } + @Test void getGameById() throws InterruptedException { //Igdb.GameResult gameResult = easyRandom.nextObject(Igdb.GameResult.class); Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() - .addGames(Igdb.Game.newBuilder().setId(easyRandom.nextLong())) + .addAllGames(List.of( + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(), + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build(), + Igdb.Game.newBuilder().setId(easyRandom.nextLong()).build())) .build(); Long gameId = gameResult.getGames(0).getId(); @@ -119,11 +119,65 @@ class IgdbWrapperTest { } @Test - void getGameBySlug() { + void getGameBySlug() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setSlug("game_slug_1").build(), + Igdb.Game.newBuilder().setSlug("game_slug_2").build(), + Igdb.Game.newBuilder().setSlug("game_slug_3").build())) + .build(); + + String gameSlug = gameResult.getGames(0).getSlug(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.getGameBySlug("game_slug_1"); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getSlug()).isEqualTo(gameSlug); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "fields %s; where slug = \"%s\"; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameSlug); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); } @Test - void findPossibleMatchingTitles() { + void findPossibleMatchingTitles() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setSlug("game_slug_1").setName("title_1").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(1)).build(), + Igdb.Game.newBuilder().setSlug("game_slug_2").setName("title_2").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(2)).build(), + Igdb.Game.newBuilder().setSlug("game_slug_3").setName("title_3").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(3)).build())) + .build(); + + String gameTitle = gameResult.getGames(0).getName(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + List suggestions = target.findPossibleMatchingTitles(gameTitle, gameResult.getGamesCount()); + + assertThat(suggestions).hasSize(gameResult.getGamesCount()); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields slug,name,first_release_date; where platforms = (preferred_platforms); limit %d;".formatted(gameTitle, gameResult.getGamesCount()); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); } @Test From a4d73439b85d53a7431bdee677922a4549c4ad7c Mon Sep 17 00:00:00 2001 From: Simon Grimme Date: Tue, 18 Oct 2022 12:18:22 +0300 Subject: [PATCH 05/15] Finished implementation of all test cases for IgdbWrapper --- .../grimsi/gameyfin/igdb/IgdbWrapperTest.java | 123 +++++++++++++++++- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java index 5407cff..458ed02 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -28,6 +28,8 @@ import org.springframework.web.reactive.function.client.WebClient; import java.io.IOException; import java.util.List; import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -76,12 +78,13 @@ class IgdbWrapperTest { assertThat(r.getRequestUrl().queryParameter("client_id")).isEqualTo("client_id_value"); assertThat(r.getRequestUrl().queryParameter("client_secret")).isEqualTo("client_secret_value"); assertThat(r.getRequestUrl().queryParameter("grant_type")).isEqualTo("client_credentials"); + + twitchApiMock.shutdown(); } @AfterAll static void tearDown() throws IOException { igdbApiMock.shutdown(); - twitchApiMock.shutdown(); } @Test @@ -155,9 +158,9 @@ class IgdbWrapperTest { void findPossibleMatchingTitles() throws InterruptedException { Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() .addAllGames(List.of( - Igdb.Game.newBuilder().setSlug("game_slug_1").setName("title_1").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(1)).build(), - Igdb.Game.newBuilder().setSlug("game_slug_2").setName("title_2").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(2)).build(), - Igdb.Game.newBuilder().setSlug("game_slug_3").setName("title_3").setFirstReleaseDate(Timestamp.newBuilder().setSeconds(3)).build())) + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) .build(); String gameTitle = gameResult.getGames(0).getName(); @@ -181,7 +184,117 @@ class IgdbWrapperTest { } @Test - void searchForGameByTitle() { + void searchForGameByTitle_exactMatch() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = gameResult.getGames(0).getName(); + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getName()).isEqualTo(searchTerm); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void searchForGameByTitle_EndsWith() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("some_prefix title_1").build(), + Igdb.Game.newBuilder().setName("title_2)").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = "title_1"; + + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName()); + + RecordedRequest r = igdbApiMock.takeRequest(); + assertThat(r.getRequestUrl()).isNotNull(); + assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery); + } + + @Test + void searchForGameByTitle_Brackets() throws InterruptedException { + Igdb.GameResult gameResult = Igdb.GameResult.newBuilder() + .addAllGames(List.of( + Igdb.Game.newBuilder().setName("title_1").build(), + Igdb.Game.newBuilder().setName("title_2").build(), + Igdb.Game.newBuilder().setName("title_3").build())) + .build(); + + String searchTerm = gameResult.getGames(0).getName() + " (Text in brackets should be ignored)"; + + // First request should result in an empty response + igdbApiMock.enqueue(new MockResponse().setHeader("Content-Type", "application/protobuf")); + + // Second request should contain the same query, but with brackets removed + igdbApiMock.enqueue(new MockResponse() + .setBody(toBuffer(gameResult)) + .setHeader("Content-Type", "application/protobuf") + ); + + Optional gameOptional = target.searchForGameByTitle(searchTerm); + + assertThat(gameOptional).isPresent(); + + Igdb.Game game = gameOptional.get(); + + // Result should be game with title equal to search term with brackets removed + assertThat(game.getName()).isEqualTo(gameResult.getGames(0).getName()); + + // First query (should contain brackets) + RecordedRequest r1 = igdbApiMock.takeRequest(); + assertThat(r1.getRequestUrl()).isNotNull(); + assertThat(r1.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String r1_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r1.getBody().readUtf8()).isEqualTo(r1_expectedQuery); + + // Second query (should not contain brackets) + RecordedRequest r2 = igdbApiMock.takeRequest(); + assertThat(r2.getRequestUrl()).isNotNull(); + assertThat(r2.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF)); + + String r2_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(gameResult.getGames(0).getName(), IgdbApiProperties.GAME_QUERY_FIELDS_STRING); + + assertThat(r2.getBody().readUtf8()).isEqualTo(r2_expectedQuery); } private static Buffer toBuffer(Message input) { From cb7c8c8e004b933946ddf7e46a7e946853f5951d Mon Sep 17 00:00:00 2001 From: shawly Date: Tue, 18 Oct 2022 22:05:16 +0200 Subject: [PATCH 06/15] feat(download): add file size calculation for DownloadService This allows the browser to show file size and a time estimate for downloading files. Does not work for ZipOutputStreams though since dir size doesn't match the zip file size. Also added no-cache headers so browser won't start caching downloads. --- .../grimsi/gameyfin/rest/GamesController.java | 18 ++++++++++++++++- .../gameyfin/service/DownloadService.java | 20 +++++++++++++++++++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java index 156988a..efb1186 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java @@ -5,11 +5,16 @@ import de.grimsi.gameyfin.entities.DetectedGame; import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.GameService; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Map; @@ -50,10 +55,21 @@ public class GamesController { DetectedGame game = gameService.getDetectedGame(slug); String downloadFileName = downloadService.getDownloadFileName(game); + long downloadFileSize = downloadService.getDownloadFileSize(game); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"%s\"".formatted(downloadFileName)); + headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); + headers.add(HttpHeaders.PRAGMA, "no-cache"); + headers.add(HttpHeaders.EXPIRES, "0"); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + if (downloadFileSize > 0) { + headers.setContentLength(downloadFileSize); + } return ResponseEntity .ok() - .header("Content-Disposition", "attachment; filename=\"%s\"".formatted(downloadFileName)) + .headers(headers) .body(out -> downloadService.sendGamefilesToClient(game, out)); } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java index 563e522..c29fe20 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/DownloadService.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.catalina.connector.ClientAbortException; +import org.apache.commons.io.FileUtils; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StopWatch; @@ -34,6 +35,23 @@ public class DownloadService { return getFilenameWithExtension(path) + ".zip"; } + public long getDownloadFileSize(DetectedGame game) { + Path path = Path.of(game.getPath()); + + try { + if (!path.toFile().isDirectory()) { + long fileSize = filesystemService.getSizeOnDisk(path); + log.info("Calculated file size for {} ({} MB).", path, Math.divideExact(fileSize, 1000000L)); + return fileSize; + } else { + // return zero since we cannot set content length for ZipOutputStreams that are used to archive directories + return 0; + } + } catch (IOException e) { + throw new DownloadAbortedException(); + } + } + public Resource sendImageToClient(String imageId) { String filename = "%s.png".formatted(imageId); return filesystemService.getFileFromCache(filename); @@ -78,6 +96,7 @@ public class DownloadService { } private void sendGamefilesAsZipToClient(Path path, OutputStream outputStream) { + log.info("Archiving game path {} for download...", path); ZipOutputStream zos = new ZipOutputStream(outputStream) {{ def.setLevel(Deflater.NO_COMPRESSION); }}; @@ -87,6 +106,7 @@ public class DownloadService { @SneakyThrows public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { zos.putNextEntry(new ZipEntry(path.relativize(file).toString())); + log.debug("Adding file {} to archive...", file); Files.copy(file, zos); zos.closeEntry(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ef089f2..0c909b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "dependencies": { "@angular/animations": "^14.0.0", "@angular/cdk": "^14.1.0", diff --git a/frontend/package.json b/frontend/package.json index cf5b2db..6734fcf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.2.3-SNAPSHOT", + "version": "1.2.4-SNAPSHOT", "scripts": { "ng": "ng", "start": "ng serve", From 130ec4565d3abbf37a719afa7aaac84f19d611bb Mon Sep 17 00:00:00 2001 From: shawly Date: Wed, 19 Oct 2022 23:35:24 +0200 Subject: [PATCH 07/15] feat(refresh): added refresh button on game detail view to refresh metadata Resolves grimsi/gameyfin#45 --- .../grimsi/gameyfin/rest/GamesController.java | 7 ++++++- .../grimsi/gameyfin/service/GameService.java | 15 +++++++++++++-- frontend/src/app/api/GamesApi.ts | 1 + .../game-detail-view.component.html | 3 +++ .../game-detail-view.component.ts | 18 ++++++++++++++++++ frontend/src/app/services/games.service.ts | 4 ++++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java index 156988a..25661f8 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/GamesController.java @@ -44,7 +44,7 @@ public class GamesController { return gameService.getAllMappings(); } - @GetMapping(value="/game/{slug}/download") + @GetMapping(value = "/game/{slug}/download") public ResponseEntity downloadGameFiles(@PathVariable String slug) { DetectedGame game = gameService.getDetectedGame(slug); @@ -57,4 +57,9 @@ public class GamesController { .body(out -> downloadService.sendGamefilesToClient(game, out)); } + @GetMapping(value = "/game/{slug}/refresh", produces = MediaType.APPLICATION_JSON_VALUE) + public DetectedGame refreshGame(@PathVariable String slug) { + return gameService.refreshGame(slug); + } + } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java index 3fd2a82..0c36729 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java @@ -9,6 +9,7 @@ import de.grimsi.gameyfin.mapper.GameMapper; import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.repositories.UnmappableFileRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -20,6 +21,7 @@ import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor +@Slf4j @Service public class GameService { @@ -87,6 +89,17 @@ public class GameService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Path '%s' not in database".formatted(path)); } + public DetectedGame refreshGame(String slug) { + Optional optionalDetectedGame = detectedGameRepository.findById(slug); + + if (optionalDetectedGame.isPresent()) { + log.info("Refreshing game with slug '{}'", slug); + return mapDetectedGame(optionalDetectedGame.get(), slug); + } + + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' not found in database".formatted(slug)); + } + private DetectedGame mapUnmappableFile(UnmappableFile unmappableFile, String slug) { Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); @@ -106,8 +119,6 @@ public class GameService { DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath())); game = detectedGameRepository.save(game); - detectedGameRepository.deleteById(existingGame.getSlug()); - return game; } } diff --git a/frontend/src/app/api/GamesApi.ts b/frontend/src/app/api/GamesApi.ts index b198553..aa24c14 100644 --- a/frontend/src/app/api/GamesApi.ts +++ b/frontend/src/app/api/GamesApi.ts @@ -7,4 +7,5 @@ export interface GamesApi { getGame(slug: String): Observable; getGameOverviews(): Observable; getAllGameMappings(): Observable>; + refreshGame(slug: String): Observable; } diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.html b/frontend/src/app/components/game-detail-view/game-detail-view.component.html index 8d2d39b..e9798f7 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.html +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.html @@ -33,6 +33,9 @@
+ diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts index 444f1b0..88f6db7 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts @@ -48,6 +48,24 @@ export class GameDetailViewComponent { this.gamesService.downloadGame(this.game.slug); } + public refreshGame(): void { + this.gamesService.refreshGame(this.game.slug).subscribe({ + next: game => { + this.game = game; + if(game.companies !== undefined) { + this.companiesWithLogo = game.companies.filter(c => c.logoId !== undefined && c.logoId.length > 0); + } + }, + error: error => { + if (error.status === 404) { + this.router.navigate(['/library']); + } else { + console.error(error); + } + } + }); + } + public bytesAsHumanReadableString(bytes: number): string { const thresh = 1024; diff --git a/frontend/src/app/services/games.service.ts b/frontend/src/app/services/games.service.ts index 87bd6f4..ea7a5b8 100644 --- a/frontend/src/app/services/games.service.ts +++ b/frontend/src/app/services/games.service.ts @@ -92,6 +92,10 @@ export class GamesService implements GamesApi { window.open(`v1${this.apiPath}/game/${slug}/download`, '_top'); } + refreshGame(slug: String): Observable { + return this.http.get(`${this.apiPath}/game/${slug}/refresh`); + } + getGameOverviews(): Observable { return this.http.get(`${this.apiPath}/game-overviews`); } From 46b8ddcd2aa638422934eb28e06f6aa69d60dd88 Mon Sep 17 00:00:00 2001 From: shawly Date: Thu, 20 Oct 2022 00:34:19 +0200 Subject: [PATCH 08/15] fix(game-detail): prevent overflow of long company logos Fixes grimsi/gameyfin#55 --- .../game-detail-view/game-detail-view.component.html | 6 +++--- .../game-detail-view/game-detail-view.component.scss | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.html b/frontend/src/app/components/game-detail-view/game-detail-view.component.html index 8d2d39b..692a2cf 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.html +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.html @@ -3,7 +3,7 @@
- +
Game cover
@@ -20,8 +20,8 @@

Developed by

-
- {{company.name}} +
+ {{company.name}}
diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.scss b/frontend/src/app/components/game-detail-view/game-detail-view.component.scss index e69de29..fda9bd6 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.scss +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.scss @@ -0,0 +1,8 @@ +.mat-card { +// min-height: max-content; + + .company-logos img { + max-height: 52px; + max-width: 260px; + } +} From c5b167d0c384d8df133420acd99d7c938500d3a0 Mon Sep 17 00:00:00 2001 From: Simon Grimme Date: Thu, 20 Oct 2022 12:17:54 +0300 Subject: [PATCH 09/15] Implement some more test cases for ProtobufUtil and FilenameUtil --- backend/pom.xml | 6 ++ .../de/grimsi/gameyfin/igdb/IgdbWrapper.java | 7 +- .../gameyfin/util/FilenameUtilTest.java | 93 +++++++++++++++++++ .../gameyfin/util/ProtobufUtilTest.java | 20 ++++ .../src/test/resources/application-test.yml | 4 +- 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java create mode 100644 backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 2b5633f..ee8d887 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -141,6 +141,12 @@ mockwebserver test + + com.google.jimfs + jimfs + 1.2 + test + diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index 9b30bc5..c65d45b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -9,7 +9,6 @@ import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -111,7 +110,7 @@ public class IgdbWrapper { Igdb.GameResult.class ); - if(gameResult == null) return Collections.emptyList(); + if (gameResult == null) return Collections.emptyList(); return gameResult.getGamesList().stream().map(gameMapper::toAutocompleteSuggestionDto).toList(); } @@ -129,10 +128,10 @@ public class IgdbWrapper { // Try to remove brackets (and their content) at the end of the search term and search again // Although this process is recursive, we will only end up with a maximum recursion depth of two - Pattern brackets = Pattern.compile ("[()<>{}\\[\\]]"); + Pattern brackets = Pattern.compile("[()<>{}\\[\\]]"); Matcher hasBrackets = brackets.matcher(searchTerm); - if(hasBrackets.find()) { + if (hasBrackets.find()) { String searchTermWithoutBrackets = searchTerm.split(brackets.pattern())[0].trim(); log.warn("Trying again with search term '{}'", searchTermWithoutBrackets); return searchForGameByTitle(searchTermWithoutBrackets); diff --git a/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java new file mode 100644 index 0000000..9ea385b --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java @@ -0,0 +1,93 @@ +package de.grimsi.gameyfin.util; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Named.named; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class FilenameUtilTest { + + private static final FileSystem unixFS = Jimfs.newFileSystem(Configuration.unix()); + private static final FileSystem osxFS = Jimfs.newFileSystem(Configuration.osX()); + private static final FileSystem winFS = Jimfs.newFileSystem(Configuration.windows()); + private static final List gameFileExtensions = List.of("extension_1", "extension_2", "extension_3"); + + @AfterAll + static void close() throws IOException { + unixFS.close(); + osxFS.close(); + winFS.close(); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithoutExtension_File(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + String result = FilenameUtil.getFilenameWithoutExtension(p); + + assertThat(result).isEqualTo(filename); + } + + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithoutExtension_Folder(FileSystem fileSystem) throws IOException { + String filename = "example_folder"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createDirectory(p); + + String result = FilenameUtil.getFilenameWithoutExtension(p); + + assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0))); + } + + @Test + void getFilenameWithExtension_Unix() { + } + + @Test + void getFilenameWithExtension_OSX() { + } + + @Test + void getFilenameWithExtension_Windows() { + } + + @Test + void hasGameArchiveExtension_Unix() { + } + + @Test + void hasGameArchiveExtension_OSX() { + } + + @Test + void hasGameArchiveExtension_Windows() { + } + + private static Stream fileSystems() { + return Stream.of( + arguments(named("Unix", unixFS)), + arguments(named("OSX", osxFS)), + arguments(named("Windows", winFS)) + ); + } +} \ No newline at end of file diff --git a/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java b/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java new file mode 100644 index 0000000..240b53b --- /dev/null +++ b/backend/src/test/java/de/grimsi/gameyfin/util/ProtobufUtilTest.java @@ -0,0 +1,20 @@ +package de.grimsi.gameyfin.util; + +import com.google.protobuf.Timestamp; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProtobufUtilTest { + + @Test + void toInstant() { + Timestamp t = Timestamp.newBuilder().setSeconds(1).build(); + + Instant i = ProtobufUtil.toInstant(t); + + assertThat(i.getEpochSecond()).isEqualTo(1); + } +} \ No newline at end of file diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 8702211..c02f7cf 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -2,6 +2,4 @@ gameyfin: igdb: api: client-id: igdb_client_id - client-secret: igdb_client_secret - config: - preferred-platforms: 6 + client-secret: igdb_client_secret \ No newline at end of file From f908785891795f8dfcd8faf8c3f8ad55e0a895d6 Mon Sep 17 00:00:00 2001 From: Simon Grimme Date: Thu, 20 Oct 2022 14:31:59 +0300 Subject: [PATCH 10/15] Finished FilenameUtilTest --- .../gameyfin/util/FilenameUtilTest.java | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java index 9ea385b..306dcf5 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/util/FilenameUtilTest.java @@ -2,14 +2,18 @@ package de.grimsi.gameyfin.util; import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; +import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -24,10 +28,16 @@ class FilenameUtilTest { private static final FileSystem unixFS = Jimfs.newFileSystem(Configuration.unix()); private static final FileSystem osxFS = Jimfs.newFileSystem(Configuration.osX()); private static final FileSystem winFS = Jimfs.newFileSystem(Configuration.windows()); + private static final List gameFileExtensions = List.of("extension_1", "extension_2", "extension_3"); + @BeforeAll + static void init() { + new FilenameUtil().setPossibleGameFileExtensions(gameFileExtensions); + } + @AfterAll - static void close() throws IOException { + static void closeFileSystems() throws IOException { unixFS.close(); osxFS.close(); winFS.close(); @@ -44,6 +54,8 @@ class FilenameUtilTest { String result = FilenameUtil.getFilenameWithoutExtension(p); assertThat(result).isEqualTo(filename); + + Files.deleteIfExists(p); } @ParameterizedTest @@ -57,30 +69,49 @@ class FilenameUtilTest { String result = FilenameUtil.getFilenameWithoutExtension(p); assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0))); + + Files.deleteIfExists(p); } - @Test - void getFilenameWithExtension_Unix() { + @ParameterizedTest + @MethodSource("fileSystems") + void getFilenameWithExtension(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + String result = FilenameUtil.getFilenameWithExtension(p); + + assertThat(result).isEqualTo("%s.%s".formatted(filename, gameFileExtensions.get(0))); + + Files.deleteIfExists(p); } - @Test - void getFilenameWithExtension_OSX() { + @ParameterizedTest + @MethodSource("fileSystems") + void hasGameArchiveExtension_gameArchive(FileSystem fileSystem) throws IOException { + String filename = "example_file"; + + Path p = fileSystem.getPath("%s.%s".formatted(filename, gameFileExtensions.get(0))); + Files.createFile(p); + + assertThat(FilenameUtil.hasGameArchiveExtension(p)).isTrue(); + + Files.deleteIfExists(p); } - @Test - void getFilenameWithExtension_Windows() { - } + @ParameterizedTest + @MethodSource("fileSystems") + void hasGameArchiveExtension_notGameArchive(FileSystem fileSystem) throws IOException { + String filename = "example_file"; - @Test - void hasGameArchiveExtension_Unix() { - } + Path p = fileSystem.getPath("%s.%s".formatted(filename, "some_other_extension")); + Files.createFile(p); - @Test - void hasGameArchiveExtension_OSX() { - } + assertThat(FilenameUtil.hasGameArchiveExtension(p)).isFalse(); - @Test - void hasGameArchiveExtension_Windows() { + Files.deleteIfExists(p); } private static Stream fileSystems() { From 0c9eb90f5aba20f77a7c6e1af0d6bb5807e31c23 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 17:30:57 +0200 Subject: [PATCH 11/15] Switch CI pipeline to SonarCloud --- .github/workflows/build.yml | 27 ++++++++++++++++----------- pom.xml | 5 +++++ sonar-project.properties | 2 -- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd19a95..bccdfcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,14 +16,23 @@ jobs: steps: - name: Git checkout uses: actions/checkout@v3 - + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK uses: actions/setup-java@v3 with: java-version: '18' distribution: 'temurin' cache: 'maven' - + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Extract Maven project version id: project run: echo "GAMEYFIN_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT @@ -31,15 +40,11 @@ jobs: - name: Show extracted Maven project version run: echo "${{ steps.project.outputs.GAMEYFIN_VERSION }}" - - name: Maven build - run: mvn --batch-mode --update-snapshots package - - - name: SonarQube scan - uses: kitabisa/sonarqube-action@v1.2.0 - with: - host: https://sonarqube.grimsi.de - login: ${{ secrets.SONARQUBE_TOKEN }} - projectKey: grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=grimsi_gameyfin - name: Upload build artifact uses: actions/upload-artifact@v3 diff --git a/pom.xml b/pom.xml index 4911d82..64af3b9 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,11 @@ backend + + grimsi-github + https://sonarcloud.io + + org.springframework.boot spring-boot-starter-parent diff --git a/sonar-project.properties b/sonar-project.properties index cb0b4b5..efe7579 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,2 @@ -sonar.projectKey=grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd - # Point SONAR to the compiled Java classes sonar.java.binaries=./backend/target From 435ed2360e1aa9c427fce8c3611d915f1c61c746 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 17:44:57 +0200 Subject: [PATCH 12/15] Generate test coverage report --- backend/pom.xml | 15 +++++++++++++++ pom.xml | 50 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/backend/pom.xml b/backend/pom.xml index 0842744..511520f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -223,6 +223,21 @@ + + + + org.jacoco + jacoco-maven-plugin + + + report + + report-aggregate + + verify + + + diff --git a/pom.xml b/pom.xml index 64af3b9..3bd169b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - + 4.0.0 de.grimsi @@ -18,30 +19,69 @@ grimsi-github https://sonarcloud.io + 0.8.8 + 2.5.3 org.springframework.boot spring-boot-starter-parent 2.7.4 - + scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git - v1.2.2 + + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + + report + + + + + + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + org.apache.maven.plugins maven-release-plugin - 2.5.3 + ${maven-release-plugin.version} - [ci skip] + [ci skip] v@{project.version} frontend/package.json From 24656ea0756f04da4b6120094544caec3c3b2089 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 17:58:01 +0200 Subject: [PATCH 13/15] Switch to SonarCloud --- .github/workflows/build.yml | 36 ++++++++++++++++++++++-------------- pom.xml | 6 +++++- sonar-project.properties | 2 +- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd19a95..b24b904 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,30 +16,38 @@ jobs: steps: - name: Git checkout uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up JDK uses: actions/setup-java@v3 with: java-version: '18' distribution: 'temurin' - cache: 'maven' - + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Extract Maven project version id: project run: echo "GAMEYFIN_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_OUTPUT - - - name: Show extracted Maven project version - run: echo "${{ steps.project.outputs.GAMEYFIN_VERSION }}" - - name: Maven build - run: mvn --batch-mode --update-snapshots package - - - name: SonarQube scan - uses: kitabisa/sonarqube-action@v1.2.0 - with: - host: https://sonarqube.grimsi.de - login: ${{ secrets.SONARQUBE_TOKEN }} - projectKey: grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - name: Upload build artifact uses: actions/upload-artifact@v3 diff --git a/pom.xml b/pom.xml index 4911d82..c2c5cf9 100644 --- a/pom.xml +++ b/pom.xml @@ -26,9 +26,13 @@ scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git scm:git:https://github.com/grimsi/gameyfin.git - v1.2.2 + + grimsi-github + https://sonarcloud.io + + diff --git a/sonar-project.properties b/sonar-project.properties index cb0b4b5..3593a3c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.projectKey=grimsi_gameyfin_AYPM67pzsxiaNzCh9BZd +sonar.projectKey=grimsi_gameyfin # Point SONAR to the compiled Java classes sonar.java.binaries=./backend/target From 3bfb9acb4134e7bf292dc42cccc4056c283220fd Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 18:38:45 +0200 Subject: [PATCH 14/15] Fix JaCoCo path --- sonar-project.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index efe7579..94f3f62 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,5 @@ # Point SONAR to the compiled Java classes sonar.java.binaries=./backend/target + +# Point SONAR to the JaCoCo report +sonar.coverage.jacoco.xmlReportPaths=./backend/target/site/jacoco-aggregate/jacoco.xml From 4d4c0d7afc0f7b2ba2b0cb1589fa0073356c1ed0 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Fri, 21 Oct 2022 18:47:06 +0200 Subject: [PATCH 15/15] Fix JaCoCo maven plugin version --- backend/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/pom.xml b/backend/pom.xml index ce82109..5247c41 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -259,6 +259,7 @@ org.jacoco jacoco-maven-plugin + 0.8.8 report