From 8498c4e54440ce72eb7a9e2a908d28cdc6c58177 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Fri, 25 Aug 2023 20:07:23 +0100 Subject: [PATCH 01/16] Get all cakes endpoint happy path --- .gitignore | 13 +- README.txt | 12 ++ pom.xml | 161 ++++++++++-------- .../java/com.waracle.cakemgr/CakeEntity.java | 52 ------ .../java/com.waracle.cakemgr/CakeServlet.java | 106 ------------ .../com.waracle.cakemgr/HibernateUtil.java | 36 ---- .../cakemanager/CakeManagerApplication.java | 13 ++ .../controller/CakeController.java | 21 +++ .../philldenness/cakemanager/dto/CakeDTO.java | 8 + .../cakemanager/entity/CakeEntity.java | 23 +++ .../cakemanager/mapper/CakeMapper.java | 12 ++ .../repository/CakeRepository.java | 9 + .../cakemanager/service/CakeService.java | 20 +++ src/main/resources/application.properties | 5 + .../resources/db/migration/V1__Initial.sql | 8 + src/main/resources/hibernate.cfg.xml | 17 -- src/main/webapp/WEB-INF/web.xml | 8 - src/main/webapp/index.jsp | 5 - .../controller/CakeControllerTest.java | 34 ++++ .../integrationTest/GetCakeIT.java | 45 +++++ .../cakemanager/mapper/CakeMapperTest.java | 31 ++++ .../cakemanager/service/CakeServiceTest.java | 47 +++++ src/test/resources/json/cakes-list.json | 102 +++++++++++ 23 files changed, 489 insertions(+), 299 deletions(-) delete mode 100644 src/main/java/com.waracle.cakemgr/CakeEntity.java delete mode 100644 src/main/java/com.waracle.cakemgr/CakeServlet.java delete mode 100644 src/main/java/com.waracle.cakemgr/HibernateUtil.java create mode 100644 src/main/java/com/philldenness/cakemanager/CakeManagerApplication.java create mode 100644 src/main/java/com/philldenness/cakemanager/controller/CakeController.java create mode 100644 src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java create mode 100644 src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java create mode 100644 src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java create mode 100644 src/main/java/com/philldenness/cakemanager/repository/CakeRepository.java create mode 100644 src/main/java/com/philldenness/cakemanager/service/CakeService.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/db/migration/V1__Initial.sql delete mode 100644 src/main/resources/hibernate.cfg.xml delete mode 100644 src/main/webapp/WEB-INF/web.xml delete mode 100644 src/main/webapp/index.jsp create mode 100644 src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java create mode 100644 src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java create mode 100644 src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java create mode 100644 src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java create mode 100644 src/test/resources/json/cakes-list.json diff --git a/.gitignore b/.gitignore index d81f12ed..f0de6717 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ -/target -/.idea +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr diff --git a/README.txt b/README.txt index a0901c92..593bc1eb 100644 --- a/README.txt +++ b/README.txt @@ -55,3 +55,15 @@ Please provide your version of this project as a git repository (e.g. Github, Bi A fork of this repo, or a Pull Request would be suitable. Good luck! + + +Notes from Phill +========== + +Requires Java 20 to run. +If running in intelliJ it requires lombok annotations to be enabled. +I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ +Flyway migration scripts run automatically on startup + +Next steps: +Add pagination and sorting to getAll endpoint \ No newline at end of file diff --git a/pom.xml b/pom.xml index c8cbf9d5..01bac950 100644 --- a/pom.xml +++ b/pom.xml @@ -1,77 +1,92 @@ - 4.0.0 - com.waracle - cake-manager - war - 1.0-SNAPSHOT - cake-manager Maven Webapp - http://maven.apache.org - + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.3 + + + com.philldenness + cakemanager + 0.0.1-SNAPSHOT + cakemanager + Cake Manager Micro Service (fictitious) + + 20 + 2022.0.4 + 9.21.2 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.flywaydb + flyway-core + ${flywaydb-core.version} + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud + spring-cloud-starter-contract-verifier + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + - - - javax.servlet - javax.servlet-api - 3.1.0 - - - - - com.fasterxml.jackson.core - jackson-core - 2.8.0 - - - - - org.hibernate - hibernate-entitymanager - 4.3.6.Final - - - - - org.hsqldb - hsqldb - 2.3.4 - - - - - junit - junit - 4.1 - test - - - - - cake-manager - - - - maven-compiler-plugin - 2.3.2 - - 1.8 - 1.8 - - - - - org.eclipse.jetty - jetty-maven-plugin - - 10 - STOP - 8005 - - 8282 - - - - - - + + + + org.springframework.cloud + spring-cloud-contract-maven-plugin + 4.0.4 + true + + JUNIT5 + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + diff --git a/src/main/java/com.waracle.cakemgr/CakeEntity.java b/src/main/java/com.waracle.cakemgr/CakeEntity.java deleted file mode 100644 index 7927bd5d..00000000 --- a/src/main/java/com.waracle.cakemgr/CakeEntity.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.waracle.cakemgr; - -import java.io.Serializable; - -import javax.persistence.*; - -@Entity -@org.hibernate.annotations.Entity(dynamicUpdate = true) -@Table(name = "Employee", uniqueConstraints = {@UniqueConstraint(columnNames = "ID"), @UniqueConstraint(columnNames = "EMAIL")}) -public class CakeEntity implements Serializable { - - private static final long serialVersionUID = -1798070786993154676L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "ID", unique = true, nullable = false) - private Integer employeeId; - - @Column(name = "EMAIL", unique = true, nullable = false, length = 100) - private String title; - - @Column(name = "FIRST_NAME", unique = false, nullable = false, length = 100) - private String description; - - @Column(name = "LAST_NAME", unique = false, nullable = false, length = 300) - private String image; - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public String getImage() { - return image; - } - - public void setImage(String image) { - this.image = image; - } - -} \ No newline at end of file diff --git a/src/main/java/com.waracle.cakemgr/CakeServlet.java b/src/main/java/com.waracle.cakemgr/CakeServlet.java deleted file mode 100644 index 9bd32f76..00000000 --- a/src/main/java/com.waracle.cakemgr/CakeServlet.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.waracle.cakemgr; - -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import org.hibernate.Session; -import org.hibernate.exception.ConstraintViolationException; - -import javax.servlet.ServletException; -import javax.servlet.annotation.WebServlet; -import javax.servlet.http.HttpServlet; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.*; -import java.net.URL; -import java.util.List; - -@WebServlet("/cakes") -public class CakeServlet extends HttpServlet { - - @Override - public void init() throws ServletException { - super.init(); - - System.out.println("init started"); - - - System.out.println("downloading cake json"); - try (InputStream inputStream = new URL("https://gist.githubusercontent.com/hart88/198f29ec5114a3ec3460/raw/8dd19a88f9b8d24c23d9960f3300d0c917a4f07c/cake.json").openStream()) { - BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); - - StringBuffer buffer = new StringBuffer(); - String line = reader.readLine(); - while (line != null) { - buffer.append(line); - line = reader.readLine(); - } - - System.out.println("parsing cake json"); - JsonParser parser = new JsonFactory().createParser(buffer.toString()); - if (JsonToken.START_ARRAY != parser.nextToken()) { - throw new Exception("bad token"); - } - - JsonToken nextToken = parser.nextToken(); - while(nextToken == JsonToken.START_OBJECT) { - System.out.println("creating cake entity"); - - CakeEntity cakeEntity = new CakeEntity(); - System.out.println(parser.nextFieldName()); - cakeEntity.setTitle(parser.nextTextValue()); - - System.out.println(parser.nextFieldName()); - cakeEntity.setDescription(parser.nextTextValue()); - - System.out.println(parser.nextFieldName()); - cakeEntity.setImage(parser.nextTextValue()); - - Session session = HibernateUtil.getSessionFactory().openSession(); - try { - session.beginTransaction(); - session.persist(cakeEntity); - System.out.println("adding cake entity"); - session.getTransaction().commit(); - } catch (ConstraintViolationException ex) { - - } - session.close(); - - nextToken = parser.nextToken(); - System.out.println(nextToken); - - nextToken = parser.nextToken(); - System.out.println(nextToken); - } - - } catch (Exception ex) { - throw new ServletException(ex); - } - - System.out.println("init finished"); - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - - Session session = HibernateUtil.getSessionFactory().openSession(); - List list = session.createCriteria(CakeEntity.class).list(); - - resp.getWriter().println("["); - - for (CakeEntity entity : list) { - resp.getWriter().println("\t{"); - - resp.getWriter().println("\t\t\"title\" : " + entity.getTitle() + ", "); - resp.getWriter().println("\t\t\"desc\" : " + entity.getDescription() + ","); - resp.getWriter().println("\t\t\"image\" : " + entity.getImage()); - - resp.getWriter().println("\t}"); - } - - resp.getWriter().println("]"); - - } - -} diff --git a/src/main/java/com.waracle.cakemgr/HibernateUtil.java b/src/main/java/com.waracle.cakemgr/HibernateUtil.java deleted file mode 100644 index 41ef137b..00000000 --- a/src/main/java/com.waracle.cakemgr/HibernateUtil.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.waracle.cakemgr; - -import org.hibernate.SessionFactory; -import org.hibernate.boot.registry.StandardServiceRegistryBuilder; -import org.hibernate.cfg.Configuration; -import org.hibernate.service.ServiceRegistry; - -public class HibernateUtil { - - private static SessionFactory sessionFactory = buildSessionFactory(); - - private static SessionFactory buildSessionFactory() { - try { - if (sessionFactory == null) { - Configuration configuration = new Configuration().configure(HibernateUtil.class.getResource("/hibernate.cfg.xml")); - StandardServiceRegistryBuilder serviceRegistryBuilder = new StandardServiceRegistryBuilder(); - serviceRegistryBuilder.applySettings(configuration.getProperties()); - ServiceRegistry serviceRegistry = serviceRegistryBuilder.build(); - sessionFactory = configuration.buildSessionFactory(serviceRegistry); - } - return sessionFactory; - } catch (Throwable ex) { - System.err.println("Initial SessionFactory creation failed." + ex); - throw new ExceptionInInitializerError(ex); - } - } - - public static SessionFactory getSessionFactory() { - return sessionFactory; - } - - public static void shutdown() { - getSessionFactory().close(); - } - -} diff --git a/src/main/java/com/philldenness/cakemanager/CakeManagerApplication.java b/src/main/java/com/philldenness/cakemanager/CakeManagerApplication.java new file mode 100644 index 00000000..7a89e4a8 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/CakeManagerApplication.java @@ -0,0 +1,13 @@ +package com.philldenness.cakemanager; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CakeManagerApplication { + + public static void main(String[] args) { + SpringApplication.run(CakeManagerApplication.class, args); + } + +} diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java new file mode 100644 index 00000000..ff5fd193 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -0,0 +1,21 @@ +package com.philldenness.cakemanager.controller; + +import java.util.List; + +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.service.CakeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class CakeController { + + private final CakeService cakeService; + + @GetMapping("/cakes") + public List getAllCakes() { + return cakeService.getCakes(); + } +} diff --git a/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java new file mode 100644 index 00000000..e9d98f96 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java @@ -0,0 +1,8 @@ +package com.philldenness.cakemanager.dto; + +public record CakeDTO( + String title, + String description, + String image +) { +} \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java b/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java new file mode 100644 index 00000000..2175922e --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java @@ -0,0 +1,23 @@ +package com.philldenness.cakemanager.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "cake") +public class CakeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long employeeId; + private String title; + private String description; + private String image; +} \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java b/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java new file mode 100644 index 00000000..2b2e39fd --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java @@ -0,0 +1,12 @@ +package com.philldenness.cakemanager.mapper; + +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.entity.CakeEntity; +import org.springframework.stereotype.Component; + +@Component +public class CakeMapper { + public CakeDTO toDTO(CakeEntity entity) { + return new CakeDTO(entity.getTitle(), entity.getDescription(), entity.getImage()); + } +} diff --git a/src/main/java/com/philldenness/cakemanager/repository/CakeRepository.java b/src/main/java/com/philldenness/cakemanager/repository/CakeRepository.java new file mode 100644 index 00000000..7bf8f2e0 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/repository/CakeRepository.java @@ -0,0 +1,9 @@ +package com.philldenness.cakemanager.repository; + +import com.philldenness.cakemanager.entity.CakeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CakeRepository extends JpaRepository { +} diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java new file mode 100644 index 00000000..ba444b19 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -0,0 +1,20 @@ +package com.philldenness.cakemanager.service; + +import java.util.List; + +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.mapper.CakeMapper; +import com.philldenness.cakemanager.repository.CakeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CakeService { + private final CakeRepository repository; + private final CakeMapper cakeMapper; + + public List getCakes() { + return repository.findAll().stream().map(cakeMapper::toDTO).toList(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..97c35704 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.show-sql=true +spring.jpa.hibernate.ddl-auto=none diff --git a/src/main/resources/db/migration/V1__Initial.sql b/src/main/resources/db/migration/V1__Initial.sql new file mode 100644 index 00000000..a967eb6a --- /dev/null +++ b/src/main/resources/db/migration/V1__Initial.sql @@ -0,0 +1,8 @@ +CREATE TABLE cake +( + id INT AUTO_INCREMENT PRIMARY KEY, + employee_id INT, + title VARCHAR, + description VARCHAR, + image VARCHAR +); \ No newline at end of file diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml deleted file mode 100644 index 0ae06d63..00000000 --- a/src/main/resources/hibernate.cfg.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - class,hbm - org.hibernate.dialect.HSQLDialect - true - org.hsqldb.jdbcDriver - sa - - jdbc:hsqldb:mem:db - create - - - \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml deleted file mode 100644 index d004447f..00000000 --- a/src/main/webapp/WEB-INF/web.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - Archetype Created Web Application - - diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp deleted file mode 100644 index c38169bb..00000000 --- a/src/main/webapp/index.jsp +++ /dev/null @@ -1,5 +0,0 @@ - - -

Hello World!

- - diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java new file mode 100644 index 00000000..c276f56d --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -0,0 +1,34 @@ +package com.philldenness.cakemanager.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.util.List; + +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.service.CakeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CakeControllerTest { + + @Mock + private CakeService cakeService; + + @InjectMocks + private CakeController cakeController; + + @Test + void shouldGetCakeReturnsCakesFromCakeService() { + List cakes = List.of(new CakeDTO("title", "description", "image")); + when(cakeService.getCakes()).thenReturn(cakes); + + List cakeList = cakeController.getAllCakes(); + + assertEquals(cakes, cakeList); + } +} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java new file mode 100644 index 00000000..61de86db --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -0,0 +1,45 @@ +package com.philldenness.cakemanager.integrationTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.philldenness.cakemanager.entity.CakeEntity; +import com.philldenness.cakemanager.repository.CakeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +public class GetCakeIT { + + @Autowired + private MockMvc mvc; + + @Autowired + private CakeRepository cakeRepository; + + @BeforeEach + void setUp() { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setEmployeeId(1L); + cakeEntity.setTitle("Lemon cheesecake"); + cakeEntity.setDescription("A cheesecake made of lemon"); + cakeEntity.setImage("https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg"); + + cakeRepository.save(cakeEntity); + } + + @Test + void testGetCakesReturnsAllCakes() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/cakes").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("[{'title':'Lemon cheesecake','description':'A cheesecake made of lemon','image':'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'}]")); + + } +} diff --git a/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java b/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java new file mode 100644 index 00000000..7950e4d8 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java @@ -0,0 +1,31 @@ +package com.philldenness.cakemanager.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.entity.CakeEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CakeMapperTest { + + @InjectMocks + private CakeMapper cakeMapper; + + @Test + void shouldConvertEntityToDTO() { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setTitle("a title"); + cakeEntity.setDescription("a description"); + cakeEntity.setImage("an image"); + + CakeDTO cakeDTO = cakeMapper.toDTO(cakeEntity); + + assertEquals(cakeEntity.getTitle(), cakeDTO.title()); + assertEquals(cakeEntity.getDescription(), cakeDTO.description()); + assertEquals(cakeEntity.getImage(), cakeDTO.image()); + } +} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java new file mode 100644 index 00000000..7a0421ca --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -0,0 +1,47 @@ +package com.philldenness.cakemanager.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; + +import com.philldenness.cakemanager.entity.CakeEntity; +import com.philldenness.cakemanager.mapper.CakeMapper; +import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.repository.CakeRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CakeServiceTest { + + @InjectMocks + private CakeService cakeService; + + @Mock + private CakeRepository cakeRepository; + + @Mock + private CakeMapper cakeMapper; + + @Test + void shouldCallMapperWithEntityAndReturnMapperResult() { + CakeDTO cakeDTO1 = mock(CakeDTO.class); + CakeDTO cakeDTO2 = mock(CakeDTO.class); + CakeEntity cakeEntity1 = mock(CakeEntity.class); + CakeEntity cakeEntity2 = mock(CakeEntity.class); + + when(cakeRepository.findAll()).thenReturn(List.of(cakeEntity1, cakeEntity2)); + when(cakeMapper.toDTO(any(CakeEntity.class))).thenReturn(cakeDTO1).thenReturn(cakeDTO2); + + List cakes = cakeService.getCakes(); + + verify(cakeMapper, times(1)).toDTO(cakeEntity1); + verify(cakeMapper, times(1)).toDTO(cakeEntity2); + assertEquals(List.of(cakeDTO1, cakeDTO2), cakes); + } +} \ No newline at end of file diff --git a/src/test/resources/json/cakes-list.json b/src/test/resources/json/cakes-list.json new file mode 100644 index 00000000..6e40dc24 --- /dev/null +++ b/src/test/resources/json/cakes-list.json @@ -0,0 +1,102 @@ +[ + { + "title": "Lemon cheesecake", + "desc": "A cheesecake made of lemon", + "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" + }, + { + "title": "victoria sponge", + "desc": "sponge with jam", + "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" + }, + { + "title": "Carrot cake", + "desc": "Bugs bunnys favourite", + "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" + }, + { + "title": "Banana cake", + "desc": "Donkey kongs favourite", + "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" + }, + { + "title": "Birthday cake", + "desc": "a yearly treat", + "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" + }, + { + "title": "Lemon cheesecake", + "desc": "A cheesecake made of lemon", + "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" + }, + { + "title": "victoria sponge", + "desc": "sponge with jam", + "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" + }, + { + "title": "Carrot cake", + "desc": "Bugs bunnys favourite", + "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" + }, + { + "title": "Banana cake", + "desc": "Donkey kongs favourite", + "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" + }, + { + "title": "Birthday cake", + "desc": "a yearly treat", + "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" + }, + { + "title": "Lemon cheesecake", + "desc": "A cheesecake made of lemon", + "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" + }, + { + "title": "victoria sponge", + "desc": "sponge with jam", + "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" + }, + { + "title": "Carrot cake", + "desc": "Bugs bunnys favourite", + "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" + }, + { + "title": "Banana cake", + "desc": "Donkey kongs favourite", + "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" + }, + { + "title": "Birthday cake", + "desc": "a yearly treat", + "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" + }, + { + "title": "Lemon cheesecake", + "desc": "A cheesecake made of lemon", + "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" + }, + { + "title": "victoria sponge", + "desc": "sponge with jam", + "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" + }, + { + "title": "Carrot cake", + "desc": "Bugs bunnys favourite", + "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" + }, + { + "title": "Banana cake", + "desc": "Donkey kongs favourite", + "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" + }, + { + "title": "Birthday cake", + "desc": "a yearly treat", + "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" + } +] \ No newline at end of file From 645368fb80f5d4d8eb1e9e7888c30f4c71fafc91 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 12:58:47 +0100 Subject: [PATCH 02/16] Add swagger and seed data --- README.txt | 3 + pom.xml | 41 ++++--- .../controller/CakeController.java | 2 + .../cakemanager/entity/CakeEntity.java | 1 - .../resources/db/migration/V1__Initial.sql | 7 +- .../db/migration/V2__insert_cakes.sql | 41 +++++++ .../integrationTest/GetCakeIT.java | 21 +--- src/test/resources/json/cakes-list.json | 102 ------------------ 8 files changed, 80 insertions(+), 138 deletions(-) create mode 100644 src/main/resources/db/migration/V2__insert_cakes.sql delete mode 100644 src/test/resources/json/cakes-list.json diff --git a/README.txt b/README.txt index 593bc1eb..5e717065 100644 --- a/README.txt +++ b/README.txt @@ -65,5 +65,8 @@ If running in intelliJ it requires lombok annotations to be enabled. I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ Flyway migration scripts run automatically on startup +Build with integration tests command: mvn verify -Pfailsafe +Swagger is on http://localhost:8080/swagger-ui/index.html + Next steps: Add pagination and sorting to getAll endpoint \ No newline at end of file diff --git a/pom.xml b/pom.xml index 01bac950..a6f1c803 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ 20 2022.0.4 9.21.2 + 2.2.0 @@ -36,6 +37,11 @@ flyway-core ${flywaydb-core.version} + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi.version} + org.projectlombok lombok @@ -46,11 +52,6 @@ spring-boot-starter-test test - - org.springframework.cloud - spring-cloud-starter-contract-verifier - test - @@ -66,15 +67,6 @@ - - org.springframework.cloud - spring-cloud-contract-maven-plugin - 4.0.4 - true - - JUNIT5 - - org.springframework.boot spring-boot-maven-plugin @@ -89,4 +81,25 @@ + + + failsafe + + + + maven-failsafe-plugin + 2.22.0 + + + + integration-test + verify + + + + + + + + diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index ff5fd193..1832aeef 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -4,6 +4,7 @@ import com.philldenness.cakemanager.dto.CakeDTO; import com.philldenness.cakemanager.service.CakeService; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -15,6 +16,7 @@ public class CakeController { private final CakeService cakeService; @GetMapping("/cakes") + @Operation(summary = "Get all cakes") public List getAllCakes() { return cakeService.getCakes(); } diff --git a/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java b/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java index 2175922e..39c787fc 100644 --- a/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java +++ b/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java @@ -16,7 +16,6 @@ public class CakeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private Long employeeId; private String title; private String description; private String image; diff --git a/src/main/resources/db/migration/V1__Initial.sql b/src/main/resources/db/migration/V1__Initial.sql index a967eb6a..7442f112 100644 --- a/src/main/resources/db/migration/V1__Initial.sql +++ b/src/main/resources/db/migration/V1__Initial.sql @@ -1,8 +1,7 @@ CREATE TABLE cake ( id INT AUTO_INCREMENT PRIMARY KEY, - employee_id INT, - title VARCHAR, - description VARCHAR, - image VARCHAR + title VARCHAR NOT NULL, + description VARCHAR NOT NULL, + image VARCHAR NOT NULL ); \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__insert_cakes.sql b/src/main/resources/db/migration/V2__insert_cakes.sql new file mode 100644 index 00000000..f305a8ee --- /dev/null +++ b/src/main/resources/db/migration/V2__insert_cakes.sql @@ -0,0 +1,41 @@ +INSERT INTO cake (title, description, image) +VALUES ('Lemon cheesecake', 'A cheesecake made of lemon', + 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'), + ('victoria sponge', 'sponge with jam', + 'http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg'), + ('Carrot cake', 'Bugs bunnys favourite', + 'http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg'), + ('Banana cake', 'Donkey kongs favourite', + 'http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg'), + ('Birthday cake', 'a yearly treat', + 'http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg'), + ('Lemon cheesecake', 'A cheesecake made of lemon', + 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'), + ('victoria sponge', 'sponge with jam', + 'http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg'), + ('Carrot cake', 'Bugs bunnys favourite', + 'http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg'), + ('Banana cake', 'Donkey kongs favourite', + 'http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg'), + ('Birthday cake', 'a yearly treat', + 'http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg'), + ('Lemon cheesecake', 'A cheesecake made of lemon', + 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'), + ('victoria sponge', 'sponge with jam', + 'http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg'), + ('Carrot cake', 'Bugs bunnys favourite', + 'http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg'), + ('Banana cake', 'Donkey kongs favourite', + 'http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg'), + ('Birthday cake', 'a yearly treat', + 'http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg'), + ('Lemon cheesecake', 'A cheesecake made of lemon', + 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'), + ('victoria sponge', 'sponge with jam', + 'http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg'), + ('Carrot cake', 'Bugs bunnys favourite', + 'http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg'), + ('Banana cake', 'Donkey kongs favourite', + 'http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg'), + ('Birthday cake', 'a yearly treat', + 'http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg'); \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java index 61de86db..c0950a10 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -1,11 +1,8 @@ package com.philldenness.cakemanager.integrationTest; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.repository.CakeRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -13,6 +10,7 @@ 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; @SpringBootTest @AutoConfigureMockMvc @@ -24,22 +22,11 @@ public class GetCakeIT { @Autowired private CakeRepository cakeRepository; - @BeforeEach - void setUp() { - CakeEntity cakeEntity = new CakeEntity(); - cakeEntity.setEmployeeId(1L); - cakeEntity.setTitle("Lemon cheesecake"); - cakeEntity.setDescription("A cheesecake made of lemon"); - cakeEntity.setImage("https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg"); - - cakeRepository.save(cakeEntity); - } - @Test void testGetCakesReturnsAllCakes() throws Exception { - mvc.perform(MockMvcRequestBuilders.get("/cakes").accept(MediaType.APPLICATION_JSON)) + mvc.perform(MockMvcRequestBuilders.get("/cakes") + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(content().json("[{'title':'Lemon cheesecake','description':'A cheesecake made of lemon','image':'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'}]")); - + .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(20)); } } diff --git a/src/test/resources/json/cakes-list.json b/src/test/resources/json/cakes-list.json deleted file mode 100644 index 6e40dc24..00000000 --- a/src/test/resources/json/cakes-list.json +++ /dev/null @@ -1,102 +0,0 @@ -[ - { - "title": "Lemon cheesecake", - "desc": "A cheesecake made of lemon", - "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" - }, - { - "title": "victoria sponge", - "desc": "sponge with jam", - "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" - }, - { - "title": "Carrot cake", - "desc": "Bugs bunnys favourite", - "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" - }, - { - "title": "Banana cake", - "desc": "Donkey kongs favourite", - "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" - }, - { - "title": "Birthday cake", - "desc": "a yearly treat", - "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" - }, - { - "title": "Lemon cheesecake", - "desc": "A cheesecake made of lemon", - "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" - }, - { - "title": "victoria sponge", - "desc": "sponge with jam", - "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" - }, - { - "title": "Carrot cake", - "desc": "Bugs bunnys favourite", - "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" - }, - { - "title": "Banana cake", - "desc": "Donkey kongs favourite", - "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" - }, - { - "title": "Birthday cake", - "desc": "a yearly treat", - "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" - }, - { - "title": "Lemon cheesecake", - "desc": "A cheesecake made of lemon", - "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" - }, - { - "title": "victoria sponge", - "desc": "sponge with jam", - "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" - }, - { - "title": "Carrot cake", - "desc": "Bugs bunnys favourite", - "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" - }, - { - "title": "Banana cake", - "desc": "Donkey kongs favourite", - "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" - }, - { - "title": "Birthday cake", - "desc": "a yearly treat", - "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" - }, - { - "title": "Lemon cheesecake", - "desc": "A cheesecake made of lemon", - "image": "https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg" - }, - { - "title": "victoria sponge", - "desc": "sponge with jam", - "image": "http://www.bbcgoodfood.com/sites/bbcgoodfood.com/files/recipe_images/recipe-image-legacy-id--1001468_10.jpg" - }, - { - "title": "Carrot cake", - "desc": "Bugs bunnys favourite", - "image": "http://www.villageinn.com/i/pies/profile/carrotcake_main1.jpg" - }, - { - "title": "Banana cake", - "desc": "Donkey kongs favourite", - "image": "http://ukcdn.ar-cdn.com/recipes/xlarge/ff22df7f-dbcd-4a09-81f7-9c1d8395d936.jpg" - }, - { - "title": "Birthday cake", - "desc": "a yearly treat", - "image": "http://cornandco.com/wp-content/uploads/2014/05/birthday-cake-popcorn.jpg" - } -] \ No newline at end of file From 0a9352840a83ed7fb7c5d069e339b6f9323fc734 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 13:26:22 +0100 Subject: [PATCH 03/16] Add docker and docker-compose support --- .dockerignore | 1 + Dockerfile | 18 ++++++++++++++++++ README.txt | 16 +++++++++++++--- compose.yml | 6 ++++++ pom.xml | 21 --------------------- 5 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1de56593 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +target \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5044cb68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 + +FROM maven:3.9.3-amazoncorretto-20 AS builder +WORKDIR /app +COPY pom.xml ./ +COPY src ./src + +RUN mvn clean install + +# Second stage: Minimal runtime environment +FROM eclipse-temurin:20-jre-jammy +WORKDIR /app + +# copy jar from the first stage +COPY --from=builder /app/target/*.jar /app/app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/README.txt b/README.txt index 5e717065..95a0845e 100644 --- a/README.txt +++ b/README.txt @@ -60,12 +60,22 @@ Good luck! Notes from Phill ========== -Requires Java 20 to run. -If running in intelliJ it requires lombok annotations to be enabled. +Requires Java 20 to run and requires lombok annotations to be enabled on intelliJ. I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ Flyway migration scripts run automatically on startup -Build with integration tests command: mvn verify -Pfailsafe +Starting app +Using docker +Build the container ` docker build --tag java-docker . ` +Run the container `docker run --publish 8080:8080 java-docker` + +Using docker-compose +run docker compose `docker-compose up` + +Using mvn +run `mvn spring-boot:run` + +Documentation Swagger is on http://localhost:8080/swagger-ui/index.html Next steps: diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..5e74d65e --- /dev/null +++ b/compose.yml @@ -0,0 +1,6 @@ +services: + backend: + container_name: java-docker + build: . + ports: + - 8080:8080 \ No newline at end of file diff --git a/pom.xml b/pom.xml index a6f1c803..7a16c12c 100644 --- a/pom.xml +++ b/pom.xml @@ -81,25 +81,4 @@ - - - failsafe - - - - maven-failsafe-plugin - 2.22.0 - - - - integration-test - verify - - - - - - - - From 237032b11bdcacc47177d111e3e25e3e672e494f Mon Sep 17 00:00:00 2001 From: Phillip Denness Date: Sat, 26 Aug 2023 13:35:29 +0100 Subject: [PATCH 04/16] dockerhub cicd --- .github/workflows/main.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..5bb6efb6 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: + - "master" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - + name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/clockbox:latest From dbdb24fa91f7e382a7e3e1a3d1677296aedf6d66 Mon Sep 17 00:00:00 2001 From: Phillip Denness Date: Sat, 26 Aug 2023 13:38:10 +0100 Subject: [PATCH 05/16] Tag docker as cakemanager --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5bb6efb6..2488bc60 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,4 +28,4 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKERHUB_USERNAME }}/clockbox:latest + tags: ${{ secrets.DOCKERHUB_USERNAME }}/cakemanager:latest From 74c2ef6fc259f7a37965bfc8547931bdd8b3576a Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 13:46:46 +0100 Subject: [PATCH 06/16] Include readme for getting container from dockerhub --- README.txt | 17 ++++++++++++----- compose.yml | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.txt b/README.txt index 95a0845e..271b3dee 100644 --- a/README.txt +++ b/README.txt @@ -62,12 +62,18 @@ Notes from Phill Requires Java 20 to run and requires lombok annotations to be enabled on intelliJ. I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ -Flyway migration scripts run automatically on startup +Flyway migration scripts run automatically on startup. + +CICD +Github actions workflow is setup to automatically test and build the docker container. Then it is pushed to dockerhub which can be viewed here: https://hub.docker.com/r/phillipdenness1/cakemanager Starting app -Using docker -Build the container ` docker build --tag java-docker . ` -Run the container `docker run --publish 8080:8080 java-docker` +Pull from dockerhub +docker run --publish 8080:8080 phillipdenness1/cakemanager:latest + +Build and run docker +Build the container `docker build --tag cakemanagerpd . ` +Run the container `docker run --publish 8080:8080 cakemanagerpd` Using docker-compose run docker compose `docker-compose up` @@ -79,4 +85,5 @@ Documentation Swagger is on http://localhost:8080/swagger-ui/index.html Next steps: -Add pagination and sorting to getAll endpoint \ No newline at end of file +Add pagination and sorting to getAll endpoint +Extend CICD to deploy to cloud \ No newline at end of file diff --git a/compose.yml b/compose.yml index 5e74d65e..32378223 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: backend: - container_name: java-docker + container_name: cakemanagerpd build: . ports: - 8080:8080 \ No newline at end of file From e1f75f5ba11e55132fc305a89fdd0faf0db413aa Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 14:48:35 +0100 Subject: [PATCH 07/16] Get by ID happy and unhappy path --- .../controller/CakeController.java | 10 ++++- .../ControllerExceptionHandler.java | 15 +++++++ .../cakemanager/service/CakeService.java | 6 +++ .../controller/CakeControllerTest.java | 27 +++++++++++- .../integrationTest/GetCakeIT.java | 20 +++++++-- .../cakemanager/service/CakeServiceTest.java | 44 +++++++++++++++++-- 6 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index 1832aeef..d5d07c6e 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -7,17 +7,25 @@ import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController +@RequestMapping("/cakes") @RequiredArgsConstructor public class CakeController { private final CakeService cakeService; - @GetMapping("/cakes") + @GetMapping @Operation(summary = "Get all cakes") public List getAllCakes() { return cakeService.getCakes(); } + + @GetMapping("/{id}") + public CakeDTO getCakeById(@PathVariable Long id) { + return cakeService.getCakeById(id); + } } diff --git a/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java new file mode 100644 index 00000000..e84ae2dc --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java @@ -0,0 +1,15 @@ +package com.philldenness.cakemanager.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@ControllerAdvice +public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { + + @ExceptionHandler({ IllegalArgumentException.class }) + public ResponseEntity handleAccessDeniedException() { + return ResponseEntity.notFound().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index ba444b19..159be1b2 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -3,6 +3,7 @@ import java.util.List; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; import com.philldenness.cakemanager.repository.CakeRepository; import lombok.RequiredArgsConstructor; @@ -17,4 +18,9 @@ public class CakeService { public List getCakes() { return repository.findAll().stream().map(cakeMapper::toDTO).toList(); } + + public CakeDTO getCakeById(Long id) { + CakeEntity cakeEntity = repository.findById(id).orElseThrow(IllegalArgumentException::new); + return cakeMapper.toDTO(cakeEntity); + } } diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java index c276f56d..4f93e9fe 100644 --- a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -1,6 +1,8 @@ package com.philldenness.cakemanager.controller; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; import java.util.List; @@ -22,8 +24,9 @@ class CakeControllerTest { @InjectMocks private CakeController cakeController; + // region allCakes @Test - void shouldGetCakeReturnsCakesFromCakeService() { + void shouldReturnAllCakesFromCakeService() { List cakes = List.of(new CakeDTO("title", "description", "image")); when(cakeService.getCakes()).thenReturn(cakes); @@ -31,4 +34,26 @@ void shouldGetCakeReturnsCakesFromCakeService() { assertEquals(cakes, cakeList); } + // endregion + + // region cake by id + @Test + void shouldCallServiceWithPathIdAndReturnCakeDto() { + Long cakeId = 1L; + CakeDTO expectedCake = new CakeDTO("title", "description", "image"); + when(cakeService.getCakeById(cakeId)).thenReturn(expectedCake); + + CakeDTO cakeDTO = cakeController.getCakeById(cakeId); + + assertEquals(expectedCake, cakeDTO); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenServiceThrowsIllegalArgumentException() { + when(cakeService.getCakeById(anyLong())).thenThrow(IllegalArgumentException.class); + + assertThrows(IllegalArgumentException.class, () -> cakeController.getCakeById(9L)); + } + + // endregion } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java index c0950a10..443e813f 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -1,8 +1,8 @@ package com.philldenness.cakemanager.integrationTest; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.philldenness.cakemanager.repository.CakeRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -19,9 +19,6 @@ public class GetCakeIT { @Autowired private MockMvc mvc; - @Autowired - private CakeRepository cakeRepository; - @Test void testGetCakesReturnsAllCakes() throws Exception { mvc.perform(MockMvcRequestBuilders.get("/cakes") @@ -29,4 +26,19 @@ void testGetCakesReturnsAllCakes() throws Exception { .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(20)); } + + @Test + void testGetCakeById() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/cakes/1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{'title': 'Lemon cheesecake', 'description': 'A cheesecake made of lemon', 'image': 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'}")); + } + + @Test + void testGetCakeByIdReturns404WhenIddOESNTeXIST() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/cakes/99") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } } diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index 7a0421ca..dddeccf4 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -1,14 +1,16 @@ package com.philldenness.cakemanager.service; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.util.List; +import java.util.Optional; +import com.philldenness.cakemanager.dto.CakeDTO; import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; -import com.philldenness.cakemanager.dto.CakeDTO; import com.philldenness.cakemanager.repository.CakeRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,8 +30,9 @@ class CakeServiceTest { @Mock private CakeMapper cakeMapper; + // region all cakes @Test - void shouldCallMapperWithEntityAndReturnMapperResult() { + void shouldCallMapperWithEntity() { CakeDTO cakeDTO1 = mock(CakeDTO.class); CakeDTO cakeDTO2 = mock(CakeDTO.class); CakeEntity cakeEntity1 = mock(CakeEntity.class); @@ -44,4 +47,39 @@ void shouldCallMapperWithEntityAndReturnMapperResult() { verify(cakeMapper, times(1)).toDTO(cakeEntity2); assertEquals(List.of(cakeDTO1, cakeDTO2), cakes); } + + // endregion + + // region cake by id + @Test + void shouldCallRepoWithId() { + Long id = 1L; + when(cakeRepository.findById(anyLong())).thenReturn(Optional.of(mock(CakeEntity.class))); + + cakeService.getCakeById(id); + + verify(cakeRepository).findById(id); + } + + @Test + void shouldPassEntityToMapper() { + Long id = 1L; + CakeDTO expectedCake = mock(CakeDTO.class); + CakeEntity cakeEntity = mock(CakeEntity.class); + when(cakeRepository.findById(anyLong())).thenReturn(Optional.of(cakeEntity)); + when(cakeMapper.toDTO(any(CakeEntity.class))).thenReturn(expectedCake); + + CakeDTO cakeDTO = cakeService.getCakeById(id); + + verify(cakeMapper).toDTO(cakeEntity); + assertEquals(expectedCake, cakeDTO); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { + when(cakeRepository.findById(anyLong())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> cakeService.getCakeById(9L)); + } + // endregion } \ No newline at end of file From 165eb55c67e35d6cac26d3ece5b4aa25355f3159 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 15:18:23 +0100 Subject: [PATCH 08/16] Add structured logging --- pom.xml | 16 ++++++++++++++++ .../cakemanager/controller/CakeController.java | 1 + .../cakemanager/service/CakeService.java | 9 ++++++++- src/main/resources/logback-spring.xml | 8 ++++++++ .../cakemanager/service/CakeServiceTest.java | 1 + 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/pom.xml b/pom.xml index 7a16c12c..11f3cc2e 100644 --- a/pom.xml +++ b/pom.xml @@ -22,6 +22,22 @@ org.springframework.boot spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-logging + + + + + net.logstash.logback + logstash-logback-encoder + 7.4 + + + ch.qos.logback + logback-classic + 1.3.7 org.springframework.boot diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index d5d07c6e..134d2beb 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -25,6 +25,7 @@ public List getAllCakes() { } @GetMapping("/{id}") + @Operation(summary = "Get cake by its ID") public CakeDTO getCakeById(@PathVariable Long id) { return cakeService.getCakeById(id); } diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index 159be1b2..b63d1498 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -1,5 +1,7 @@ package com.philldenness.cakemanager.service; +import static net.logstash.logback.argument.StructuredArguments.keyValue; + import java.util.List; import com.philldenness.cakemanager.dto.CakeDTO; @@ -7,8 +9,10 @@ import com.philldenness.cakemanager.mapper.CakeMapper; import com.philldenness.cakemanager.repository.CakeRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class CakeService { @@ -20,7 +24,10 @@ public List getCakes() { } public CakeDTO getCakeById(Long id) { - CakeEntity cakeEntity = repository.findById(id).orElseThrow(IllegalArgumentException::new); + CakeEntity cakeEntity = repository.findById(id).orElseThrow(() -> { + log.warn("Unknown cake ID", keyValue("cakeId", id)); + return new IllegalArgumentException(); + }); return cakeMapper.toDTO(cakeEntity); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..e67af387 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index dddeccf4..813862b1 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -81,5 +81,6 @@ void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { assertThrows(IllegalArgumentException.class, () -> cakeService.getCakeById(9L)); } + // endregion } \ No newline at end of file From 9c65f1f5efe6eece4418f3300f23785d4189a8b4 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 16:07:53 +0100 Subject: [PATCH 09/16] Add create cake flow --- pom.xml | 4 ++ .../controller/CakeController.java | 44 ++++++++++-- .../philldenness/cakemanager/dto/CakeDTO.java | 1 + .../cakemanager/dto/CakeRequest.java | 10 +++ .../cakemanager/mapper/CakeMapper.java | 12 +++- .../cakemanager/service/CakeService.java | 13 +++- .../controller/CakeControllerTest.java | 21 +++++- .../integrationTest/CreateCakeIT.java | 67 +++++++++++++++++++ .../integrationTest/GetCakeIT.java | 13 +++- .../cakemanager/mapper/CakeMapperTest.java | 20 +++++- .../cakemanager/service/CakeServiceTest.java | 20 ++++++ 11 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java create mode 100644 src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java diff --git a/pom.xml b/pom.xml index 11f3cc2e..beeecf72 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,10 @@ springdoc-openapi-starter-webmvc-ui ${springdoc-openapi.version} + + org.springframework.boot + spring-boot-starter-validation + org.projectlombok lombok diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index 134d2beb..63d6fb49 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -3,12 +3,22 @@ import java.util.List; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.service.CakeService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @@ -16,17 +26,39 @@ @RequiredArgsConstructor public class CakeController { - private final CakeService cakeService; + private final CakeService cakeService; - @GetMapping - @Operation(summary = "Get all cakes") - public List getAllCakes() { - return cakeService.getCakes(); - } + @GetMapping + @Operation(summary = "Get all cakes") + public List getAllCakes() { + return cakeService.getCakes(); + } @GetMapping("/{id}") @Operation(summary = "Get cake by its ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Found the cake", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = CakeDTO.class))}), + @ApiResponse(responseCode = "400", description = "Invalid id supplied", + content = @Content), + @ApiResponse(responseCode = "404", description = "Cake not found", + content = @Content)}) public CakeDTO getCakeById(@PathVariable Long id) { return cakeService.getCakeById(id); } + + @PostMapping + @Operation(summary = "Create a cake") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Created the cake", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = CakeDTO.class))}), + @ApiResponse(responseCode = "400", description = "Invalid payload supplied", + content = @Content) + }) + @ResponseStatus(HttpStatus.CREATED) + public CakeDTO createCake(@Valid @RequestBody CakeRequest payloadCake) { + return cakeService.create(payloadCake); + } } diff --git a/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java index e9d98f96..1cb51175 100644 --- a/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java +++ b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java @@ -1,6 +1,7 @@ package com.philldenness.cakemanager.dto; public record CakeDTO( + Long id, String title, String description, String image diff --git a/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java b/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java new file mode 100644 index 00000000..8a976203 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java @@ -0,0 +1,10 @@ +package com.philldenness.cakemanager.dto; + +import jakarta.validation.constraints.NotNull; + +public record CakeRequest( + @NotNull String title, + @NotNull String description, + @NotNull String image +) { +} \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java b/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java index 2b2e39fd..592886c8 100644 --- a/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java +++ b/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java @@ -1,12 +1,22 @@ package com.philldenness.cakemanager.mapper; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import org.springframework.stereotype.Component; @Component public class CakeMapper { public CakeDTO toDTO(CakeEntity entity) { - return new CakeDTO(entity.getTitle(), entity.getDescription(), entity.getImage()); + return new CakeDTO(entity.getId(), entity.getTitle(), entity.getDescription(), entity.getImage()); + } + + public CakeEntity toEntity(CakeRequest cakeRequest) { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setTitle(cakeRequest.title()); + cakeEntity.setDescription(cakeRequest.description()); + cakeEntity.setImage(cakeRequest.image()); + + return cakeEntity; } } diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index b63d1498..0e88c211 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -5,6 +5,7 @@ import java.util.List; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; import com.philldenness.cakemanager.repository.CakeRepository; @@ -17,10 +18,10 @@ @RequiredArgsConstructor public class CakeService { private final CakeRepository repository; - private final CakeMapper cakeMapper; + private final CakeMapper mapper; public List getCakes() { - return repository.findAll().stream().map(cakeMapper::toDTO).toList(); + return repository.findAll().stream().map(mapper::toDTO).toList(); } public CakeDTO getCakeById(Long id) { @@ -28,6 +29,12 @@ public CakeDTO getCakeById(Long id) { log.warn("Unknown cake ID", keyValue("cakeId", id)); return new IllegalArgumentException(); }); - return cakeMapper.toDTO(cakeEntity); + return mapper.toDTO(cakeEntity); + } + + public CakeDTO create(CakeRequest toSave) { + CakeEntity cakeEntity = mapper.toEntity(toSave); + CakeEntity savedEntity = repository.save(cakeEntity); + return mapper.toDTO(savedEntity); } } diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java index 4f93e9fe..18c20d65 100644 --- a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -2,12 +2,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.service.CakeService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,7 +30,7 @@ class CakeControllerTest { // region allCakes @Test void shouldReturnAllCakesFromCakeService() { - List cakes = List.of(new CakeDTO("title", "description", "image")); + List cakes = List.of(new CakeDTO(1L, "title", "description", "image")); when(cakeService.getCakes()).thenReturn(cakes); List cakeList = cakeController.getAllCakes(); @@ -40,7 +43,7 @@ void shouldReturnAllCakesFromCakeService() { @Test void shouldCallServiceWithPathIdAndReturnCakeDto() { Long cakeId = 1L; - CakeDTO expectedCake = new CakeDTO("title", "description", "image"); + CakeDTO expectedCake = new CakeDTO(cakeId, "title", "description", "image"); when(cakeService.getCakeById(cakeId)).thenReturn(expectedCake); CakeDTO cakeDTO = cakeController.getCakeById(cakeId); @@ -56,4 +59,18 @@ void shouldThrowIllegalArgumentExceptionWhenServiceThrowsIllegalArgumentExceptio } // endregion + + // region create cake + @Test + void shouldCallServiceWithCakePayload() { + CakeDTO expectedCake = new CakeDTO(1L, "title", "desc", "image"); + CakeRequest payloadCake = new CakeRequest( "title", "desc", "image"); + when(cakeService.create(any(CakeRequest.class))).thenReturn(expectedCake); + + CakeDTO cakeDTO = cakeController.createCake(payloadCake); + + verify(cakeService).create(payloadCake); + assertEquals(expectedCake, cakeDTO); + } + // endregion } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java new file mode 100644 index 00000000..45101f88 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java @@ -0,0 +1,67 @@ +package com.philldenness.cakemanager.integrationTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.philldenness.cakemanager.dto.CakeRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +public class CreateCakeIT { + + @Autowired + private MockMvc mvc; + + @Test + void testValidPostCreatesCake() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/cakes") + .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(content().json("{id: 21, title: 'new title', description: 'new description', image: 'new image'}")); + } + + @Test + void testInvalidPostCreatesCakeWithNullTitle() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/cakes") + .content(asJsonString(new CakeRequest(null, "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void testInvalidPostCreatesCakeWithNullDescription() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/cakes") + .content(asJsonString(new CakeRequest("new title", null, "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void testInvalidPostCreatesCakeWithNullImage() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/cakes") + .content(asJsonString(new CakeRequest("new title", "new description", null))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + public static String asJsonString(final Object obj) { + try { + return new ObjectMapper().writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java index 443e813f..abee6ca9 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -19,6 +19,7 @@ public class GetCakeIT { @Autowired private MockMvc mvc; + // region get all @Test void testGetCakesReturnsAllCakes() throws Exception { mvc.perform(MockMvcRequestBuilders.get("/cakes") @@ -26,7 +27,9 @@ void testGetCakesReturnsAllCakes() throws Exception { .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(20)); } + // endregion + // region get by id @Test void testGetCakeById() throws Exception { mvc.perform(MockMvcRequestBuilders.get("/cakes/1") @@ -36,9 +39,17 @@ void testGetCakeById() throws Exception { } @Test - void testGetCakeByIdReturns404WhenIddOESNTeXIST() throws Exception { + void testGetCakeByIdReturns404WhenIdDoesntExist() throws Exception { mvc.perform(MockMvcRequestBuilders.get("/cakes/99") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } + + @Test + void testGetCakeByIdReturns400WhenIdIsNotALong() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/cakes/abc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + // endregion } diff --git a/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java b/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java index 7950e4d8..e199ce5c 100644 --- a/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java +++ b/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java @@ -1,8 +1,10 @@ package com.philldenness.cakemanager.mapper; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,16 +18,30 @@ class CakeMapperTest { private CakeMapper cakeMapper; @Test - void shouldConvertEntityToDTO() { + void shouldMapEntityToDto() { CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setId(1L); cakeEntity.setTitle("a title"); cakeEntity.setDescription("a description"); cakeEntity.setImage("an image"); CakeDTO cakeDTO = cakeMapper.toDTO(cakeEntity); + assertEquals(cakeEntity.getId(), cakeDTO.id()); assertEquals(cakeEntity.getTitle(), cakeDTO.title()); assertEquals(cakeEntity.getDescription(), cakeDTO.description()); assertEquals(cakeEntity.getImage(), cakeDTO.image()); } + + @Test + void shouldMapRequestToEntity() { + CakeRequest cakeRequest = new CakeRequest( "a title", "a desc", "an image"); + + CakeEntity cakeEntity = cakeMapper.toEntity(cakeRequest); + + assertNull(cakeEntity.getId()); + assertEquals(cakeRequest.title(), cakeEntity.getTitle()); + assertEquals(cakeRequest.description(), cakeEntity.getDescription()); + assertEquals(cakeRequest.image(), cakeEntity.getImage()); + } } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index 813862b1..31e927e6 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -9,6 +9,7 @@ import java.util.Optional; import com.philldenness.cakemanager.dto.CakeDTO; +import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; import com.philldenness.cakemanager.repository.CakeRepository; @@ -83,4 +84,23 @@ void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { } // endregion + + // region create cake + @Test + void shouldPassDtoToMapper() { + CakeEntity cakeEntity = mock(CakeEntity.class); + CakeEntity savedEntity = mock(CakeEntity.class); + CakeRequest toSave = mock(CakeRequest.class); + CakeDTO fromEntity = mock(CakeDTO.class); + + when(cakeMapper.toEntity(toSave)).thenReturn(cakeEntity); + when(cakeRepository.save(cakeEntity)).thenReturn(savedEntity); + when(cakeMapper.toDTO(savedEntity)).thenReturn(fromEntity); + + CakeDTO savedCake = cakeService.create(toSave); + + verify(cakeRepository).save(cakeEntity); + assertEquals(fromEntity, savedCake); + } + // endregion } \ No newline at end of file From b781878c97eff2ac4bfbfe4dbc5f87b351c6a991 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 16:33:52 +0100 Subject: [PATCH 10/16] Add update cake flow --- .../controller/CakeController.java | 15 ++++ .../cakemanager/service/CakeService.java | 12 ++++ .../controller/CakeControllerTest.java | 21 +++++- .../integrationTest/CreateCakeIT.java | 10 +-- .../integrationTest/UpdateCakeIT.java | 69 +++++++++++++++++++ .../cakemanager/service/CakeServiceTest.java | 31 ++++++++- .../cakemanager/testUtils/TestUtils.java | 13 ++++ 7 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java create mode 100644 src/test/java/com/philldenness/cakemanager/testUtils/TestUtils.java diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index 63d6fb49..287ce6e3 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; @@ -61,4 +62,18 @@ public CakeDTO getCakeById(@PathVariable Long id) { public CakeDTO createCake(@Valid @RequestBody CakeRequest payloadCake) { return cakeService.create(payloadCake); } + + @PutMapping("/{id}") + @Operation(summary = "Update cake") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Updated the cake", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = CakeDTO.class))}), + @ApiResponse(responseCode = "400", description = "Invalid payload supplied", + content = @Content), + @ApiResponse(responseCode = "404", description = "Cake not found", + content = @Content)}) + public CakeDTO updateCake(@PathVariable Long id, @Valid @RequestBody CakeRequest payloadCake) { + return cakeService.update(id, payloadCake); + } } diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index 0e88c211..71b97ab0 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -37,4 +37,16 @@ public CakeDTO create(CakeRequest toSave) { CakeEntity savedEntity = repository.save(cakeEntity); return mapper.toDTO(savedEntity); } + + // TODO Maybe transactions? + public CakeDTO update(Long id, CakeRequest toSave) { + CakeEntity oldEntity = repository.findById(id).orElseThrow(() -> { + log.warn("Could not find cake to update", keyValue("cakeId", id)); + return new IllegalArgumentException(); + }); + CakeEntity newPartialEntity = mapper.toEntity(toSave); + newPartialEntity.setId(oldEntity.getId()); + CakeEntity savedEntity = repository.save(newPartialEntity); + return mapper.toDTO(savedEntity); + } } diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java index 18c20d65..8da85fc9 100644 --- a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -62,15 +62,30 @@ void shouldThrowIllegalArgumentExceptionWhenServiceThrowsIllegalArgumentExceptio // region create cake @Test - void shouldCallServiceWithCakePayload() { + void shouldCallCreateWithCakePayload() { CakeDTO expectedCake = new CakeDTO(1L, "title", "desc", "image"); CakeRequest payloadCake = new CakeRequest( "title", "desc", "image"); when(cakeService.create(any(CakeRequest.class))).thenReturn(expectedCake); - CakeDTO cakeDTO = cakeController.createCake(payloadCake); + CakeDTO createdCake = cakeController.createCake(payloadCake); verify(cakeService).create(payloadCake); - assertEquals(expectedCake, cakeDTO); + assertEquals(expectedCake, createdCake); + } + // endregion + + // region update cake + @Test + void shouldCallUpdateWithCakePayload() { + Long id = 1L; + CakeDTO expectedCake = new CakeDTO(id, "title", "desc", "image"); + CakeRequest payloadCake = new CakeRequest( "title", "desc", "image"); + when(cakeService.update(anyLong(), any(CakeRequest.class))).thenReturn(expectedCake); + + CakeDTO updatedCake = cakeController.updateCake(id, payloadCake); + + verify(cakeService).update(id, payloadCake); + assertEquals(expectedCake, updatedCake); } // endregion } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java index 45101f88..b9adb449 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java @@ -1,9 +1,9 @@ package com.philldenness.cakemanager.integrationTest; +import static com.philldenness.cakemanager.testUtils.TestUtils.asJsonString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.fasterxml.jackson.databind.ObjectMapper; import com.philldenness.cakemanager.dto.CakeRequest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -56,12 +56,4 @@ void testInvalidPostCreatesCakeWithNullImage() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } - - public static String asJsonString(final Object obj) { - try { - return new ObjectMapper().writeValueAsString(obj); - } catch (Exception e) { - throw new RuntimeException(e); - } - } } diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java new file mode 100644 index 00000000..bb3a3aa9 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java @@ -0,0 +1,69 @@ +package com.philldenness.cakemanager.integrationTest; + +import static com.philldenness.cakemanager.testUtils.TestUtils.asJsonString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.philldenness.cakemanager.dto.CakeRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +public class UpdateCakeIT { + + @Autowired + private MockMvc mvc; + + @Test + void testValidUpdateCake() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json("{id: 1, title: 'new title', description: 'new description', image: 'new image'}")); + } + + @Test + void testUpdateCakeReturns404WhenIdDoesntExist() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/cakes/99") + .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + + @Test + void testInvalidPostCreatesCakeWithNullTitle() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + .content(asJsonString(new CakeRequest(null, "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void testInvalidPostCreatesCakeWithNullDescription() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + .content(asJsonString(new CakeRequest("new title", null, "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void testInvalidPostCreatesCakeWithNullImage() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + .content(asJsonString(new CakeRequest("new title", "new description", null))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index 31e927e6..4455035e 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -87,7 +87,7 @@ void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { // region create cake @Test - void shouldPassDtoToMapper() { + void shouldPassCreateRequestToMapper() { CakeEntity cakeEntity = mock(CakeEntity.class); CakeEntity savedEntity = mock(CakeEntity.class); CakeRequest toSave = mock(CakeRequest.class); @@ -103,4 +103,33 @@ void shouldPassDtoToMapper() { assertEquals(fromEntity, savedCake); } // endregion + + // region update cake + @Test + void shouldPassUpdateRequestToMapper() { + Long id = 1L; + CakeEntity newEntity = mock(CakeEntity.class); + CakeEntity oldEntity = mock(CakeEntity.class); + CakeEntity newSavedEntity = mock(CakeEntity.class); + CakeRequest toSave = mock(CakeRequest.class); + CakeDTO fromEntity = mock(CakeDTO.class); + + when(cakeMapper.toEntity(toSave)).thenReturn(newEntity); + when(cakeRepository.findById(id)).thenReturn(Optional.of(oldEntity)); + when(cakeRepository.save(newEntity)).thenReturn(newSavedEntity); + when(cakeMapper.toDTO(newSavedEntity)).thenReturn(fromEntity); + + CakeDTO savedCake = cakeService.update(id, toSave); + + verify(cakeRepository).save(newEntity); + assertEquals(fromEntity, savedCake); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenUpdateIdIsNotFound() { + when(cakeRepository.findById(anyLong())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> cakeService.update(9L, mock(CakeRequest.class))); + } + // endregion } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/testUtils/TestUtils.java b/src/test/java/com/philldenness/cakemanager/testUtils/TestUtils.java new file mode 100644 index 00000000..423f99e3 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/testUtils/TestUtils.java @@ -0,0 +1,13 @@ +package com.philldenness.cakemanager.testUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class TestUtils { + public static String asJsonString(final Object obj) { + try { + return new ObjectMapper().writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} From 7f8279dbd0fd3fd72364324073e615b0db725906 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 16:52:31 +0100 Subject: [PATCH 11/16] Add delete cake flow --- .../controller/CakeController.java | 13 +++++ .../cakemanager/service/CakeService.java | 8 ++++ .../controller/CakeControllerTest.java | 48 +++++++++++++++---- .../integrationTest/DeleteCakeIT.java | 44 +++++++++++++++++ .../cakemanager/service/CakeServiceTest.java | 21 +++++++- 5 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index 287ce6e3..46579af4 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -13,6 +13,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -76,4 +77,16 @@ public CakeDTO createCake(@Valid @RequestBody CakeRequest payloadCake) { public CakeDTO updateCake(@PathVariable Long id, @Valid @RequestBody CakeRequest payloadCake) { return cakeService.update(id, payloadCake); } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Deleted the cake", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = CakeDTO.class))}), + @ApiResponse(responseCode = "404", description = "Cake not found", + content = @Content)}) + public void deleteCake(@PathVariable Long id) { + cakeService.delete(id); + } } diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index 71b97ab0..0b220b34 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -49,4 +49,12 @@ public CakeDTO update(Long id, CakeRequest toSave) { CakeEntity savedEntity = repository.save(newPartialEntity); return mapper.toDTO(savedEntity); } + + public void delete(Long id) { + if (!repository.existsById(id)) { + log.warn("Could not find cake to delete", keyValue("cakeId", id)); + throw new IllegalArgumentException(); + } + repository.deleteById(id); + } } diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java index 8da85fc9..ab1d1e07 100644 --- a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -4,8 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import java.util.List; @@ -30,7 +29,7 @@ class CakeControllerTest { // region allCakes @Test void shouldReturnAllCakesFromCakeService() { - List cakes = List.of(new CakeDTO(1L, "title", "description", "image")); + List cakes = List.of(mock(CakeDTO.class)); when(cakeService.getCakes()).thenReturn(cakes); List cakeList = cakeController.getAllCakes(); @@ -43,7 +42,7 @@ void shouldReturnAllCakesFromCakeService() { @Test void shouldCallServiceWithPathIdAndReturnCakeDto() { Long cakeId = 1L; - CakeDTO expectedCake = new CakeDTO(cakeId, "title", "description", "image"); + CakeDTO expectedCake = mock(CakeDTO.class); when(cakeService.getCakeById(cakeId)).thenReturn(expectedCake); CakeDTO cakeDTO = cakeController.getCakeById(cakeId); @@ -52,7 +51,7 @@ void shouldCallServiceWithPathIdAndReturnCakeDto() { } @Test - void shouldThrowIllegalArgumentExceptionWhenServiceThrowsIllegalArgumentException() { + void shouldThrowIllegalArgumentExceptionWhenGetByIdThrowsIllegalArgumentException() { when(cakeService.getCakeById(anyLong())).thenThrow(IllegalArgumentException.class); assertThrows(IllegalArgumentException.class, () -> cakeController.getCakeById(9L)); @@ -63,8 +62,8 @@ void shouldThrowIllegalArgumentExceptionWhenServiceThrowsIllegalArgumentExceptio // region create cake @Test void shouldCallCreateWithCakePayload() { - CakeDTO expectedCake = new CakeDTO(1L, "title", "desc", "image"); - CakeRequest payloadCake = new CakeRequest( "title", "desc", "image"); + CakeDTO expectedCake = mock(CakeDTO.class); + CakeRequest payloadCake = mock(CakeRequest.class); when(cakeService.create(any(CakeRequest.class))).thenReturn(expectedCake); CakeDTO createdCake = cakeController.createCake(payloadCake); @@ -72,14 +71,21 @@ void shouldCallCreateWithCakePayload() { verify(cakeService).create(payloadCake); assertEquals(expectedCake, createdCake); } + + @Test + void shouldThrowIllegalArgumentExceptionWhenCreateThrowsIllegalArgumentException() { + when(cakeService.create(any(CakeRequest.class))).thenThrow(IllegalArgumentException.class); + + assertThrows(IllegalArgumentException.class, () -> cakeController.createCake(mock(CakeRequest.class))); + } // endregion // region update cake @Test void shouldCallUpdateWithCakePayload() { Long id = 1L; - CakeDTO expectedCake = new CakeDTO(id, "title", "desc", "image"); - CakeRequest payloadCake = new CakeRequest( "title", "desc", "image"); + CakeDTO expectedCake = mock(CakeDTO.class); + CakeRequest payloadCake = mock(CakeRequest.class); when(cakeService.update(anyLong(), any(CakeRequest.class))).thenReturn(expectedCake); CakeDTO updatedCake = cakeController.updateCake(id, payloadCake); @@ -87,5 +93,29 @@ void shouldCallUpdateWithCakePayload() { verify(cakeService).update(id, payloadCake); assertEquals(expectedCake, updatedCake); } + + @Test + void shouldThrowIllegalArgumentExceptionWhenUpdateThrowsIllegalArgumentException() { + when(cakeService.update(anyLong(), any(CakeRequest.class))).thenThrow(IllegalArgumentException.class); + + assertThrows(IllegalArgumentException.class, () -> cakeController.updateCake(1L, mock(CakeRequest.class))); + } + // endregion + + // region delete cake + @Test + void shouldCallDeleteWithId() { + Long id = 1L; + cakeController.deleteCake(id); + + verify(cakeService).delete(id); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenDeleteThrowsIllegalArgumentException() { + doThrow(IllegalArgumentException.class).when(cakeService).delete(anyLong()); + + assertThrows(IllegalArgumentException.class, () -> cakeController.deleteCake(1L)); + } // endregion } \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java new file mode 100644 index 00000000..f49b8472 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java @@ -0,0 +1,44 @@ +package com.philldenness.cakemanager.integrationTest; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.philldenness.cakemanager.entity.CakeEntity; +import com.philldenness.cakemanager.repository.CakeRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +public class DeleteCakeIT { + + @Autowired + private MockMvc mvc; + + @Autowired + private CakeRepository cakeRepository; + + @Test + void testDeleteCake() throws Exception { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setImage("to delete"); + cakeEntity.setTitle("to delete"); + cakeEntity.setDescription("to delete"); + CakeEntity deleteEntity = cakeRepository.save(cakeEntity); + + mvc.perform(MockMvcRequestBuilders.delete("/cakes/" + deleteEntity.getId()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void testDeleteNonExistentCake() throws Exception { + mvc.perform(MockMvcRequestBuilders.delete("/cakes/99") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index 4455035e..da12cf2f 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -33,7 +33,7 @@ class CakeServiceTest { // region all cakes @Test - void shouldCallMapperWithEntity() { + void shouldMapDtoForEachWithEntity() { CakeDTO cakeDTO1 = mock(CakeDTO.class); CakeDTO cakeDTO2 = mock(CakeDTO.class); CakeEntity cakeEntity1 = mock(CakeEntity.class); @@ -132,4 +132,23 @@ void shouldThrowIllegalArgumentExceptionWhenUpdateIdIsNotFound() { assertThrows(IllegalArgumentException.class, () -> cakeService.update(9L, mock(CakeRequest.class))); } // endregion + + // region delete cake + @Test + void shouldCallRepoDeleteByIdWithSuppliedId() { + Long id = 1L; + when(cakeRepository.existsById(anyLong())).thenReturn(true); + + cakeService.delete(id); + + verify(cakeRepository).deleteById(id); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenDeleteIdIsNotFound() { + when(cakeRepository.existsById(anyLong())).thenReturn(false); + + assertThrows(IllegalArgumentException.class, () -> cakeService.delete(9L)); + } + // endregion } \ No newline at end of file From 50b3a7c13082c14ee0c50a7221343dd74436636c Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sat, 26 Aug 2023 18:37:44 +0100 Subject: [PATCH 12/16] Catch and log exceptions --- README.txt | 32 -------- .../controller/CakeController.java | 6 +- .../ControllerExceptionHandler.java | 11 ++- .../cakemanager/service/CakeService.java | 8 +- .../controller/CakeControllerTest.java | 11 ++- .../integrationTest/UpdateCakeIT.java | 26 ++++++- .../cakemanager/service/CakeServiceTest.java | 73 ++++++++++++++++++- 7 files changed, 119 insertions(+), 48 deletions(-) diff --git a/README.txt b/README.txt index 271b3dee..2bd82b46 100644 --- a/README.txt +++ b/README.txt @@ -25,38 +25,6 @@ Bonus points: * Continuous Integration via any cloud CI system * Containerisation -Scope -* Only the API and related code is in scope. No GUI of any kind is required - - -Original Project Info -===================== - -To run a server locally execute the following command: - -`mvn jetty:run` - -and access the following URL: - -`http://localhost:8282/` - -Feel free to change how the project is run, but clear instructions must be given in README -You can use any IDE you like, so long as the project can build and run with Maven or Gradle. - -The project loads some pre-defined data in to an in-memory database, which is acceptable for this exercise. There is -no need to create persistent storage. - - -Submission -========== - -Please provide your version of this project as a git repository (e.g. Github, BitBucket, etc). - -A fork of this repo, or a Pull Request would be suitable. - -Good luck! - - Notes from Phill ========== diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index 46579af4..d421792b 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -46,7 +46,7 @@ public List getAllCakes() { content = @Content), @ApiResponse(responseCode = "404", description = "Cake not found", content = @Content)}) - public CakeDTO getCakeById(@PathVariable Long id) { + public CakeDTO getCakeById(@PathVariable long id) { return cakeService.getCakeById(id); } @@ -74,7 +74,7 @@ public CakeDTO createCake(@Valid @RequestBody CakeRequest payloadCake) { content = @Content), @ApiResponse(responseCode = "404", description = "Cake not found", content = @Content)}) - public CakeDTO updateCake(@PathVariable Long id, @Valid @RequestBody CakeRequest payloadCake) { + public CakeDTO updateCake(@PathVariable long id, @Valid @RequestBody CakeRequest payloadCake) { return cakeService.update(id, payloadCake); } @@ -86,7 +86,7 @@ public CakeDTO updateCake(@PathVariable Long id, @Valid @RequestBody CakeRequest schema = @Schema(implementation = CakeDTO.class))}), @ApiResponse(responseCode = "404", description = "Cake not found", content = @Content)}) - public void deleteCake(@PathVariable Long id) { + public void deleteCake(@PathVariable long id) { cakeService.delete(id); } } diff --git a/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java index e84ae2dc..a92809b6 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java +++ b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java @@ -1,15 +1,24 @@ package com.philldenness.cakemanager.controller; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @ControllerAdvice +@Slf4j public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler({ IllegalArgumentException.class }) - public ResponseEntity handleAccessDeniedException() { + public ResponseEntity handleIllegalArgumentException() { return ResponseEntity.notFound().build(); } + + + @ExceptionHandler({ Exception.class }) + public ResponseEntity handleException(Exception ex) { + log.error("Returning internal server error due to exception", ex); + return ResponseEntity.internalServerError().build(); + } } \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index 0b220b34..d67a0b85 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @@ -38,7 +39,7 @@ public CakeDTO create(CakeRequest toSave) { return mapper.toDTO(savedEntity); } - // TODO Maybe transactions? + @Transactional public CakeDTO update(Long id, CakeRequest toSave) { CakeEntity oldEntity = repository.findById(id).orElseThrow(() -> { log.warn("Could not find cake to update", keyValue("cakeId", id)); @@ -46,10 +47,11 @@ public CakeDTO update(Long id, CakeRequest toSave) { }); CakeEntity newPartialEntity = mapper.toEntity(toSave); newPartialEntity.setId(oldEntity.getId()); - CakeEntity savedEntity = repository.save(newPartialEntity); - return mapper.toDTO(savedEntity); + + return mapper.toDTO(repository.save(newPartialEntity)); } + @Transactional public void delete(Long id) { if (!repository.existsById(id)) { log.warn("Could not find cake to delete", keyValue("cakeId", id)); diff --git a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java index ab1d1e07..53fcf536 100644 --- a/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -4,7 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; @@ -41,7 +44,7 @@ void shouldReturnAllCakesFromCakeService() { // region cake by id @Test void shouldCallServiceWithPathIdAndReturnCakeDto() { - Long cakeId = 1L; + long cakeId = 1L; CakeDTO expectedCake = mock(CakeDTO.class); when(cakeService.getCakeById(cakeId)).thenReturn(expectedCake); @@ -83,7 +86,7 @@ void shouldThrowIllegalArgumentExceptionWhenCreateThrowsIllegalArgumentException // region update cake @Test void shouldCallUpdateWithCakePayload() { - Long id = 1L; + long id = 1L; CakeDTO expectedCake = mock(CakeDTO.class); CakeRequest payloadCake = mock(CakeRequest.class); when(cakeService.update(anyLong(), any(CakeRequest.class))).thenReturn(expectedCake); @@ -105,7 +108,7 @@ void shouldThrowIllegalArgumentExceptionWhenUpdateThrowsIllegalArgumentException // region delete cake @Test void shouldCallDeleteWithId() { - Long id = 1L; + long id = 1L; cakeController.deleteCake(id); verify(cakeService).delete(id); diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java index bb3a3aa9..ae3228f0 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java @@ -1,10 +1,13 @@ package com.philldenness.cakemanager.integrationTest; import static com.philldenness.cakemanager.testUtils.TestUtils.asJsonString; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.philldenness.cakemanager.dto.CakeRequest; +import com.philldenness.cakemanager.entity.CakeEntity; +import com.philldenness.cakemanager.repository.CakeRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -20,14 +23,30 @@ public class UpdateCakeIT { @Autowired private MockMvc mvc; + @Autowired + private CakeRepository cakeRepository; + @Test void testValidUpdateCake() throws Exception { - mvc.perform(MockMvcRequestBuilders.put("/cakes/1") - .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + CakeEntity existingEntity = new CakeEntity(); + existingEntity.setTitle("old title"); + existingEntity.setDescription("old desc"); + existingEntity.setImage("old image"); + existingEntity = cakeRepository.save(existingEntity); + + CakeRequest request = new CakeRequest("new title", "new description", "new image"); + + mvc.perform(MockMvcRequestBuilders.put("/cakes/" + existingEntity.getId()) + .content(asJsonString(request)) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(content().json("{id: 1, title: 'new title', description: 'new description', image: 'new image'}")); + .andExpect(content().json("{title: 'new title', description: 'new description', image: 'new image'}")); + + CakeEntity updatedEntity = cakeRepository.findById(existingEntity.getId()).orElseThrow(); + assertEquals(request.title(), updatedEntity.getTitle()); + assertEquals(request.description(), updatedEntity.getDescription()); + assertEquals(request.image(), updatedEntity.getImage()); } @Test @@ -39,7 +58,6 @@ void testUpdateCakeReturns404WhenIdDoesntExist() throws Exception { .andExpect(status().isNotFound()); } - @Test void testInvalidPostCreatesCakeWithNullTitle() throws Exception { mvc.perform(MockMvcRequestBuilders.put("/cakes/1") diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index da12cf2f..1ebe5e8c 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -3,7 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import java.util.Optional; @@ -18,6 +23,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.InvalidDataAccessResourceUsageException; @ExtendWith(MockitoExtension.class) class CakeServiceTest { @@ -49,6 +55,13 @@ void shouldMapDtoForEachWithEntity() { assertEquals(List.of(cakeDTO1, cakeDTO2), cakes); } + @Test + void shouldPropagateExceptionFromFindAll() { + when(cakeRepository.findAll()).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, () -> cakeService.getCakes()); + } + // endregion // region cake by id @@ -83,6 +96,12 @@ void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { assertThrows(IllegalArgumentException.class, () -> cakeService.getCakeById(9L)); } + @Test + void shouldPropagateExceptionFromFindById() { + when(cakeRepository.findById(anyLong())).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, () -> cakeService.getCakeById(1L)); + } // endregion // region create cake @@ -102,6 +121,17 @@ void shouldPassCreateRequestToMapper() { verify(cakeRepository).save(cakeEntity); assertEquals(fromEntity, savedCake); } + + @Test + void shouldPropagateExceptionFromSave() { + CakeRequest toSave = mock(CakeRequest.class); + when(cakeMapper.toEntity(toSave)).thenReturn(mock(CakeEntity.class)); + when(cakeRepository.save(any(CakeEntity.class))).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, + () -> cakeService.create(toSave) + ); + } // endregion // region update cake @@ -131,6 +161,26 @@ void shouldThrowIllegalArgumentExceptionWhenUpdateIdIsNotFound() { assertThrows(IllegalArgumentException.class, () -> cakeService.update(9L, mock(CakeRequest.class))); } + + @Test + void shouldPropagateExceptionWhenFindByIdThrowsException() { + when(cakeRepository.findById(anyLong())).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, () -> cakeService.update(1L, mock(CakeRequest.class))); + } + + @Test + void shouldPropagateExceptionWhenSaveThrowsException() { + CakeEntity newEntity = mock(CakeEntity.class); + CakeEntity oldEntity = mock(CakeEntity.class); + CakeRequest toSave = mock(CakeRequest.class); + + when(cakeMapper.toEntity(any(CakeRequest.class))).thenReturn(newEntity); + when(cakeRepository.findById(anyLong())).thenReturn(Optional.of(oldEntity)); + when(cakeRepository.save(any(CakeEntity.class))).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, () -> cakeService.update(1L, toSave)); + } // endregion // region delete cake @@ -150,5 +200,26 @@ void shouldThrowIllegalArgumentExceptionWhenDeleteIdIsNotFound() { assertThrows(IllegalArgumentException.class, () -> cakeService.delete(9L)); } + + @Test + void shouldPropagateExceptionFromDelete() { + Long id = 1L; + when(cakeRepository.existsById(anyLong())).thenReturn(true); + doThrow(mock(InvalidDataAccessResourceUsageException.class)).when(cakeRepository).deleteById(anyLong()); + + assertThrows(InvalidDataAccessResourceUsageException.class, + () -> cakeService.delete(id) + ); + } + + @Test + void shouldPropagateExceptionFromExistsById() { + Long id = 1L; + when(cakeRepository.existsById(anyLong())).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); + + assertThrows(InvalidDataAccessResourceUsageException.class, + () -> cakeService.delete(id) + ); + } // endregion } \ No newline at end of file From bad2cd7bea013e9425b9510336ae49d9b73dcfdb Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sun, 27 Aug 2023 14:43:00 +0100 Subject: [PATCH 13/16] Add prometheus counters and docker container --- README.txt => README.md | 73 ++++++++++++------ compose.yml | 9 ++- pom.xml | 8 ++ prometheus.yml | 15 ++++ .../ControllerExceptionHandler.java | 8 ++ .../cakemanager/metrics/CounterConfig.java | 59 +++++++++++++++ .../cakemanager/metrics/CounterManager.java | 34 +++++++++ .../cakemanager/metrics/CounterName.java | 21 ++++++ .../cakemanager/service/CakeService.java | 8 ++ src/main/resources/application.properties | 3 + .../ControllerExceptionHandlerTest.java | 36 +++++++++ .../metrics/CounterManagerTest.java | 69 +++++++++++++++++ .../cakemanager/metrics/CounterNameTest.java | 19 +++++ .../cakemanager/service/CakeServiceTest.java | 75 ++++++++++++++++++- 14 files changed, 412 insertions(+), 25 deletions(-) rename README.txt => README.md (52%) create mode 100644 prometheus.yml create mode 100644 src/main/java/com/philldenness/cakemanager/metrics/CounterConfig.java create mode 100644 src/main/java/com/philldenness/cakemanager/metrics/CounterManager.java create mode 100644 src/main/java/com/philldenness/cakemanager/metrics/CounterName.java create mode 100644 src/test/java/com/philldenness/cakemanager/controller/ControllerExceptionHandlerTest.java create mode 100644 src/test/java/com/philldenness/cakemanager/metrics/CounterManagerTest.java create mode 100644 src/test/java/com/philldenness/cakemanager/metrics/CounterNameTest.java diff --git a/README.txt b/README.md similarity index 52% rename from README.txt rename to README.md index 2bd82b46..18c53243 100644 --- a/README.txt +++ b/README.md @@ -25,33 +25,62 @@ Bonus points: * Continuous Integration via any cloud CI system * Containerisation +--- Notes from Phill ========== +

+ CICD • + Starting app • + Documentation • + Next steps +

-Requires Java 20 to run and requires lombok annotations to be enabled on intelliJ. I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ -Flyway migration scripts run automatically on startup. -CICD -Github actions workflow is setup to automatically test and build the docker container. Then it is pushed to dockerhub which can be viewed here: https://hub.docker.com/r/phillipdenness1/cakemanager -Starting app -Pull from dockerhub -docker run --publish 8080:8080 phillipdenness1/cakemanager:latest - -Build and run docker -Build the container `docker build --tag cakemanagerpd . ` -Run the container `docker run --publish 8080:8080 cakemanagerpd` - -Using docker-compose -run docker compose `docker-compose up` +## CICD +* Github actions workflow is setup to automatically test and build the docker container. Then it is pushed to dockerhub which can be viewed here: https://hub.docker.com/r/phillipdenness1/cakemanager +* Flyway migration scripts run automatically on startup. -Using mvn -run `mvn spring-boot:run` +--- +## Starting app -Documentation -Swagger is on http://localhost:8080/swagger-ui/index.html - -Next steps: -Add pagination and sorting to getAll endpoint -Extend CICD to deploy to cloud \ No newline at end of file +Choose 1 method below: +### Using docker-compose +```bash +# run docker compose +docker-compose up +``` +#### OR +### Pull from dockerhub +```bash +docker run --publish 8080:8080 phillipdenness1/cakemanager:latest +``` +#### OR +### Build and run docker +```bash +# Build the container +docker build --tag cakemanagerpd . +# Run the container +docker run --publish 8080:8080 cakemanagerpd +``` +#### OR + +### mvn +```bash +mvn spring-boot:run +``` +--- +## Documentation +Swagger documentation + - http://localhost:8080/swagger-ui/index.html + +## Monitoring +* Logback with Logstash encoder to support searchable logs +* Prometheus is available when running via docker-compose. + - Prometheus UI: http://localhost:9090/targets?search= + - find_all_counter_total, find_by_id_counter_total, save_counter_total, update_counter_total, delete_counter_total, unknown_error_counter_total, bad_request_counter_total + +## Next steps +* Add pagination and sorting to getAll endpoint +* Extend CICD to deploy to cloud \ No newline at end of file diff --git a/compose.yml b/compose.yml index 32378223..be299380 100644 --- a/compose.yml +++ b/compose.yml @@ -3,4 +3,11 @@ services: container_name: cakemanagerpd build: . ports: - - 8080:8080 \ No newline at end of file + - 8080:8080 + prometheus: + image: 'prom/prometheus' + ports: + - '9090:9090' + command: '--config.file=/etc/prometheus/config.yml' + volumes: + - './prometheus.yml:/etc/prometheus/config.yml' \ No newline at end of file diff --git a/pom.xml b/pom.xml index beeecf72..d6b00a70 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,14 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + org.projectlombok lombok diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 00000000..e40efc0f --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,15 @@ +# Sample Prometheus config +# This assumes that your Prometheus instance can access this application on localhost:8080 + +global: + scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute. + evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute. + # scrape_timeout is set to the global default (10s). + +scrape_configs: + - job_name: 'spring boot scrape' + metrics_path: '/actuator/prometheus' + scrape_interval: 5s + static_configs: + - targets: + - cakemanagerpd:8080 \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java index a92809b6..ab392a6d 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java +++ b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java @@ -1,5 +1,8 @@ package com.philldenness.cakemanager.controller; +import com.philldenness.cakemanager.metrics.CounterManager; +import com.philldenness.cakemanager.metrics.CounterName; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; @@ -8,10 +11,14 @@ @ControllerAdvice @Slf4j +@RequiredArgsConstructor public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { + private final CounterManager counterManager; + @ExceptionHandler({ IllegalArgumentException.class }) public ResponseEntity handleIllegalArgumentException() { + counterManager.increment(CounterName.BAD_REQUEST); return ResponseEntity.notFound().build(); } @@ -19,6 +26,7 @@ public ResponseEntity handleIllegalArgumentException() { @ExceptionHandler({ Exception.class }) public ResponseEntity handleException(Exception ex) { log.error("Returning internal server error due to exception", ex); + counterManager.increment(CounterName.UNKNOWN_ERROR); return ResponseEntity.internalServerError().build(); } } \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/metrics/CounterConfig.java b/src/main/java/com/philldenness/cakemanager/metrics/CounterConfig.java new file mode 100644 index 00000000..898afa33 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/metrics/CounterConfig.java @@ -0,0 +1,59 @@ +package com.philldenness.cakemanager.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CounterConfig { + + @Bean + public Counter findAllCounter(MeterRegistry registry) { + return Counter.builder(CounterName.FIND_ALL_COUNTER.toString()) + .description("Number of find all requests") + .register(registry); + } + + @Bean + public Counter findByIdCounter(MeterRegistry registry) { + return Counter.builder(CounterName.FIND_BY_ID_COUNTER.toString()) + .description("Number of find by id requests") + .register(registry); + } + + @Bean + public Counter saveCounter(MeterRegistry registry) { + return Counter.builder(CounterName.SAVE.toString()) + .description("Number of save requests") + .register(registry); + } + + @Bean + public Counter updateCounter(MeterRegistry registry) { + return Counter.builder(CounterName.UPDATE.toString()) + .description("Number of update requests") + .register(registry); + } + + @Bean + public Counter deleteCounter(MeterRegistry registry) { + return Counter.builder(CounterName.DELETE.toString()) + .description("Number of delete requests") + .register(registry); + } + + @Bean + public Counter unknownErrorCounter(MeterRegistry registry) { + return Counter.builder(CounterName.UNKNOWN_ERROR.toString()) + .description("Number of unknown errors") + .register(registry); + } + + @Bean + public Counter badRequestCounter(MeterRegistry registry) { + return Counter.builder(CounterName.BAD_REQUEST.toString()) + .description("Number of bad request errors") + .register(registry); + } +} diff --git a/src/main/java/com/philldenness/cakemanager/metrics/CounterManager.java b/src/main/java/com/philldenness/cakemanager/metrics/CounterManager.java new file mode 100644 index 00000000..d5a65da9 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/metrics/CounterManager.java @@ -0,0 +1,34 @@ +package com.philldenness.cakemanager.metrics; + +import java.util.HashMap; +import java.util.Map; + +import io.micrometer.core.instrument.Counter; +import org.springframework.stereotype.Component; + +@Component +public class CounterManager { + + private final Map counterNameCounterMap; + + public CounterManager(Counter findAllCounter, + Counter findByIdCounter, + Counter saveCounter, + Counter updateCounter, + Counter deleteCounter, + Counter unknownErrorCounter, + Counter badRequestCounter) { + counterNameCounterMap = new HashMap<>(); + counterNameCounterMap.put(CounterName.FIND_ALL_COUNTER, findAllCounter); + counterNameCounterMap.put(CounterName.FIND_BY_ID_COUNTER, findByIdCounter); + counterNameCounterMap.put(CounterName.SAVE, saveCounter); + counterNameCounterMap.put(CounterName.UPDATE, updateCounter); + counterNameCounterMap.put(CounterName.DELETE, deleteCounter); + counterNameCounterMap.put(CounterName.UNKNOWN_ERROR, unknownErrorCounter); + counterNameCounterMap.put(CounterName.BAD_REQUEST, badRequestCounter); + } + + public void increment(CounterName counterName) { + counterNameCounterMap.get(counterName).increment(); + } +} diff --git a/src/main/java/com/philldenness/cakemanager/metrics/CounterName.java b/src/main/java/com/philldenness/cakemanager/metrics/CounterName.java new file mode 100644 index 00000000..17a7cb1d --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/metrics/CounterName.java @@ -0,0 +1,21 @@ +package com.philldenness.cakemanager.metrics; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum CounterName { + FIND_BY_ID_COUNTER("find_by_id_counter"), + SAVE("save_counter"), + UPDATE("update_counter"), + DELETE("delete_counter"), + UNKNOWN_ERROR("unknown_error_counter"), + BAD_REQUEST("bad_request_counter"), + FIND_ALL_COUNTER("find_all_counter"); + + private final String counterName; + + @Override + public String toString() { + return counterName; + } +} diff --git a/src/main/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java index d67a0b85..f0acd706 100644 --- a/src/main/java/com/philldenness/cakemanager/service/CakeService.java +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -8,6 +8,8 @@ import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; +import com.philldenness.cakemanager.metrics.CounterManager; +import com.philldenness.cakemanager.metrics.CounterName; import com.philldenness.cakemanager.repository.CakeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,8 +22,10 @@ public class CakeService { private final CakeRepository repository; private final CakeMapper mapper; + private final CounterManager counterManager; public List getCakes() { + counterManager.increment(CounterName.FIND_ALL_COUNTER); return repository.findAll().stream().map(mapper::toDTO).toList(); } @@ -30,12 +34,14 @@ public CakeDTO getCakeById(Long id) { log.warn("Unknown cake ID", keyValue("cakeId", id)); return new IllegalArgumentException(); }); + counterManager.increment(CounterName.FIND_BY_ID_COUNTER); return mapper.toDTO(cakeEntity); } public CakeDTO create(CakeRequest toSave) { CakeEntity cakeEntity = mapper.toEntity(toSave); CakeEntity savedEntity = repository.save(cakeEntity); + counterManager.increment(CounterName.SAVE); return mapper.toDTO(savedEntity); } @@ -48,6 +54,7 @@ public CakeDTO update(Long id, CakeRequest toSave) { CakeEntity newPartialEntity = mapper.toEntity(toSave); newPartialEntity.setId(oldEntity.getId()); + counterManager.increment(CounterName.UPDATE); return mapper.toDTO(repository.save(newPartialEntity)); } @@ -57,6 +64,7 @@ public void delete(Long id) { log.warn("Could not find cake to delete", keyValue("cakeId", id)); throw new IllegalArgumentException(); } + counterManager.increment(CounterName.DELETE); repository.deleteById(id); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 97c35704..03083664 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,8 @@ +spring.application.name=Cakemanager spring.datasource.url=jdbc:h2:mem:testdb spring.datasource.driverClassName=org.h2.Driver spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=none +management.endpoints.web.exposure.include=health,info,prometheus +management.metrics.tags.application=${spring.application.name} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/controller/ControllerExceptionHandlerTest.java b/src/test/java/com/philldenness/cakemanager/controller/ControllerExceptionHandlerTest.java new file mode 100644 index 00000000..7a7d3a92 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/controller/ControllerExceptionHandlerTest.java @@ -0,0 +1,36 @@ +package com.philldenness.cakemanager.controller; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.philldenness.cakemanager.metrics.CounterManager; +import com.philldenness.cakemanager.metrics.CounterName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ControllerExceptionHandlerTest { + + @InjectMocks + private ControllerExceptionHandler controllerExceptionHandler; + + @Mock + private CounterManager counterManager; + + @Test + void shouldIncrementBadRequestCounter() { + controllerExceptionHandler.handleIllegalArgumentException(); + + verify(counterManager).increment(CounterName.BAD_REQUEST); + } + + @Test + void shouldIncrementUnknownErrorCounter() { + controllerExceptionHandler.handleException(mock(Exception.class)); + + verify(counterManager).increment(CounterName.UNKNOWN_ERROR); + } +} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/metrics/CounterManagerTest.java b/src/test/java/com/philldenness/cakemanager/metrics/CounterManagerTest.java new file mode 100644 index 00000000..f3c940dd --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/metrics/CounterManagerTest.java @@ -0,0 +1,69 @@ +package com.philldenness.cakemanager.metrics; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.micrometer.core.instrument.Counter; +import org.junit.jupiter.api.Test; + +class CounterManagerTest { + + private final Counter findAllCounter = mock(Counter.class); + private final Counter findByIdCounter = mock(Counter.class); + private final Counter saveCounter = mock(Counter.class); + private final Counter updateCounter = mock(Counter.class); + private final Counter deleteCounter = mock(Counter.class); + private final Counter unknownErrorCounter = mock(Counter.class); + private final Counter badRequestCounter = mock(Counter.class); + + private final CounterManager counterManager = new CounterManager(findAllCounter, findByIdCounter, saveCounter, updateCounter, deleteCounter, unknownErrorCounter, badRequestCounter); + + @Test + void shouldCallCounterForFindAll() { + counterManager.increment(CounterName.FIND_ALL_COUNTER); + + verify(findAllCounter).increment(); + } + + @Test + void shouldCallCounterForFindById() { + counterManager.increment(CounterName.FIND_BY_ID_COUNTER); + + verify(findByIdCounter).increment(); + } + + @Test + void shouldCallCounterForSave() { + counterManager.increment(CounterName.SAVE); + + verify(saveCounter).increment(); + } + + @Test + void shouldCallCounterForUpdate() { + counterManager.increment(CounterName.UPDATE); + + verify(updateCounter).increment(); + } + + @Test + void shouldCallCounterForDelete() { + counterManager.increment(CounterName.DELETE); + + verify(deleteCounter).increment(); + } + + @Test + void shouldCallCounterForInternalServerError() { + counterManager.increment(CounterName.UNKNOWN_ERROR); + + verify(unknownErrorCounter).increment(); + } + + @Test + void shouldCallCounterForBadRequestError() { + counterManager.increment(CounterName.BAD_REQUEST); + + verify(badRequestCounter).increment(); + } +} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/metrics/CounterNameTest.java b/src/test/java/com/philldenness/cakemanager/metrics/CounterNameTest.java new file mode 100644 index 00000000..e13b364c --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/metrics/CounterNameTest.java @@ -0,0 +1,19 @@ +package com.philldenness.cakemanager.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class CounterNameTest { + + @Test + void shouldHaveCounterNames() { + assertEquals("find_all_counter", CounterName.FIND_ALL_COUNTER.toString()); + assertEquals("find_by_id_counter", CounterName.FIND_BY_ID_COUNTER.toString()); + assertEquals("save_counter", CounterName.SAVE.toString()); + assertEquals("update_counter", CounterName.UPDATE.toString()); + assertEquals("delete_counter", CounterName.DELETE.toString()); + assertEquals("unknown_error_counter", CounterName.UNKNOWN_ERROR.toString()); + assertEquals("bad_request_counter", CounterName.BAD_REQUEST.toString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java index 1ebe5e8c..3def9715 100644 --- a/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -17,6 +17,8 @@ import com.philldenness.cakemanager.dto.CakeRequest; import com.philldenness.cakemanager.entity.CakeEntity; import com.philldenness.cakemanager.mapper.CakeMapper; +import com.philldenness.cakemanager.metrics.CounterManager; +import com.philldenness.cakemanager.metrics.CounterName; import com.philldenness.cakemanager.repository.CakeRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,6 +39,9 @@ class CakeServiceTest { @Mock private CakeMapper cakeMapper; + @Mock + private CounterManager counterManager; + // region all cakes @Test void shouldMapDtoForEachWithEntity() { @@ -46,7 +51,9 @@ void shouldMapDtoForEachWithEntity() { CakeEntity cakeEntity2 = mock(CakeEntity.class); when(cakeRepository.findAll()).thenReturn(List.of(cakeEntity1, cakeEntity2)); - when(cakeMapper.toDTO(any(CakeEntity.class))).thenReturn(cakeDTO1).thenReturn(cakeDTO2); + when(cakeMapper.toDTO(any(CakeEntity.class))) + .thenReturn(cakeDTO1) + .thenReturn(cakeDTO2); List cakes = cakeService.getCakes(); @@ -55,6 +62,14 @@ void shouldMapDtoForEachWithEntity() { assertEquals(List.of(cakeDTO1, cakeDTO2), cakes); } + @Test + void shouldIncrementFindAllCounter() { + when(cakeRepository.findAll()).thenReturn(List.of()); + cakeService.getCakes(); + + verify(counterManager, times(1)).increment(CounterName.FIND_ALL_COUNTER); + } + @Test void shouldPropagateExceptionFromFindAll() { when(cakeRepository.findAll()).thenThrow(mock(InvalidDataAccessResourceUsageException.class)); @@ -89,6 +104,18 @@ void shouldPassEntityToMapper() { assertEquals(expectedCake, cakeDTO); } + @Test + void shouldIncrementFindByIdCounter() { + CakeDTO expectedCake = mock(CakeDTO.class); + CakeEntity cakeEntity = mock(CakeEntity.class); + when(cakeRepository.findById(anyLong())).thenReturn(Optional.of(cakeEntity)); + when(cakeMapper.toDTO(any(CakeEntity.class))).thenReturn(expectedCake); + + cakeService.getCakeById(1L); + + verify(counterManager, times(1)).increment(CounterName.FIND_BY_ID_COUNTER); + } + @Test void shouldThrowIllegalArgumentExceptionWhenRepoOptionalIsEmpty() { when(cakeRepository.findById(anyLong())).thenReturn(Optional.empty()); @@ -122,6 +149,22 @@ void shouldPassCreateRequestToMapper() { assertEquals(fromEntity, savedCake); } + @Test + void shouldIncrementSaveCounter() { + CakeEntity cakeEntity = mock(CakeEntity.class); + CakeEntity savedEntity = mock(CakeEntity.class); + CakeRequest toSave = mock(CakeRequest.class); + CakeDTO fromEntity = mock(CakeDTO.class); + + when(cakeMapper.toEntity(toSave)).thenReturn(cakeEntity); + when(cakeRepository.save(cakeEntity)).thenReturn(savedEntity); + when(cakeMapper.toDTO(savedEntity)).thenReturn(fromEntity); + + cakeService.create(toSave); + + verify(counterManager, times(1)).increment(CounterName.SAVE); + } + @Test void shouldPropagateExceptionFromSave() { CakeRequest toSave = mock(CakeRequest.class); @@ -136,7 +179,7 @@ void shouldPropagateExceptionFromSave() { // region update cake @Test - void shouldPassUpdateRequestToMapper() { + void shouldIncrementUpdateCounter() { Long id = 1L; CakeEntity newEntity = mock(CakeEntity.class); CakeEntity oldEntity = mock(CakeEntity.class); @@ -155,6 +198,25 @@ void shouldPassUpdateRequestToMapper() { assertEquals(fromEntity, savedCake); } + @Test + void shouldPerformUpdate() { + Long id = 1L; + CakeEntity newEntity = mock(CakeEntity.class); + CakeEntity oldEntity = mock(CakeEntity.class); + CakeEntity newSavedEntity = mock(CakeEntity.class); + CakeRequest toSave = mock(CakeRequest.class); + CakeDTO fromEntity = mock(CakeDTO.class); + + when(cakeMapper.toEntity(toSave)).thenReturn(newEntity); + when(cakeRepository.findById(id)).thenReturn(Optional.of(oldEntity)); + when(cakeRepository.save(newEntity)).thenReturn(newSavedEntity); + when(cakeMapper.toDTO(newSavedEntity)).thenReturn(fromEntity); + + cakeService.update(id, toSave); + + verify(counterManager, times(1)).increment(CounterName.UPDATE); + } + @Test void shouldThrowIllegalArgumentExceptionWhenUpdateIdIsNotFound() { when(cakeRepository.findById(anyLong())).thenReturn(Optional.empty()); @@ -194,6 +256,15 @@ void shouldCallRepoDeleteByIdWithSuppliedId() { verify(cakeRepository).deleteById(id); } + @Test + void shouldIncrementDeleteCounter() { + when(cakeRepository.existsById(anyLong())).thenReturn(true); + + cakeService.delete(1L); + + verify(counterManager).increment(CounterName.DELETE); + } + @Test void shouldThrowIllegalArgumentExceptionWhenDeleteIdIsNotFound() { when(cakeRepository.existsById(anyLong())).thenReturn(false); From 827d4d91aea8924469dc8bb8e02da72fb245dbd9 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Sun, 27 Aug 2023 15:01:03 +0100 Subject: [PATCH 14/16] Postman export and update readme --- README.md | 12 ++- postman.cakemanager.json | 186 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 postman.cakemanager.json diff --git a/README.md b/README.md index 18c53243..2873ff88 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,15 @@ Bonus points: --- Notes from Phill ========== -

+## Contents +

CICDStarting appDocumentationNext steps

+## Methodology I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ @@ -72,8 +74,11 @@ mvn spring-boot:run ``` --- ## Documentation -Swagger documentation + +### Swagger documentation - http://localhost:8080/swagger-ui/index.html +### Postman export + - postman.cakemanager.json ## Monitoring * Logback with Logstash encoder to support searchable logs @@ -83,4 +88,5 @@ Swagger documentation ## Next steps * Add pagination and sorting to getAll endpoint -* Extend CICD to deploy to cloud \ No newline at end of file +* Extend CICD to deploy to cloud +* Authentication and Authorisation \ No newline at end of file diff --git a/postman.cakemanager.json b/postman.cakemanager.json new file mode 100644 index 00000000..f3999e5f --- /dev/null +++ b/postman.cakemanager.json @@ -0,0 +1,186 @@ +{ + "info": { + "_postman_id": "535213fe-169b-446b-a22a-9f8285fca823", + "name": "Waracle", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "5178960" + }, + "item": [ + { + "name": "Get all cakes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/cakes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cakes" + ] + } + }, + "response": [] + }, + { + "name": "Create cake", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"a title\",\n \"description\": \"a description\",\n \"image\": \"a image\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/cakes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cakes" + ] + } + }, + "response": [] + }, + { + "name": "Update cake", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"a title\",\n \"description\": \"a description\",\n \"image\": \"a image\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/cakes/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cakes", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Delete cake", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:8080/cakes/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cakes", + "2" + ] + } + }, + "response": [] + }, + { + "name": "Get cake by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/cakes/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cakes", + "2" + ] + } + }, + "response": [] + }, + { + "name": "health", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/actuator/health", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "health" + ] + } + }, + "response": [] + }, + { + "name": "info", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/actuator/info", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "info" + ] + } + }, + "response": [] + }, + { + "name": "prometheus", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/actuator/prometheus", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "prometheus" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file From 34a7bb6489313adf94d7f092abc152bf70ffa34b Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Mon, 28 Aug 2023 14:18:50 +0100 Subject: [PATCH 15/16] Add basic auth to endpoints --- README.md | 16 +- pom.xml | 9 + postman.cakemanager.json | 125 +++++++++++++- prometheus.yml | 5 +- .../controller/CakeController.java | 2 +- .../cakemanager/dto/CakeRequest.java | 8 +- .../security/BasicConfiguration.java | 49 ++++++ src/main/resources/application.properties | 3 +- src/main/resources/logback-spring.xml | 24 ++- .../integrationTest/CreateCakeIT.java | 39 ++++- .../integrationTest/DeleteCakeIT.java | 6 +- .../integrationTest/GetCakeIT.java | 18 +- .../integrationTest/SecurityIT.java | 156 ++++++++++++++++++ .../integrationTest/UpdateCakeIT.java | 39 ++++- 14 files changed, 455 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/philldenness/cakemanager/security/BasicConfiguration.java create mode 100644 src/test/java/com/philldenness/cakemanager/integrationTest/SecurityIT.java diff --git a/README.md b/README.md index 2873ff88..f18b9029 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,16 @@ Notes from Phill CICDStarting appDocumentation • + AuthenticationNext steps

## Methodology I used TDD and 'The Double Loop' cycle which is described brilliantly here: https://jmauerhan.wordpress.com/talks/double-loop-tdd-bdd-done-right/ - ## CICD * Github actions workflow is setup to automatically test and build the docker container. Then it is pushed to dockerhub which can be viewed here: https://hub.docker.com/r/phillipdenness1/cakemanager -* Flyway migration scripts run automatically on startup. +* Flyway migration scripts run automatically on startup and populates the H2 DB with test data. --- ## Starting app @@ -77,16 +77,22 @@ mvn spring-boot:run ### Swagger documentation - http://localhost:8080/swagger-ui/index.html + - Endpoints are validated jakarta.validation annotations + ### Postman export - postman.cakemanager.json ## Monitoring * Logback with Logstash encoder to support searchable logs * Prometheus is available when running via docker-compose. - - Prometheus UI: http://localhost:9090/targets?search= + - Prometheus UI: http://localhost:9090/graph? - find_all_counter_total, find_by_id_counter_total, save_counter_total, update_counter_total, delete_counter_total, unknown_error_counter_total, bad_request_counter_total +## Authentication +* Basic authentication is applied to secure endpoints + - Add header: `Authorization: Basic dXNlcjp1c2VyUGFzcw==` + - Username=user , password=userPass + ## Next steps * Add pagination and sorting to getAll endpoint -* Extend CICD to deploy to cloud -* Authentication and Authorisation \ No newline at end of file +* Extend CICD to deploy to cloud \ No newline at end of file diff --git a/pom.xml b/pom.xml index d6b00a70..c06b8a27 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-security + io.micrometer micrometer-registry-prometheus @@ -80,6 +84,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + diff --git a/postman.cakemanager.json b/postman.cakemanager.json index f3999e5f..64154236 100644 --- a/postman.cakemanager.json +++ b/postman.cakemanager.json @@ -9,16 +9,32 @@ { "name": "Get all cakes", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "user", + "type": "string" + }, + { + "key": "password", + "value": "userPass", + "type": "string" + } + ] + }, "method": "GET", "header": [], "url": { - "raw": "http://localhost:8080/cakes", + "raw": "http://localhost:8080/api/cakes", "protocol": "http", "host": [ "localhost" ], "port": "8080", "path": [ + "api", "cakes" ] } @@ -28,6 +44,21 @@ { "name": "Create cake", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, "method": "POST", "header": [], "body": { @@ -40,13 +71,14 @@ } }, "url": { - "raw": "http://localhost:8080/cakes", + "raw": "http://localhost:8080/api/cakes", "protocol": "http", "host": [ "localhost" ], "port": "8080", "path": [ + "api", "cakes" ] } @@ -56,6 +88,21 @@ { "name": "Update cake", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, "method": "PUT", "header": [], "body": { @@ -68,13 +115,14 @@ } }, "url": { - "raw": "http://localhost:8080/cakes/2", + "raw": "http://localhost:8080/api/cakes/2", "protocol": "http", "host": [ "localhost" ], "port": "8080", "path": [ + "api", "cakes", "2" ] @@ -85,16 +133,32 @@ { "name": "Delete cake", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, "method": "DELETE", "header": [], "url": { - "raw": "http://localhost:8080/cakes/2", + "raw": "http://localhost:8080/api/cakes/2", "protocol": "http", "host": [ "localhost" ], "port": "8080", "path": [ + "api", "cakes", "2" ] @@ -105,18 +169,34 @@ { "name": "Get cake by ID", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "user", + "type": "string" + }, + { + "key": "password", + "value": "userPass", + "type": "string" + } + ] + }, "method": "GET", "header": [], "url": { - "raw": "http://localhost:8080/cakes/2", + "raw": "http://localhost:8080/api/cakes/3", "protocol": "http", "host": [ "localhost" ], "port": "8080", "path": [ + "api", "cakes", - "2" + "3" ] } }, @@ -125,6 +205,9 @@ { "name": "health", "request": { + "auth": { + "type": "noauth" + }, "method": "GET", "header": [], "url": { @@ -145,6 +228,21 @@ { "name": "info", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, "method": "GET", "header": [], "url": { @@ -165,6 +263,21 @@ { "name": "prometheus", "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "user", + "type": "string" + }, + { + "key": "password", + "value": "userPass", + "type": "string" + } + ] + }, "method": "GET", "header": [], "url": { diff --git a/prometheus.yml b/prometheus.yml index e40efc0f..55474849 100644 --- a/prometheus.yml +++ b/prometheus.yml @@ -12,4 +12,7 @@ scrape_configs: scrape_interval: 5s static_configs: - targets: - - cakemanagerpd:8080 \ No newline at end of file + - cakemanagerpd:8080 + basic_auth: + username: 'user' + password: 'userPass' \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java index d421792b..170e60e3 100644 --- a/src/main/java/com/philldenness/cakemanager/controller/CakeController.java +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/cakes") +@RequestMapping("/api/cakes") @RequiredArgsConstructor public class CakeController { diff --git a/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java b/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java index 8a976203..a8eb2a8a 100644 --- a/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java +++ b/src/main/java/com/philldenness/cakemanager/dto/CakeRequest.java @@ -1,10 +1,10 @@ package com.philldenness.cakemanager.dto; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; public record CakeRequest( - @NotNull String title, - @NotNull String description, - @NotNull String image + @NotBlank String title, + @NotBlank String description, + @NotBlank String image ) { } \ No newline at end of file diff --git a/src/main/java/com/philldenness/cakemanager/security/BasicConfiguration.java b/src/main/java/com/philldenness/cakemanager/security/BasicConfiguration.java new file mode 100644 index 00000000..c4b8cd5d --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/security/BasicConfiguration.java @@ -0,0 +1,49 @@ +package com.philldenness.cakemanager.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class BasicConfiguration { + + private static final String[] AUTH_WHITELIST = { + "/v3/api-docs/**", + "/swagger-ui/**", + "/actuator/health", + }; + + @Bean + public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) { + UserDetails user = User.withUsername("user") + .password(passwordEncoder.encode("userPass")) + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf().disable(). + authorizeRequests() + .requestMatchers(AUTH_WHITELIST) + .permitAll() + .anyRequest() + .authenticated() + .and() + .httpBasic(); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 03083664..92b9b996 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,4 +5,5 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=none management.endpoints.web.exposure.include=health,info,prometheus -management.metrics.tags.application=${spring.application.name} \ No newline at end of file +management.metrics.tags.application=${spring.application.name} +spring.profiles.active=workspace diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index e67af387..a2090786 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,8 +1,20 @@ - - - - - - + + + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + \ No newline at end of file diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java index b9adb449..de61b50a 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java @@ -10,19 +10,21 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @SpringBootTest @AutoConfigureMockMvc +@WithMockUser public class CreateCakeIT { @Autowired private MockMvc mvc; @Test - void testValidPostCreatesCake() throws Exception { - mvc.perform(MockMvcRequestBuilders.post("/cakes") + void testCreateCake_returnsCake() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) @@ -32,28 +34,55 @@ void testValidPostCreatesCake() throws Exception { @Test void testInvalidPostCreatesCakeWithNullTitle() throws Exception { - mvc.perform(MockMvcRequestBuilders.post("/cakes") + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") .content(asJsonString(new CakeRequest(null, "new description", "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + @Test + void testInvalidPostCreatesCakeWithBlankTitle() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") + .content(asJsonString(new CakeRequest("", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + @Test void testInvalidPostCreatesCakeWithNullDescription() throws Exception { - mvc.perform(MockMvcRequestBuilders.post("/cakes") + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") .content(asJsonString(new CakeRequest("new title", null, "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + @Test + void testInvalidPostCreatesCakeWithBlankDescription() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") + .content(asJsonString(new CakeRequest("new title", "", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + @Test void testInvalidPostCreatesCakeWithNullImage() throws Exception { - mvc.perform(MockMvcRequestBuilders.post("/cakes") + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") .content(asJsonString(new CakeRequest("new title", "new description", null))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + + @Test + void testInvalidPostCreatesCakeWithBlankImage() throws Exception { + mvc.perform(MockMvcRequestBuilders.post("/api/cakes") + .content(asJsonString(new CakeRequest("new title", "new description", ""))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } } diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java index f49b8472..37b4b43d 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java @@ -9,11 +9,13 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @SpringBootTest @AutoConfigureMockMvc +@WithMockUser public class DeleteCakeIT { @Autowired @@ -30,14 +32,14 @@ void testDeleteCake() throws Exception { cakeEntity.setDescription("to delete"); CakeEntity deleteEntity = cakeRepository.save(cakeEntity); - mvc.perform(MockMvcRequestBuilders.delete("/cakes/" + deleteEntity.getId()) + mvc.perform(MockMvcRequestBuilders.delete("/api/cakes/" + deleteEntity.getId()) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNoContent()); } @Test void testDeleteNonExistentCake() throws Exception { - mvc.perform(MockMvcRequestBuilders.delete("/cakes/99") + mvc.perform(MockMvcRequestBuilders.delete("/api/cakes/99") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java index abee6ca9..d87ebdbc 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -8,12 +8,14 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @SpringBootTest @AutoConfigureMockMvc +@WithMockUser public class GetCakeIT { @Autowired @@ -21,8 +23,8 @@ public class GetCakeIT { // region get all @Test - void testGetCakesReturnsAllCakes() throws Exception { - mvc.perform(MockMvcRequestBuilders.get("/cakes") + void testGetCakes_returnsAllCakes() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(20)); @@ -31,23 +33,23 @@ void testGetCakesReturnsAllCakes() throws Exception { // region get by id @Test - void testGetCakeById() throws Exception { - mvc.perform(MockMvcRequestBuilders.get("/cakes/1") + void testGetCakeById_returnsCake() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes/1") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().json("{'title': 'Lemon cheesecake', 'description': 'A cheesecake made of lemon', 'image': 'https://s3-eu-west-1.amazonaws.com/s3.mediafileserver.co.uk/carnation/WebFiles/RecipeImages/lemoncheesecake_lg.jpg'}")); } @Test - void testGetCakeByIdReturns404WhenIdDoesntExist() throws Exception { - mvc.perform(MockMvcRequestBuilders.get("/cakes/99") + void testGetCakeById_returns404WhenIdDoesntExist() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes/99") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); } @Test - void testGetCakeByIdReturns400WhenIdIsNotALong() throws Exception { - mvc.perform(MockMvcRequestBuilders.get("/cakes/abc") + void testGetCakeById_returns400WhenIdIsNotALong() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes/abc") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/SecurityIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/SecurityIT.java new file mode 100644 index 00000000..c1f67591 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/SecurityIT.java @@ -0,0 +1,156 @@ +package com.philldenness.cakemanager.integrationTest; + +import static com.philldenness.cakemanager.testUtils.TestUtils.asJsonString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.philldenness.cakemanager.dto.CakeRequest; +import com.philldenness.cakemanager.entity.CakeEntity; +import com.philldenness.cakemanager.repository.CakeRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +public class SecurityIT { + + @Autowired + private MockMvc mvc; + + @Autowired + private CakeRepository cakeRepository; + + @Test + void testHealthWithUser_isOk() throws Exception { + mvc.perform(get("/actuator/health") + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } + + @Test + void testInfoWithUser_isOk() throws Exception { + mvc.perform(get("/actuator/info") + .with(httpBasic("user", "userPass")) + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } + + @Test + void testInfoWithUser_isUnauthorised() throws Exception { + mvc.perform(get("/actuator/info") + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isUnauthorized()); + } + + @Test + void testCreateCakeWithUser_returnsCreated() throws Exception { + mvc.perform(post("/api/cakes") + .with(httpBasic("user", "userPass")) + .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()); + } + + @Test + void testCreateCakeWithoutUser_returnsUnauthorised() throws Exception { + mvc.perform(post("/api/cakes") + .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + void testDeleteCakeWithUser_returnsNoContent() throws Exception { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setImage("to delete"); + cakeEntity.setTitle("to delete"); + cakeEntity.setDescription("to delete"); + CakeEntity deleteEntity = cakeRepository.save(cakeEntity); + + mvc.perform(delete("/api/cakes/" + deleteEntity.getId()) + .with(httpBasic("user", "userPass")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void testDeleteCakeWithoutUser_returnsUnauthorised() throws Exception { + CakeEntity cakeEntity = new CakeEntity(); + cakeEntity.setImage("to delete"); + cakeEntity.setTitle("to delete"); + cakeEntity.setDescription("to delete"); + CakeEntity deleteEntity = cakeRepository.save(cakeEntity); + + mvc.perform(delete("/api/cakes/" + deleteEntity.getId()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + void testGetCakeByIdWithUser_returnsOk() throws Exception { + mvc.perform(get("/api/cakes/1") + .with(httpBasic("user", "userPass")) + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } + + @Test + void testGetCakeByIdWithoutUser_returnsUnauthorised() throws Exception { + mvc.perform(get("/api/cakes/1") + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isUnauthorized()); + } + + @Test + void testGetCakesWithUser_returnsOk() throws Exception { + mvc.perform(get("/api/cakes") + .with(httpBasic("user", "userPass")) + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } + + @Test + void testGetCakesWithoutUser_returnsUnauthorised() throws Exception { + mvc.perform(get("/api/cakes") + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isUnauthorized()); + } + + @Test + void testUpdateCakeWithUser_returnsOk() throws Exception { + CakeEntity existingEntity = new CakeEntity(); + existingEntity.setTitle("old title"); + existingEntity.setDescription("old desc"); + existingEntity.setImage("old image"); + existingEntity = cakeRepository.save(existingEntity); + + CakeRequest request = new CakeRequest("new title", "new description", "new image"); + + mvc.perform(put("/api/cakes/" + existingEntity.getId()) + .with(httpBasic("user", "userPass")) + .content(asJsonString(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void testUpdateCakeWithoutUser_returnsUnauthorised() throws Exception { + CakeEntity existingEntity = new CakeEntity(); + existingEntity.setTitle("old title"); + existingEntity.setDescription("old desc"); + existingEntity.setImage("old image"); + existingEntity = cakeRepository.save(existingEntity); + + CakeRequest request = new CakeRequest("new title", "new description", "new image"); + + mvc.perform(put("/api/cakes/" + existingEntity.getId()) + .content(asJsonString(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java index ae3228f0..3336333d 100644 --- a/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java @@ -13,11 +13,13 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @SpringBootTest @AutoConfigureMockMvc +@WithMockUser public class UpdateCakeIT { @Autowired @@ -36,7 +38,7 @@ void testValidUpdateCake() throws Exception { CakeRequest request = new CakeRequest("new title", "new description", "new image"); - mvc.perform(MockMvcRequestBuilders.put("/cakes/" + existingEntity.getId()) + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/" + existingEntity.getId()) .content(asJsonString(request)) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) @@ -51,7 +53,7 @@ void testValidUpdateCake() throws Exception { @Test void testUpdateCakeReturns404WhenIdDoesntExist() throws Exception { - mvc.perform(MockMvcRequestBuilders.put("/cakes/99") + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/99") .content(asJsonString(new CakeRequest("new title", "new description", "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) @@ -60,28 +62,55 @@ void testUpdateCakeReturns404WhenIdDoesntExist() throws Exception { @Test void testInvalidPostCreatesCakeWithNullTitle() throws Exception { - mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") .content(asJsonString(new CakeRequest(null, "new description", "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + @Test + void testInvalidPostCreatesCakeWithBlankTitle() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") + .content(asJsonString(new CakeRequest("", "new description", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + @Test void testInvalidPostCreatesCakeWithNullDescription() throws Exception { - mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") .content(asJsonString(new CakeRequest("new title", null, "new image"))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + @Test + void testInvalidPostCreatesCakeWithBlankDescription() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") + .content(asJsonString(new CakeRequest("new title", "", "new image"))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + @Test void testInvalidPostCreatesCakeWithNullImage() throws Exception { - mvc.perform(MockMvcRequestBuilders.put("/cakes/1") + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") .content(asJsonString(new CakeRequest("new title", "new description", null))) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()); } + + @Test + void testInvalidPostCreatesCakeWithBlankImage() throws Exception { + mvc.perform(MockMvcRequestBuilders.put("/api/cakes/1") + .content(asJsonString(new CakeRequest("new title", "new description", ""))) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } } From 0e5f19468df37039a74f01f9b95a6880298bcd17 Mon Sep 17 00:00:00 2001 From: phillipDenness Date: Mon, 28 Aug 2023 14:31:11 +0100 Subject: [PATCH 16/16] Exclude vulnerability --- pom.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pom.xml b/pom.xml index c06b8a27..cb8de50b 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,10 @@ org.springframework.boot spring-boot-starter-logging + + org.yaml + snakeyaml + @@ -46,6 +50,7 @@ com.h2database h2 + 2.2.220 runtime @@ -57,6 +62,12 @@ org.springdoc springdoc-openapi-starter-webmvc-ui ${springdoc-openapi.version} + + + org.yaml + snakeyaml + + org.springframework.boot