From aed4f640ce4a038df3772038ba4bde68a293a3e0 Mon Sep 17 00:00:00 2001 From: Madhura Bhave Date: Wed, 6 Nov 2024 21:06:12 -0800 Subject: [PATCH] Improve error handling This commit also breaks out Github operations for fetch into a new class. Exceptions that are encountered when loading the cache are ignored so that cache creation does not fail. Exceptions encountered on writes to Github are bubbled up and handled using a ControllerAdvice. --- .../io/spring/projectapi/Application.java | 10 + .../spring/projectapi/ProjectRepository.java | 66 +----- .../projectapi/github/GithubOperations.java | 103 ++-------- .../github/GithubProjectRepository.java | 82 ++++++++ .../projectapi/github/GithubQueries.java | 191 ++++++++++++++++++ .../github/NoSuchGithubProjectException.java | 6 + .../spring/projectapi/github/ProjectData.java | 45 +++++ .../web/release/ReleasesController.java | 2 +- .../github/GithubOperationsTests.java | 90 --------- .../github/GithubProjectRepositoryTests.java | 174 ++++++++++++++++ .../projectapi/github/GithubQueriesTests.java | 172 ++++++++++++++++ .../ProjectDetailsControllerTests.java | 2 - .../web/release/ReleasesControllerTests.java | 10 +- .../web/webhook/CacheControllerTests.java | 124 ++++++++++++ 14 files changed, 831 insertions(+), 246 deletions(-) create mode 100644 src/main/java/io/spring/projectapi/github/GithubProjectRepository.java create mode 100644 src/main/java/io/spring/projectapi/github/GithubQueries.java create mode 100644 src/main/java/io/spring/projectapi/github/ProjectData.java create mode 100644 src/test/java/io/spring/projectapi/github/GithubProjectRepositoryTests.java create mode 100644 src/test/java/io/spring/projectapi/github/GithubQueriesTests.java create mode 100644 src/test/java/io/spring/projectapi/web/webhook/CacheControllerTests.java diff --git a/src/main/java/io/spring/projectapi/Application.java b/src/main/java/io/spring/projectapi/Application.java index 6dd671f..e5369df 100644 --- a/src/main/java/io/spring/projectapi/Application.java +++ b/src/main/java/io/spring/projectapi/Application.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.spring.projectapi.ApplicationProperties.Github; import io.spring.projectapi.github.GithubOperations; +import io.spring.projectapi.github.GithubQueries; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -39,6 +40,15 @@ public GithubOperations githubOperations(RestTemplateBuilder builder, ObjectMapp return new GithubOperations(builder, objectMapper, accessToken, branch); } + @Bean + public GithubQueries githubQueries(RestTemplateBuilder builder, ObjectMapper objectMapper, + ApplicationProperties properties) { + Github github = properties.getGithub(); + String accessToken = github.getAccesstoken(); + String branch = github.getBranch(); + return new GithubQueries(builder, objectMapper, accessToken, branch); + } + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/io/spring/projectapi/ProjectRepository.java b/src/main/java/io/spring/projectapi/ProjectRepository.java index e24204a..8228860 100644 --- a/src/main/java/io/spring/projectapi/ProjectRepository.java +++ b/src/main/java/io/spring/projectapi/ProjectRepository.java @@ -17,80 +17,30 @@ package io.spring.projectapi; import java.util.Collection; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import io.spring.projectapi.github.GithubOperations; import io.spring.projectapi.github.Project; import io.spring.projectapi.github.ProjectDocumentation; import io.spring.projectapi.github.ProjectSupport; import io.spring.projectapi.web.webhook.CacheController; -import org.springframework.stereotype.Component; - /** - * Caches Github project information. Populated on start up and updates triggered via - * {@link CacheController}. + * Stores project information. Updates triggered via {@link CacheController}. * * @author Madhura Bhave - * @author Phillip Webb */ -@Component -public class ProjectRepository { - - private final GithubOperations githubOperations; - - private transient Data data; - - public ProjectRepository(GithubOperations githubOperations) { - this.githubOperations = githubOperations; - this.data = Data.load(githubOperations); - } - - public void update() { - this.data = Data.load(this.githubOperations); - } - - public Collection getProjects() { - return this.data.project().values(); - } - - public Project getProject(String projectSlug) { - return this.data.project().get(projectSlug); - } +public interface ProjectRepository { - public List getProjectDocumentations(String projectSlug) { - return this.data.documentation().get(projectSlug); - } + void update(); - public List getProjectSupports(String projectSlug) { - return this.data.support().get(projectSlug); - } + Collection getProjects(); - public String getProjectSupportPolicy(String projectSlug) { - return this.data.supportPolicy().get(projectSlug); - } + Project getProject(String projectSlug); - record Data(Map project, Map> documentation, - Map> support, Map supportPolicy) { + List getProjectDocumentations(String projectSlug); - public static Data load(GithubOperations githubOperations) { - Map projects = new LinkedHashMap<>(); - Map> documentation = new LinkedHashMap<>(); - Map> support = new LinkedHashMap<>(); - Map supportPolicy = new LinkedHashMap<>(); - githubOperations.getProjects().forEach((project) -> { - String slug = project.getSlug(); - projects.put(slug, project); - documentation.put(slug, githubOperations.getProjectDocumentations(slug)); - support.put(slug, githubOperations.getProjectSupports(slug)); - supportPolicy.put(slug, githubOperations.getProjectSupportPolicy(slug)); - }); - return new Data(Map.copyOf(projects), Map.copyOf(documentation), Map.copyOf(support), - Map.copyOf(supportPolicy)); - } + List getProjectSupports(String projectSlug); - } + String getProjectSupportPolicy(String projectSlug); } diff --git a/src/main/java/io/spring/projectapi/github/GithubOperations.java b/src/main/java/io/spring/projectapi/github/GithubOperations.java index 553c7f8..8444de6 100644 --- a/src/main/java/io/spring/projectapi/github/GithubOperations.java +++ b/src/main/java/io/spring/projectapi/github/GithubOperations.java @@ -39,7 +39,6 @@ import org.slf4j.LoggerFactory; import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.cache.annotation.Cacheable; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -59,9 +58,6 @@ public class GithubOperations { private static final TypeReference<@NotNull List> DOCUMENTATION_LIST = new TypeReference<>() { }; - private static final TypeReference> SUPPORT_LIST = new TypeReference<>() { - }; - private static final String GITHUB_URI = "https://api.github.com/repos/spring-io/spring-website-content/contents"; private static final Comparator VERSION_COMPARATOR = GithubOperations::compare; @@ -78,8 +74,6 @@ public class GithubOperations { private static final String CONFIG_COMMIT_MESSAGE = "Update Spring Boot Config"; - private static final String DEFAULT_SUPPORT_POLICY = "UPSTREAM"; - private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { }; @@ -121,11 +115,19 @@ public void addProjectDocumentation(String projectSlug, ProjectDocumentation doc updateProjectDocumentation(projectSlug, updatedDocumentation, sha); } - @NotNull private List convertToProjectDocumentation(String content) { return readValue(content, DOCUMENTATION_LIST); } + private T readValue(String contents, TypeReference type) { + try { + return this.objectMapper.readValue(contents, type); + } + catch (JsonProcessingException ex) { + throw new RuntimeException(ex); + } + } + private void updateProjectDocumentation(String projectSlug, List documentations, String sha) { try { byte[] content = this.objectMapper.writer(this.prettyPrinter).writeValueAsBytes(documentations); @@ -140,14 +142,14 @@ public void patchProjectDetails(String projectSlug, ProjectDetails projectDetail throwIfProjectDoesNotExist(projectSlug); if (projectDetails.getSpringBootConfig() != null) { ResponseEntity> response = getFile(projectSlug, "springBootConfig.md"); - NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json"); + NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "springBootConfig.md"); String sha = getFileSha(response); updateContents(projectDetails.getSpringBootConfig().getBytes(), sha, projectSlug, "springBootConfig.md", CONFIG_COMMIT_MESSAGE); } if (projectDetails.getBody() != null) { ResponseEntity> response = getFile(projectSlug, "index.md"); - NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json"); + NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "index.md"); String contents = getFileContents(response); String sha = getFileSha(response); String updatedContent = MarkdownUtils.getUpdatedContent(contents, projectDetails.getBody()); @@ -218,9 +220,9 @@ private ResponseEntity> getFile(String projectSlug, String f return this.restTemplate.exchange(request, STRING_OBJECT_MAP); } catch (HttpClientErrorException ex) { - logger.info("*** Exception thrown for " + projectSlug + " and file " + fileName + " due to " - + ex.getMessage() + " with status " + ex.getStatusCode()); HttpStatusCode statusCode = ex.getStatusCode(); + logger.debug("Failed to get file " + fileName + " for project " + projectSlug + " due to " + ex.getMessage() + + " with status " + statusCode); if (statusCode.value() == 404) { throwIfProjectDoesNotExist(projectSlug); return null; @@ -253,83 +255,4 @@ private String getFileSha(ResponseEntity> exchange) { return (String) exchange.getBody().get("sha"); } - @Cacheable("projects") - public List getProjects() { - List projects = new ArrayList<>(); - try { - RequestEntity request = RequestEntity.get("/project?ref=" + this.branch).build(); - ResponseEntity>> exchange = this.restTemplate.exchange(request, - STRING_OBJECT_MAP_LIST); - InvalidGithubResponseException.throwIfInvalid(exchange); - List> body = exchange.getBody(); - body.forEach((project) -> { - String projectSlug = (String) project.get("name"); - try { - Project fetchedProject = getProject(projectSlug); - if (fetchedProject != null) { - projects.add(fetchedProject); - } - } - catch (Exception ex) { - // Ignore project without an index file - } - }); - } - catch (HttpClientErrorException ex) { - // Return empty list - } - return List.copyOf(projects); - } - - public Project getProject(String projectSlug) { - ResponseEntity> response = getFile(projectSlug, "index.md"); - if (response == null) { - return null; - } - String contents = getFileContents(response); - Map frontMatter = MarkdownUtils.getFrontMatter(contents); - InvalidGithubProjectIndexException.throwIfInvalid(Objects::nonNull, frontMatter, projectSlug); - frontMatter.put("slug", projectSlug); - return this.objectMapper.convertValue(frontMatter, Project.class); - } - - public List getProjectDocumentations(String projectSlug) { - ResponseEntity> response = getFile(projectSlug, "documentation.json"); - if (response == null) { - return Collections.emptyList(); - } - String content = getFileContents(response); - return List.copyOf(convertToProjectDocumentation(content)); - } - - public List getProjectSupports(String projectSlug) { - ResponseEntity> response = getFile(projectSlug, "support.json"); - if (response == null) { - return Collections.emptyList(); - } - String contents = getFileContents(response); - return List.copyOf(readValue(contents, SUPPORT_LIST)); - } - - private T readValue(String contents, TypeReference type) { - try { - return this.objectMapper.readValue(contents, type); - } - catch (JsonProcessingException ex) { - throw new RuntimeException(ex); - } - } - - public String getProjectSupportPolicy(String projectSlug) { - ResponseEntity> indexResponse = getFile(projectSlug, "index.md"); - if (indexResponse == null) { - return DEFAULT_SUPPORT_POLICY; - } - String indexContents = getFileContents(indexResponse); - Map frontMatter = MarkdownUtils.getFrontMatter(indexContents); - InvalidGithubProjectIndexException.throwIfInvalid(Objects::nonNull, frontMatter, projectSlug); - String supportPolicy = frontMatter.get("supportPolicy"); - return (supportPolicy != null) ? supportPolicy : DEFAULT_SUPPORT_POLICY; - } - } diff --git a/src/main/java/io/spring/projectapi/github/GithubProjectRepository.java b/src/main/java/io/spring/projectapi/github/GithubProjectRepository.java new file mode 100644 index 0000000..6cc685d --- /dev/null +++ b/src/main/java/io/spring/projectapi/github/GithubProjectRepository.java @@ -0,0 +1,82 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.github; + +import java.util.Collection; +import java.util.List; + +import io.spring.projectapi.ProjectRepository; + +import org.springframework.stereotype.Component; + +/** + * {@link ProjectRepository} backed by Github. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +@Component +class GithubProjectRepository implements ProjectRepository { + + private final GithubQueries githubQueries; + + private transient ProjectData projectData; + + GithubProjectRepository(GithubQueries githubQueries) { + this.githubQueries = githubQueries; + this.projectData = ProjectData.load(githubQueries); + } + + @Override + public void update() { + this.projectData = ProjectData.load(this.githubQueries); + } + + @Override + public Collection getProjects() { + return this.projectData.project().values(); + } + + @Override + public Project getProject(String projectSlug) { + Project project = this.projectData.project().get(projectSlug); + NoSuchGithubProjectException.throwIfNotFound(project, projectSlug); + return project; + } + + @Override + public List getProjectDocumentations(String projectSlug) { + List documentations = this.projectData.documentation().get(projectSlug); + NoSuchGithubProjectException.throwIfNotFound(documentations, projectSlug); + return documentations; + } + + @Override + public List getProjectSupports(String projectSlug) { + List projectSupports = this.projectData.support().get(projectSlug); + NoSuchGithubProjectException.throwIfNotFound(projectSupports, projectSlug); + return projectSupports; + } + + @Override + public String getProjectSupportPolicy(String projectSlug) { + String policy = this.projectData.supportPolicy().get(projectSlug); + NoSuchGithubProjectException.throwIfNotFound(policy, projectSlug); + return policy; + } + +} diff --git a/src/main/java/io/spring/projectapi/github/GithubQueries.java b/src/main/java/io/spring/projectapi/github/GithubQueries.java new file mode 100644 index 0000000..8b82cb4 --- /dev/null +++ b/src/main/java/io/spring/projectapi/github/GithubQueries.java @@ -0,0 +1,191 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.github; + +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +/** + * Central class for fetching data from Github. + * + * @author Madhura Bhave + */ +public class GithubQueries { + + private static final TypeReference<@NotNull List> DOCUMENTATION_LIST = new TypeReference<>() { + }; + + private static final TypeReference> SUPPORT_LIST = new TypeReference<>() { + }; + + private static final String GITHUB_URI = "https://api.github.com/repos/spring-io/spring-website-content/contents"; + + private static final Logger logger = LoggerFactory.getLogger(GithubOperations.class); + + private final RestTemplate restTemplate; + + private final ObjectMapper objectMapper; + + private static final String DEFAULT_SUPPORT_POLICY = "SPRING_BOOT"; + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + + private static final ParameterizedTypeReference>> STRING_OBJECT_MAP_LIST = new ParameterizedTypeReference<>() { + }; + + private final String branch; + + public GithubQueries(RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper, String token, + String branch) { + this.restTemplate = restTemplateBuilder.rootUri(GITHUB_URI) + .defaultHeader("Authorization", "Bearer " + token) + .build(); + this.branch = branch; + this.objectMapper = objectMapper; + } + + ProjectData getData() { + Map projects = new LinkedHashMap<>(); + Map> documentation = new LinkedHashMap<>(); + Map> support = new LinkedHashMap<>(); + Map supportPolicy = new LinkedHashMap<>(); + try { + RequestEntity request = RequestEntity.get("/project?ref=" + this.branch).build(); + ResponseEntity>> exchange = this.restTemplate.exchange(request, + STRING_OBJECT_MAP_LIST); + InvalidGithubResponseException.throwIfInvalid(exchange); + List> body = exchange.getBody(); + body.forEach((project) -> populateData(project, projects, documentation, support, supportPolicy)); + } + catch (Exception ex) { + logger.debug("Could not get projects due to '%s'".formatted(ex.getMessage())); + // Return empty list + } + return new ProjectData(projects, documentation, support, supportPolicy); + } + + private void populateData(Map project, Map projects, + Map> documentation, Map> support, + Map supportPolicy) { + String projectSlug = (String) project.get("name"); + ResponseEntity> response = getFile(projectSlug, "index.md"); + Project fetchedProject = getProject(response, projectSlug); + if (fetchedProject != null) { + projects.put(projectSlug, fetchedProject); + } + List projectDocumentations = getProjectDocumentations(projectSlug); + documentation.put(projectSlug, projectDocumentations); + List projectSupports = getProjectSupports(projectSlug); + support.put(projectSlug, projectSupports); + String policy = getProjectSupportPolicy(response, projectSlug); + supportPolicy.put(projectSlug, policy); + } + + private Project getProject(ResponseEntity> response, String projectSlug) { + try { + String contents = getFileContent(response); + Map frontMatter = MarkdownUtils.getFrontMatter(contents); + frontMatter.put("slug", projectSlug); + return this.objectMapper.convertValue(frontMatter, Project.class); + } + catch (Exception ex) { + logger.debug("Could not get project for '%s' due to '%s'".formatted(projectSlug, ex.getMessage())); + } + return null; + } + + private List getProjectDocumentations(String projectSlug) { + try { + ResponseEntity> response = getFile(projectSlug, "documentation.json"); + String content = getFileContent(response); + return List.copyOf(convertToProjectDocumentation(content)); + } + catch (Exception ex) { + logger.debug( + "Could not get project documentation for '%s' due to '%s'".formatted(projectSlug, ex.getMessage())); + } + return Collections.emptyList(); + } + + private List getProjectSupports(String projectSlug) { + try { + ResponseEntity> response = getFile(projectSlug, "support.json"); + String contents = getFileContent(response); + return List.copyOf(readValue(contents, SUPPORT_LIST)); + } + catch (Exception ex) { + logger.debug("Could not get project support for '%s' due to '%s'".formatted(projectSlug, ex.getMessage())); + } + return Collections.emptyList(); + } + + private String getProjectSupportPolicy(ResponseEntity> response, String projectSlug) { + try { + String content = getFileContent(response); + Map frontMatter = MarkdownUtils.getFrontMatter(content); + frontMatter.put("slug", projectSlug); + String supportPolicy = frontMatter.get("supportPolicy"); + return (supportPolicy != null) ? supportPolicy : DEFAULT_SUPPORT_POLICY; + } + catch (Exception ex) { + logger.debug("Could not get project support policy for '%s' due to '%s'".formatted(projectSlug, + ex.getMessage())); + } + return DEFAULT_SUPPORT_POLICY; + } + + private List convertToProjectDocumentation(String content) throws JsonProcessingException { + return readValue(content, DOCUMENTATION_LIST); + } + + private T readValue(String contents, TypeReference type) throws JsonProcessingException { + return this.objectMapper.readValue(contents, type); + } + + private ResponseEntity> getFile(String projectSlug, String fileName) { + RequestEntity request = RequestEntity + .get("/project/{projectSlug}/{fileName}?ref=" + this.branch, projectSlug, fileName) + .build(); + return this.restTemplate.exchange(request, STRING_OBJECT_MAP); + } + + private String getFileContent(ResponseEntity> exchange) { + String encodedContent = (String) exchange.getBody().get("content"); + String cleanedContent = StringUtils.replace(encodedContent, "\n", ""); + byte[] contents = Base64.getDecoder().decode(cleanedContent); + return new String(contents); + } + +} diff --git a/src/main/java/io/spring/projectapi/github/NoSuchGithubProjectException.java b/src/main/java/io/spring/projectapi/github/NoSuchGithubProjectException.java index 99f8651..0201b45 100644 --- a/src/main/java/io/spring/projectapi/github/NoSuchGithubProjectException.java +++ b/src/main/java/io/spring/projectapi/github/NoSuchGithubProjectException.java @@ -39,6 +39,12 @@ static void throwIfNotFound(HttpClientErrorException ex, String projectSlug) { } } + static void throwIfNotFound(Object value, String projectSlug) { + if (value == null) { + throw new NoSuchGithubProjectException(projectSlug); + } + } + public String getProjectSlug() { return this.projectSlug; } diff --git a/src/main/java/io/spring/projectapi/github/ProjectData.java b/src/main/java/io/spring/projectapi/github/ProjectData.java new file mode 100644 index 0000000..fe58321 --- /dev/null +++ b/src/main/java/io/spring/projectapi/github/ProjectData.java @@ -0,0 +1,45 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.github; + +import java.util.List; +import java.util.Map; + +/** + * Represents cached data from Github. + * + * @param project all projects + * @param documentation map of project slug to project documentations + * @param support map of project slug to project supports + * @param supportPolicy map of project slug to project support policy + * @author Phillip Webb + * @author Madhura Bhave + */ +record ProjectData(Map project, Map> documentation, + Map> support, Map supportPolicy) { + + public static ProjectData load(GithubQueries githubQueries) { + ProjectData data = githubQueries.getData(); + Map projects = data.project(); + Map> documentation = data.documentation(); + Map> support = data.support(); + Map supportPolicy = data.supportPolicy(); + return new ProjectData(Map.copyOf(projects), Map.copyOf(documentation), Map.copyOf(support), + Map.copyOf(supportPolicy)); + } + +} diff --git a/src/main/java/io/spring/projectapi/web/release/ReleasesController.java b/src/main/java/io/spring/projectapi/web/release/ReleasesController.java index 1dc0240..d93e9a8 100644 --- a/src/main/java/io/spring/projectapi/web/release/ReleasesController.java +++ b/src/main/java/io/spring/projectapi/web/release/ReleasesController.java @@ -107,7 +107,7 @@ public EntityModel current(@PathVariable String id) { @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity add(@PathVariable String id, @RequestBody NewRelease release) throws Exception { String version = release.getVersion(); - List documentations = this.githubOperations.getProjectDocumentations(id); + List documentations = this.projectRepository.getProjectDocumentations(id); if (documentations.stream().anyMatch((candidate) -> candidate.getVersion().equals(version))) { String message = "Release '%s' already present for project '%s'".formatted(version, id); return ResponseEntity.badRequest().body(message); diff --git a/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java b/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java index f91cdc3..a3efc53 100644 --- a/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java +++ b/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java @@ -18,9 +18,7 @@ import java.io.IOException; import java.io.InputStream; -import java.time.LocalDate; import java.util.Base64; -import java.util.List; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,7 +26,6 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import io.spring.projectapi.github.ProjectDocumentation.Status; -import org.hamcrest.text.MatchesPattern; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,10 +35,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.test.web.client.ExpectedCount; import org.springframework.util.FileCopyUtils; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; @@ -76,87 +71,6 @@ void setup() { "test"); } - @Test - void getProjectsReturnsProjects() throws Exception { - setupFile("project-all-response.json", "/project?ref=test"); - this.customizer.getServer() - .expect(ExpectedCount.manyTimes(), - requestTo(MatchesPattern.matchesPattern("\\/project\\/.+\\/index\\.md\\?ref\\=test"))) - .andExpect(method(HttpMethod.GET)) - .andRespond(withSuccess(from("project-index-response.json"), MediaType.APPLICATION_JSON)); - List projects = this.operations.getProjects(); - assertThat(projects.size()).isEqualTo(3); - assertThat(projects.get(0).getSlug()).isEqualTo("spring-webflow"); - } - - @Test - void getProjectsWhenNoProjectsReturnsEmpty() { - setupNoProjectDirectory(); - List projects = this.operations.getProjects(); - assertThat(projects).isEmpty(); - } - - // @Test - // void getProjectsWhenErrorThrowsException() throws Exception { - // setupResponse("query-error.json"); - // assertThatExceptionOfType(GithubException.class).isThrownBy(this.operations::getProjects); - // } - - @Test - void getProjectReturnsProject() throws Exception { - setupFile("project-index-response.json", "/project/spring-boot/index.md?ref=test"); - Project project = this.operations.getProject("spring-boot"); - assertThat(project.getSlug()).isEqualTo("spring-boot"); - } - - @Test - void getProjectWhenNoProjectMatchThrowsException() throws Exception { - setupNonExistentProject("index.md"); - assertThatExceptionOfType(NoSuchGithubProjectException.class) - .isThrownBy(() -> this.operations.getProject("does-not-exist")) - .satisfies((ex) -> assertThat(ex.getProjectSlug()).isEqualTo("does-not-exist")); - } - - @Test - void getProjectDocumentationsReturnsDocumentations() throws Exception { - setupFile("project-documentation-response.json", DOCUMENTATION_URI); - List documentations = this.operations.getProjectDocumentations("test-project"); - assertThat(documentations).hasSize(9); - } - - @Test - void getProjectDocumentationsWhenNoProjectMatchThrowsException() throws Exception { - setupNonExistentProject("documentation.json"); - assertThatExceptionOfType(NoSuchGithubProjectException.class) - .isThrownBy(() -> this.operations.getProjectDocumentations("does-not-exist")) - .satisfies((ex) -> assertThat(ex.getProjectSlug()).isEqualTo("does-not-exist")); - } - - @Test - void getProjectSupportsReturnsSupports() throws Exception { - setupFile("project-support-response.json", "/project/spring-boot/support.json?ref=test"); - setupFile("project-index-response.json", "/project/spring-boot/index.md?ref=test"); - List supports = this.operations.getProjectSupports("spring-boot"); - assertThat(supports).hasSize(14); - assertThat(supports.get(0).getInitialDate()).isEqualTo(LocalDate.parse("2017-01-30")); - } - - @Test - void getProjectSupportsWhenNoProjectMatchThrowsException() throws Exception { - setupNonExistentProject("support.json"); - assertThatExceptionOfType(NoSuchGithubProjectException.class) - .isThrownBy(() -> this.operations.getProjectSupports("does-not-exist")) - .satisfies((ex) -> assertThat(ex.getProjectSlug()).isEqualTo("does-not-exist")); - } - - @Test - void getProjectSupportsWhenNullReturnsEmptyList() { - setupResourceNotFound("/project/test-project/support.json?ref=test"); - setupProject(); - List supports = this.operations.getProjectSupports("test-project"); - assertThat(supports).isEmpty(); - } - @Test void addProjectDocumentationWhenProjectDoesNotExistThrowsException() throws Exception { setupNonExistentProject("documentation.json"); @@ -334,10 +248,6 @@ private void setupResourceNotFound(String expectedUri) { .andRespond(withResourceNotFound()); } - private void setupNoProjectDirectory() { - setupResourceNotFound("/project?ref=test"); - } - private String getEncodedContent(String path) throws Exception { return Base64.getEncoder().encodeToString(from(path)); } diff --git a/src/test/java/io/spring/projectapi/github/GithubProjectRepositoryTests.java b/src/test/java/io/spring/projectapi/github/GithubProjectRepositoryTests.java new file mode 100644 index 0000000..8063eb6 --- /dev/null +++ b/src/test/java/io/spring/projectapi/github/GithubProjectRepositoryTests.java @@ -0,0 +1,174 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.github; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import io.spring.projectapi.github.Project.Status; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.verification.VerificationMode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link GithubProjectRepository}. + */ +class GithubProjectRepositoryTests { + + private GithubProjectRepository projectRepository; + + private GithubQueries githubQueries; + + @BeforeEach + void setup() { + this.githubQueries = mock(GithubQueries.class); + setupGithubResponse("spring-boot"); + this.projectRepository = new GithubProjectRepository(this.githubQueries); + } + + @Test + void dataLoadedOnBeanCreation() { + validateCachedValues("spring-boot"); + verifyCacheUpdate(atMostOnce()); + } + + @Test + void updateRefreshesCache() { + setupGithubResponse("spring-boot-updated"); + this.projectRepository.update(); + assertThatExceptionOfType(NoSuchGithubProjectException.class) + .isThrownBy(() -> this.projectRepository.getProject("spring-boot")); + validateCachedValues("spring-boot-updated"); + verifyCacheUpdate(Mockito.atMost(2)); + } + + @Test + void getProjectsReturnsProjects() { + Collection projects = this.projectRepository.getProjects(); + assertThat(projects.size()).isEqualTo(3); + } + + @Test + void getProjectReturnsProject() { + Project project = this.projectRepository.getProject("spring-boot"); + assertThat(project.getSlug()).isEqualTo("spring-boot"); + } + + @Test + void getProjectDocumentationReturnsProjectDocumentation() { + List documentation = this.projectRepository.getProjectDocumentations("spring-boot"); + assertThat(documentation.size()).isEqualTo(2); + } + + @Test + void getProjectSupportReturnsProjectSupport() { + List support = this.projectRepository.getProjectSupports("spring-boot"); + assertThat(support.size()).isEqualTo(2); + } + + @Test + void getProjectSupportPolicyReturnsProjectSupportPolicy() { + String supportPolicy = this.projectRepository.getProjectSupportPolicy("spring-boot"); + assertThat(supportPolicy).isEqualTo("UPSTREAM"); + } + + @Test + void getProjectForNonExistentProjectThrowsException() { + assertThatExceptionOfType(NoSuchGithubProjectException.class) + .isThrownBy(() -> this.projectRepository.getProject("spring-foo")); + } + + @Test + void getProjectDocumentationForNonExistentProjectThrowsException() { + assertThatExceptionOfType(NoSuchGithubProjectException.class) + .isThrownBy(() -> this.projectRepository.getProjectDocumentations("spring-foo")); + } + + @Test + void getProjectSupportForNonExistentProjectThrowsException() { + assertThatExceptionOfType(NoSuchGithubProjectException.class) + .isThrownBy(() -> this.projectRepository.getProjectSupports("spring-foo")); + } + + @Test + void getProjectSupportPolicyForNonExistentProjectThrowsException() { + assertThatExceptionOfType(NoSuchGithubProjectException.class) + .isThrownBy(() -> this.projectRepository.getProjectSupportPolicy("spring-foo")); + } + + private void validateCachedValues(String projectSlug) { + Collection projects = this.projectRepository.getProjects(); + assertThat(projects.size()).isEqualTo(3); + Project updated = this.projectRepository.getProject(projectSlug); + assertThat(updated.getSlug()).isEqualTo(projectSlug); + List support = this.projectRepository.getProjectSupports(projectSlug); + assertThat(support).size().isEqualTo(2); + List documentations = this.projectRepository.getProjectDocumentations(projectSlug); + assertThat(documentations).size().isEqualTo(2); + String policy = this.projectRepository.getProjectSupportPolicy(projectSlug); + assertThat(policy).isEqualTo("UPSTREAM"); + } + + private void setupGithubResponse(String project) { + given(this.githubQueries.getData()).willReturn(getData(project)); + } + + private void verifyCacheUpdate(VerificationMode mode) { + verify(this.githubQueries, mode).getData(); + } + + private ProjectData getData(String project) { + return new ProjectData(getProjects(project), getProjectDocumentation(project), getProjectSupports(project), + getProjectSupportPolicy(project)); + } + + private Map getProjects(String project) { + Project project1 = new Project("Spring Boot", project, "github", Status.ACTIVE); + Project project2 = new Project("Spring Batch", "spring-batch", "github", Status.ACTIVE); + Project project3 = new Project("Spring Framework", "spring-framework", "github", Status.ACTIVE); + return Map.of(project, project1, "spring-batch", project2, "spring-framework", project3); + } + + private Map> getProjectSupports(String project) { + ProjectSupport support1 = new ProjectSupport("2.2.x", LocalDate.parse("2020-02-01"), null, null); + ProjectSupport support2 = new ProjectSupport("2.3.x", LocalDate.parse("2021-02-01"), null, null); + return Map.of(project, List.of(support1, support2)); + } + + private Map> getProjectDocumentation(String project) { + ProjectDocumentation documentation1 = new ProjectDocumentation("1.0", false, "api", "ref", + ProjectDocumentation.Status.PRERELEASE, true); + ProjectDocumentation documentation2 = new ProjectDocumentation("2.0", false, "api", "ref", + ProjectDocumentation.Status.PRERELEASE, true); + return Map.of(project, List.of(documentation1, documentation2)); + } + + private Map getProjectSupportPolicy(String project) { + return Map.of(project, "UPSTREAM"); + } + +} diff --git a/src/test/java/io/spring/projectapi/github/GithubQueriesTests.java b/src/test/java/io/spring/projectapi/github/GithubQueriesTests.java new file mode 100644 index 0000000..ef4bdb8 --- /dev/null +++ b/src/test/java/io/spring/projectapi/github/GithubQueriesTests.java @@ -0,0 +1,172 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.github; + +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import org.hamcrest.text.MatchesPattern; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.ExpectedCount; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link GithubQueries}. + */ +class GithubQueriesTests { + + private GithubQueries queries; + + private MockServerRestTemplateCustomizer customizer; + + @BeforeEach + void setup() { + this.customizer = new MockServerRestTemplateCustomizer(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES)); + objectMapper.registerModule(new JavaTimeModule()); + this.queries = new GithubQueries(new RestTemplateBuilder(this.customizer), objectMapper, "test-token", "test"); + } + + @Test + void getDataReturnsProjectData() throws Exception { + setupProjects(); + setupProjectFiles("index\\.md", "project-index-response.json"); + setupProjectFiles("documentation\\.json", "project-documentation-response.json"); + setupProjectFiles("support\\.json", "project-support-response.json"); + ProjectData projectData = this.queries.getData(); + assertThat(projectData.project().size()).isEqualTo(3); + assertThat(projectData.project().get("spring-webflow").getSlug()).isEqualTo("spring-webflow"); + assertThat(projectData.documentation().get("spring-webflow")).hasSize(9); + List support = projectData.support().get("spring-webflow"); + assertThat(support).hasSize(14); + assertThat(support.get(0).getInitialDate()).isEqualTo(LocalDate.parse("2017-01-30")); + assertThat(projectData.supportPolicy().get("spring-webflow")).isEqualTo("UPSTREAM"); + } + + @Test + void getProjectsDoesNotAddProjectIfNotFound() throws Exception { + setupProjects(); + this.customizer.getServer() + .expect(ExpectedCount.max(2), + requestTo(MatchesPattern.matchesPattern("\\/project\\/spring-w.+\\/index\\.md\\?ref\\=test"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(from("project-index-response.json"), MediaType.APPLICATION_JSON)); + setupProjectFiles("documentation\\.json", "project-documentation-response.json"); + setupProjectFiles("support\\.json", "project-support-response.json"); + this.customizer.getServer() + .expect(ExpectedCount.once(), requestTo("/project/spring-xd/index.md?ref=test")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withResourceNotFound()); + ProjectData projectData = this.queries.getData(); + assertThat(projectData.project().size()).isEqualTo(2); + assertThat(projectData.project().get("spring-webflow").getSlug()).isEqualTo("spring-webflow"); + } + + @Test + void projectWhenNoProjectsReturnsEmpty() { + setupNoProjectDirectory(); + ProjectData projectData = this.queries.getData(); + assertThat(projectData.project()).isEmpty(); + } + + @Test + void projectDocumentationWhenFileNotFoundReturnsEmptyList() throws Exception { + setupProjects(); + setupProjectFiles("index\\.md", "project-index-response.json"); + this.customizer.getServer() + .expect(ExpectedCount.max(2), + requestTo(MatchesPattern + .matchesPattern("\\/project\\/spring-w.+\\/documentation\\.json\\?ref\\=test"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(from("project-documentation-response.json"), MediaType.APPLICATION_JSON)); + setupProjectFiles("support\\.json", "project-support-response.json"); + this.customizer.getServer() + .expect(ExpectedCount.once(), requestTo("/project/spring-xd/documentation.json?ref=test")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withResourceNotFound()); + ProjectData projectData = this.queries.getData(); + assertThat(projectData.documentation().get("spring-xd")).isEmpty(); + } + + @Test + void projectSupportsWhenFileNotFoundReturnsEmptyList() throws Exception { + setupProjects(); + setupProjectFiles("index\\.md", "project-index-response.json"); + setupProjectFiles("documentation\\.json", "project-documentation-response.json"); + this.customizer.getServer() + .expect(ExpectedCount.max(2), + requestTo(MatchesPattern.matchesPattern("\\/project\\/spring-w.+\\/support\\.json\\?ref\\=test"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(from("project-support-response.json"), MediaType.APPLICATION_JSON)); + this.customizer.getServer() + .expect(ExpectedCount.once(), requestTo("/project/spring-xd/support.json?ref=test")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withResourceNotFound()); + ProjectData projectData = this.queries.getData(); + assertThat(projectData.support().get("spring-xd")).isEmpty(); + } + + private void setupProjectFiles(String fileName, String responseFileName) throws IOException { + this.customizer.getServer() + .expect(ExpectedCount.manyTimes(), + requestTo(MatchesPattern.matchesPattern("\\/project\\/.+\\/" + fileName + "\\?ref\\=test"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(from(responseFileName), MediaType.APPLICATION_JSON)); + } + + private void setupProjects() throws Exception { + this.customizer.getServer() + .expect(requestTo("/project?ref=test")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(from("project-all-response.json"), MediaType.APPLICATION_JSON)); + } + + private void setupNoProjectDirectory() { + this.customizer.getServer() + .expect(requestTo("/project?ref=test")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withResourceNotFound()); + } + + private byte[] from(String path) throws IOException { + ClassPathResource resource = new ClassPathResource(path, getClass()); + try (InputStream inputStream = resource.getInputStream()) { + return FileCopyUtils.copyToByteArray(inputStream); + } + } + +} diff --git a/src/test/java/io/spring/projectapi/web/project/ProjectDetailsControllerTests.java b/src/test/java/io/spring/projectapi/web/project/ProjectDetailsControllerTests.java index 4e846f0..de67a5a 100644 --- a/src/test/java/io/spring/projectapi/web/project/ProjectDetailsControllerTests.java +++ b/src/test/java/io/spring/projectapi/web/project/ProjectDetailsControllerTests.java @@ -39,7 +39,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.verify; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; @@ -97,7 +96,6 @@ void patchProjectDetails() throws Exception { void patchProjectDetailsWithMissingField() throws Exception { io.spring.projectapi.github.Project project = new io.spring.projectapi.github.Project("Spring Boot", "spring-boot", "https://github.com/spring-projects/spring-boot", Status.ACTIVE); - given(this.githubOperations.getProject("spring-boot")).willReturn(project); this.mvc .perform(patch("/projects/spring-boot/details").contentType(MediaType.APPLICATION_JSON) .content(from("patch-field-missing.json"))) diff --git a/src/test/java/io/spring/projectapi/web/release/ReleasesControllerTests.java b/src/test/java/io/spring/projectapi/web/release/ReleasesControllerTests.java index 639f3d9..29ccad8 100644 --- a/src/test/java/io/spring/projectapi/web/release/ReleasesControllerTests.java +++ b/src/test/java/io/spring/projectapi/web/release/ReleasesControllerTests.java @@ -157,7 +157,7 @@ void currentReturnsCurrentRelease() throws Exception { @Test @WithMockUser(roles = "ADMIN") void addAddsRelease() throws Exception { - given(this.githubOperations.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); + given(this.projectRepository.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); String expectedLocation = "https://api.spring.io/projects/spring-boot/releases/2.8.0"; ConstrainedFields fields = ConstrainedFields.constraintsOn(NewRelease.class); this.mvc @@ -180,7 +180,7 @@ void addAddsRelease() throws Exception { @Test @WithMockUser(roles = "ADMIN") void addWithAntoraVersionAddsRelease() throws Exception { - given(this.githubOperations.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); + given(this.projectRepository.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); String expectedLocation = "https://api.spring.io/projects/spring-boot/releases/2.8.0"; ConstrainedFields fields = ConstrainedFields.constraintsOn(NewRelease.class); this.mvc @@ -225,7 +225,7 @@ void addWhenHasNoAdminRoleReturnsUnauthorized() throws Exception { @Test @WithMockUser(roles = "ADMIN") void addWhenProjectDoesNotExistReturnsNotFound() throws Exception { - given(this.githubOperations.getProjectDocumentations("spring-boot")) + given(this.projectRepository.getProjectDocumentations("spring-boot")) .willThrow(NoSuchGithubProjectException.class); this.mvc .perform(post("/projects/spring-boot/releases").accept(MediaTypes.HAL_JSON) @@ -237,7 +237,7 @@ void addWhenProjectDoesNotExistReturnsNotFound() throws Exception { @Test @WithMockUser(roles = "ADMIN") void addWhenReleaseAlreadyExistsReturnsBadRequest() throws Exception { - given(this.githubOperations.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); + given(this.projectRepository.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); this.mvc .perform(post("/projects/spring-boot/releases").accept(MediaTypes.HAL_JSON) .contentType(MediaType.APPLICATION_JSON) @@ -248,7 +248,7 @@ void addWhenReleaseAlreadyExistsReturnsBadRequest() throws Exception { @Test @WithMockUser(roles = "ADMIN") void deleteDeletesDocumentation() throws Exception { - given(this.githubOperations.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); + given(this.projectRepository.getProjectDocumentations("spring-boot")).willReturn(getProjectDocumentations()); this.mvc.perform(delete("/projects/spring-boot/releases/2.3.0").accept(MediaTypes.HAL_JSON)) .andExpect(status().isNoContent()) .andDo(document("delete-release")); diff --git a/src/test/java/io/spring/projectapi/web/webhook/CacheControllerTests.java b/src/test/java/io/spring/projectapi/web/webhook/CacheControllerTests.java new file mode 100644 index 0000000..f5f3f25 --- /dev/null +++ b/src/test/java/io/spring/projectapi/web/webhook/CacheControllerTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.projectapi.web.webhook; + +import java.nio.charset.StandardCharsets; + +import io.spring.projectapi.ProjectRepository; +import io.spring.projectapi.security.SecurityConfiguration; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.util.StreamUtils; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link CacheController}. + */ +@WebMvcTest(value = CacheController.class, properties = "projects.github.webhooksecret=token") +@AutoConfigureWebClient +@Import(SecurityConfiguration.class) +class CacheControllerTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ProjectRepository projectRepository; + + @Test + void missingHeadersShouldBeRejected() throws Exception { + this.mockMvc + .perform(MockMvcRequestBuilders.post("/refresh_cache") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"message\": \"this is a test\"}")) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + void invalidHmacSignatureShouldBeRejected() throws Exception { + this.mockMvc + .perform(MockMvcRequestBuilders.post("/refresh_cache") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=wronghmacvalue") + .header("X-GitHub-Event", "push") + .content("{\"message\": \"this is a test\"}")) + .andExpect(MockMvcResultMatchers.status().isForbidden()) + .andExpect(MockMvcResultMatchers.content().string("{ \"message\": \"Forbidden\" }")); + } + + @Test + void pingEventShouldHaveResponse() throws Exception { + this.mockMvc + .perform(MockMvcRequestBuilders.post("/refresh_cache") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=9BBB4C351EF0D50F93372CA787F338385981AA41") + .header("X-GitHub-Event", "ping") + .content(getTestPayload("ping"))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect( + MockMvcResultMatchers.content().string("{ \"message\": \"Successfully processed ping event\" }")); + } + + @Test + void invalidJsonPushEventShouldBeRejected() throws Exception { + this.mockMvc + .perform(MockMvcRequestBuilders.post("/refresh_cache") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=8FCA101BFF427372C4DB6B9B6E48C8E2D2092ADC") + .header("X-GitHub-Event", "push") + .content("this is a test message")) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.content().string("{ \"message\": \"Bad Request\" }")); + } + + @Test + void shouldTriggerCacheRefresh() throws Exception { + this.mockMvc + .perform(MockMvcRequestBuilders.post("/refresh_cache") + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .header("X-Hub-Signature", "sha1=C8D5B1C972E8DCFB69AB7124678D4C91E11D6F23") + .header("X-GitHub-Event", "push") + .content(getTestPayload("push"))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content() + .string("{ \"message\": \"Successfully processed cache refresh\" }")); + verify(this.projectRepository, times(1)).update(); + } + + private String getTestPayload(String fileName) throws Exception { + ClassPathResource resource = new ClassPathResource(fileName + ".json", getClass()); + return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8).replaceAll("[\\n|\\r]", ""); + } + +}