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/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..2488bc60 --- /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 }}/cakemanager:latest 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/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.md b/README.md new file mode 100644 index 00000000..f18b9029 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +Cake Manager Micro Service (fictitious) +======================================= + +A summer intern started on this project but never managed to get it finished. +The developer assured us that some of the above is complete, but at the moment accessing the /cakes endpoint +returns a 404, so getting this working should be the first priority. + +Requirements: +* By accessing /cakes, it should be possible to list the cakes currently in the system. JSON would be an acceptable response format. + +* It must be possible to add a new cake. + +* It must be possible to update an existing cake. + +* It must be possible to delete an existing cake. + +Comments: +* We feel like the software stack used by the original developer is quite outdated, it would be good to migrate the entire application to something more modern. If you wish to update the repo in this manner, feel free! An explanation of the benefits of doing so (and any downsides) can be discussed at interview. + +* Any other changes to improve the repo are appreciated (implementation of design patterns, seperation of concerns, ensuring the API follows REST principles etc) + +Bonus points: +* Add some suitable tests (unit/integration...) +* Add some Authentication / Authorisation to the API +* Continuous Integration via any cloud CI system +* Containerisation + +--- +Notes from Phill +========== +## Contents +

+ CICD • + Starting app • + Documentation • + Authentication • + Next 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 and populates the H2 DB with test data. + +--- +## Starting app + +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 + - 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/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 \ No newline at end of file diff --git a/README.txt b/README.txt deleted file mode 100644 index a0901c92..00000000 --- a/README.txt +++ /dev/null @@ -1,57 +0,0 @@ -Cake Manager Micro Service (fictitious) -======================================= - -A summer intern started on this project but never managed to get it finished. -The developer assured us that some of the above is complete, but at the moment accessing the /cakes endpoint -returns a 404, so getting this working should be the first priority. - -Requirements: -* By accessing /cakes, it should be possible to list the cakes currently in the system. JSON would be an acceptable response format. - -* It must be possible to add a new cake. - -* It must be possible to update an existing cake. - -* It must be possible to delete an existing cake. - -Comments: -* We feel like the software stack used by the original developer is quite outdated, it would be good to migrate the entire application to something more modern. If you wish to update the repo in this manner, feel free! An explanation of the benefits of doing so (and any downsides) can be discussed at interview. - -* Any other changes to improve the repo are appreciated (implementation of design patterns, seperation of concerns, ensuring the API follows REST principles etc) - -Bonus points: -* Add some suitable tests (unit/integration...) -* Add some Authentication / Authorisation to the API -* 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! diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..be299380 --- /dev/null +++ b/compose.yml @@ -0,0 +1,13 @@ +services: + backend: + container_name: cakemanagerpd + build: . + ports: + - 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 c8cbf9d5..cb8de50b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,77 +1,132 @@ - 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 + 2.2.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-logging + + + org.yaml + snakeyaml + + + + + net.logstash.logback + logstash-logback-encoder + 7.4 + + + ch.qos.logback + logback-classic + 1.3.7 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + 2.2.220 + runtime + + + org.flywaydb + flyway-core + ${flywaydb-core.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi.version} + + + org.yaml + snakeyaml + + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + io.micrometer + micrometer-registry-prometheus + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + 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.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + diff --git a/postman.cakemanager.json b/postman.cakemanager.json new file mode 100644 index 00000000..64154236 --- /dev/null +++ b/postman.cakemanager.json @@ -0,0 +1,299 @@ +{ + "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": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "user", + "type": "string" + }, + { + "key": "password", + "value": "userPass", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/cakes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "cakes" + ] + } + }, + "response": [] + }, + { + "name": "Create cake", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, + "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/api/cakes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "cakes" + ] + } + }, + "response": [] + }, + { + "name": "Update cake", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, + "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/api/cakes/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "cakes", + "2" + ] + } + }, + "response": [] + }, + { + "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/api/cakes/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "cakes", + "2" + ] + } + }, + "response": [] + }, + { + "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/api/cakes/3", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "cakes", + "3" + ] + } + }, + "response": [] + }, + { + "name": "health", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/actuator/health", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "health" + ] + } + }, + "response": [] + }, + { + "name": "info", + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "userPass", + "type": "string" + }, + { + "key": "username", + "value": "user", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/actuator/info", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "info" + ] + } + }, + "response": [] + }, + { + "name": "prometheus", + "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/actuator/prometheus", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "actuator", + "prometheus" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 00000000..55474849 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,18 @@ +# 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 + basic_auth: + username: 'user' + password: 'userPass' \ No newline at end of file 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..170e60e3 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/controller/CakeController.java @@ -0,0 +1,92 @@ +package com.philldenness.cakemanager.controller; + +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.DeleteMapping; +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; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/cakes") +@RequiredArgsConstructor +public class CakeController { + + private final CakeService cakeService; + + @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); + } + + @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); + } + + @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/controller/ControllerExceptionHandler.java b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java new file mode 100644 index 00000000..ab392a6d --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/controller/ControllerExceptionHandler.java @@ -0,0 +1,32 @@ +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; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@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(); + } + + + @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/dto/CakeDTO.java b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java new file mode 100644 index 00000000..1cb51175 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/dto/CakeDTO.java @@ -0,0 +1,9 @@ +package com.philldenness.cakemanager.dto; + +public record CakeDTO( + Long id, + String title, + String description, + String image +) { +} \ No newline at end of file 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..a8eb2a8a --- /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.NotBlank; + +public record CakeRequest( + @NotBlank String title, + @NotBlank String description, + @NotBlank 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..39c787fc --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/entity/CakeEntity.java @@ -0,0 +1,22 @@ +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 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..592886c8 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/mapper/CakeMapper.java @@ -0,0 +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.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/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/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/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/java/com/philldenness/cakemanager/service/CakeService.java b/src/main/java/com/philldenness/cakemanager/service/CakeService.java new file mode 100644 index 00000000..f0acd706 --- /dev/null +++ b/src/main/java/com/philldenness/cakemanager/service/CakeService.java @@ -0,0 +1,70 @@ +package com.philldenness.cakemanager.service; + +import static net.logstash.logback.argument.StructuredArguments.keyValue; + +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.metrics.CounterManager; +import com.philldenness.cakemanager.metrics.CounterName; +import com.philldenness.cakemanager.repository.CakeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +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(); + } + + public CakeDTO getCakeById(Long id) { + CakeEntity cakeEntity = repository.findById(id).orElseThrow(() -> { + 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); + } + + @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)); + return new IllegalArgumentException(); + }); + CakeEntity newPartialEntity = mapper.toEntity(toSave); + newPartialEntity.setId(oldEntity.getId()); + + counterManager.increment(CounterName.UPDATE); + 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)); + throw new IllegalArgumentException(); + } + counterManager.increment(CounterName.DELETE); + repository.deleteById(id); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..92b9b996 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,9 @@ +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} +spring.profiles.active=workspace 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..7442f112 --- /dev/null +++ b/src/main/resources/db/migration/V1__Initial.sql @@ -0,0 +1,7 @@ +CREATE TABLE cake +( + id INT AUTO_INCREMENT PRIMARY KEY, + 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/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/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..a2090786 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + \ 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..53fcf536 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/controller/CakeControllerTest.java @@ -0,0 +1,124 @@ +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.any; +import static org.mockito.ArgumentMatchers.anyLong; +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; + +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; +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; + + // region allCakes + @Test + void shouldReturnAllCakesFromCakeService() { + List cakes = List.of(mock(CakeDTO.class)); + when(cakeService.getCakes()).thenReturn(cakes); + + List cakeList = cakeController.getAllCakes(); + + assertEquals(cakes, cakeList); + } + // endregion + + // region cake by id + @Test + void shouldCallServiceWithPathIdAndReturnCakeDto() { + long cakeId = 1L; + CakeDTO expectedCake = mock(CakeDTO.class); + when(cakeService.getCakeById(cakeId)).thenReturn(expectedCake); + + CakeDTO cakeDTO = cakeController.getCakeById(cakeId); + + assertEquals(expectedCake, cakeDTO); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenGetByIdThrowsIllegalArgumentException() { + when(cakeService.getCakeById(anyLong())).thenThrow(IllegalArgumentException.class); + + assertThrows(IllegalArgumentException.class, () -> cakeController.getCakeById(9L)); + } + + // endregion + + // region create cake + @Test + void shouldCallCreateWithCakePayload() { + CakeDTO expectedCake = mock(CakeDTO.class); + CakeRequest payloadCake = mock(CakeRequest.class); + when(cakeService.create(any(CakeRequest.class))).thenReturn(expectedCake); + + CakeDTO createdCake = cakeController.createCake(payloadCake); + + 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 = mock(CakeDTO.class); + CakeRequest payloadCake = mock(CakeRequest.class); + when(cakeService.update(anyLong(), any(CakeRequest.class))).thenReturn(expectedCake); + + CakeDTO updatedCake = cakeController.updateCake(id, payloadCake); + + 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/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/integrationTest/CreateCakeIT.java b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java new file mode 100644 index 00000000..de61b50a --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/CreateCakeIT.java @@ -0,0 +1,88 @@ +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.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 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)) + .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("/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("/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("/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 new file mode 100644 index 00000000..37b4b43d --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/DeleteCakeIT.java @@ -0,0 +1,46 @@ +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.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 + 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("/api/cakes/" + deleteEntity.getId()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void testDeleteNonExistentCake() throws Exception { + 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 new file mode 100644 index 00000000..d87ebdbc --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/GetCakeIT.java @@ -0,0 +1,57 @@ +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 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.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 + private MockMvc mvc; + + // region get all + @Test + void testGetCakes_returnsAllCakes() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.length()").value(20)); + } + // endregion + + // region get by id + @Test + 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 testGetCakeById_returns404WhenIdDoesntExist() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes/99") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void testGetCakeById_returns400WhenIdIsNotALong() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/cakes/abc") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + // endregion +} 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 new file mode 100644 index 00000000..3336333d --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/integrationTest/UpdateCakeIT.java @@ -0,0 +1,116 @@ +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; +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 + private MockMvc mvc; + + @Autowired + private CakeRepository cakeRepository; + + @Test + void testValidUpdateCake() 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(MockMvcRequestBuilders.put("/api/cakes/" + existingEntity.getId()) + .content(asJsonString(request)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .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 + void testUpdateCakeReturns404WhenIdDoesntExist() throws Exception { + 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)) + .andExpect(status().isNotFound()); + } + + @Test + void testInvalidPostCreatesCakeWithNullTitle() throws Exception { + 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("/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("/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()); + } +} 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..e199ce5c --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/mapper/CakeMapperTest.java @@ -0,0 +1,47 @@ +package com.philldenness.cakemanager.mapper; + +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; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CakeMapperTest { + + @InjectMocks + private CakeMapper cakeMapper; + + @Test + 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/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 new file mode 100644 index 00000000..3def9715 --- /dev/null +++ b/src/test/java/com/philldenness/cakemanager/service/CakeServiceTest.java @@ -0,0 +1,296 @@ +package com.philldenness.cakemanager.service; + +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.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; + +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.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; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.InvalidDataAccessResourceUsageException; + +@ExtendWith(MockitoExtension.class) +class CakeServiceTest { + + @InjectMocks + private CakeService cakeService; + + @Mock + private CakeRepository cakeRepository; + + @Mock + private CakeMapper cakeMapper; + + @Mock + private CounterManager counterManager; + + // region all cakes + @Test + void shouldMapDtoForEachWithEntity() { + 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); + } + + @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)); + + assertThrows(InvalidDataAccessResourceUsageException.class, () -> cakeService.getCakes()); + } + + // 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 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()); + + 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 + @Test + void shouldPassCreateRequestToMapper() { + 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); + } + + @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); + 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 + @Test + void shouldIncrementUpdateCounter() { + 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 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()); + + 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 + @Test + void shouldCallRepoDeleteByIdWithSuppliedId() { + Long id = 1L; + when(cakeRepository.existsById(anyLong())).thenReturn(true); + + cakeService.delete(id); + + 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); + + 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 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); + } + } +}