diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/VersionInfo.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/VersionInfo.java index 1bd545883..d679518cd 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/VersionInfo.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/VersionInfo.java @@ -1,5 +1,16 @@ package com.adityachandel.booklore.model.dto; -public record VersionInfo(String current, String latest) { +import lombok.Getter; + + +@Getter +public class VersionInfo { + private final String current; + private final String latest; + + public VersionInfo(String current, String latest) { + this.current = current; + this.latest = latest; + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/VersionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/VersionService.java index 6091b29c1..89d04bbc0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/VersionService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/VersionService.java @@ -19,33 +19,42 @@ import java.util.List; public class VersionService { @Value("${app.version:unknown}") - private String appVersion; + String appVersion; private static final String GITHUB_REPO = "booklore-app/booklore"; + private static final String BASE_URI = "https://api.github.com/repos/" + GITHUB_REPO; + private static final int MAX_RELEASES = 15; + private static final RestClient REST_CLIENT = RestClient.builder() + .defaultHeader("Accept", "application/vnd.github+json") + .defaultHeader("User-Agent", "BookLore-Version-Checker") + .build(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + public VersionInfo getVersionInfo() { - String latestVersion = fetchLatestGitHubReleaseVersion(); - return new VersionInfo(appVersion, latestVersion); + String latest = "unknown"; + try { + latest = fetchLatestGitHubReleaseVersion(); + } catch (Exception e) { + log.error("Error fetching latest release version", e); + } + return new VersionInfo(appVersion, latest); } public List getChangelogSinceCurrentVersion() { return fetchReleaseNotesSince(appVersion); } - private String fetchLatestGitHubReleaseVersion() { - try { - RestClient restClient = RestClient.builder() - .defaultHeader("Accept", "application/vnd.github+json") - .defaultHeader("User-Agent", "BookLore-Version-Checker") - .build(); - String response = restClient.get() - .uri("https://api.github.com/repos/" + GITHUB_REPO + "/releases/latest") + public String fetchLatestGitHubReleaseVersion() { + try { + String response = REST_CLIENT.get() + .uri(BASE_URI + "/releases/latest") .retrieve() .body(String.class); - JsonNode root = new ObjectMapper().readTree(response); - return root.has("tag_name") ? root.get("tag_name").asText() : "unknown"; + JsonNode root = MAPPER.readTree(response); + return root.path("tag_name").asText("unknown"); } catch (Exception e) { log.error("Failed to fetch latest release version", e); @@ -53,41 +62,38 @@ public class VersionService { } } - private List fetchReleaseNotesSince(String currentVersion) { + public List fetchReleaseNotesSince(String currentVersion) { + log.info("Fetching release notes since version: {}", currentVersion); + List updates = new ArrayList<>(); try { - RestClient restClient = RestClient.builder() - .defaultHeader("Accept", "application/vnd.github+json") - .defaultHeader("User-Agent", "BookLore-Version-Checker") - .build(); - - String response = restClient.get() - .uri("https://api.github.com/repos/" + GITHUB_REPO + "/releases") + String response = REST_CLIENT.get() + .uri(BASE_URI + "/releases?per_page=" + MAX_RELEASES) .retrieve() .body(String.class); - ObjectMapper mapper = new ObjectMapper(); - JsonNode releases = mapper.readTree(response); + JsonNode releases = MAPPER.readTree(response); + if (!releases.isArray()) { + log.warn("Invalid releases response from GitHub API"); + return updates; + } for (JsonNode release : releases) { - String tag = release.get("tag_name").asText(); - if (isVersionGreater(tag, currentVersion)) { - String url = "https://github.com/" + GITHUB_REPO + "/releases/tag/" + tag; - String publishedAtStr = release.get("published_at").asText(); - LocalDateTime publishedAt = LocalDateTime.parse(publishedAtStr, DateTimeFormatter.ISO_DATE_TIME); - - updates.add(new ReleaseNote( - tag, - release.get("name").asText(), - release.get("body").asText(), - url, - publishedAt - )); + String tag = release.path("tag_name").asText(null); + if (tag == null || !isVersionGreater(tag, currentVersion)) { + continue; } + String url = BASE_URI + "/releases/tag/" + tag; + LocalDateTime published = LocalDateTime.parse(release.path("published_at").asText(), DateTimeFormatter.ISO_DATE_TIME); + updates.add(new ReleaseNote(tag, release.path("name").asText(tag), release.path("body").asText(""), url, published)); } + + log.info("Returning {} newer releases", updates.size()); + } catch (Exception e) { log.error("Failed to fetch release notes", e); } + return updates; } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/VersionServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/VersionServiceTest.java new file mode 100644 index 000000000..da5cf2514 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/VersionServiceTest.java @@ -0,0 +1,163 @@ +package com.adityachandel.booklore.service; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.List; + +import com.adityachandel.booklore.model.dto.ReleaseNote; +import com.adityachandel.booklore.model.dto.VersionInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.assertj.core.api.Assertions.assertThat; + +class VersionServiceTest { + + private VersionService service; + private VersionService spyService; + + @BeforeEach + void setUp() { + service = new VersionService(); + spyService = Mockito.spy(service); + } + + + @Nested + class VersionComparison { + + private Method cmp; + + @BeforeEach + void init() throws Exception { + cmp = VersionService.class + .getDeclaredMethod("isVersionGreater", String.class, String.class); + cmp.setAccessible(true); + } + + @Test + void returnsTrueWhenMajorIncreases() throws Exception { + assertThat((Boolean) cmp.invoke(service, "2.0.0", "1.9.9")) + .isTrue(); + } + + @Test + void returnsFalseWhenMajorDecreases() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.0.0", "2.0.0")) + .isFalse(); + } + + @Test + void returnsTrueForPatchIncrease() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.0.1", "1.0.0")) + .isTrue(); + } + + @Test + void returnsFalseForPatchDecrease() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.0.0", "1.0.1")) + .isFalse(); + } + + @Test + void returnsFalseWhenEqual() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.2.3", "1.2.3")) + .isFalse(); + } + + @Test + void handlesDifferentLengthVersions() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.2.0", "1.1")) + .isTrue(); + assertThat((Boolean) cmp.invoke(service, "1.0", "1.0.1")) + .isFalse(); + } + + @Test + void ignoresPrefixAndSafelyHandlesInvalid() throws Exception { + assertThat((Boolean) cmp.invoke(service, "v1.10", "v1.9.9")) + .isTrue(); + assertThat((Boolean) cmp.invoke(service, "x.y", "1.0")) + .isFalse(); + } + + @Test + void returnsFalseWhenVersion2IsNull() throws Exception { + assertThat((Boolean) cmp.invoke(service, "1.0.0", null)) + .isFalse(); + } + + @Test + void returnsFalseWhenVersion1IsNull() throws Exception { + assertThat((Boolean) cmp.invoke(service, null, "1.0.0")) + .isFalse(); + } + + @Test + void returnsFalseWhenBothVersionsNull() throws Exception { + assertThat((Boolean) cmp.invoke(service, null, null)) + .isFalse(); + } + } + + + @Nested + class GetVersionInfoTests { + + @Test + void includesAppAndLatestOnSuccess() { + Mockito.doReturn("v9.9.9") + .when(spyService) + .fetchLatestGitHubReleaseVersion(); + + VersionInfo info = spyService.getVersionInfo(); + + assertThat(info.getCurrent()) + .isEqualTo(service.appVersion); + assertThat(info.getLatest()) + .isEqualTo("v9.9.9"); + } + + @Test + void usesUnknownIfFetchFails() { + Mockito.doThrow(new RuntimeException("fail")) + .when(spyService) + .fetchLatestGitHubReleaseVersion(); + + VersionInfo info = spyService.getVersionInfo(); + + assertThat(info.getLatest()) + .isEqualTo("unknown"); + } + } + + + @Nested + class GetChangelogSinceCurrentVersionTests { + + @Test + void returnsNotesWhenAvailable() { + LocalDateTime now = LocalDateTime.now(); + ReleaseNote note = new ReleaseNote("v1.1", "n", "b", "u", now); + + Mockito.doReturn(List.of(note)) + .when(spyService) + .fetchReleaseNotesSince(service.appVersion); + + List result = spyService.getChangelogSinceCurrentVersion(); + assertThat(result).hasSize(1).containsExactly(note); + } + + @Test + void returnsEmptyListWhenNoNewReleases() { + Mockito.doReturn(List.of()) + .when(spyService) + .fetchReleaseNotesSince(service.appVersion); + + var result = spyService.getChangelogSinceCurrentVersion(); + assertThat(result).isEmpty(); + } + } +} \ No newline at end of file