diff --git a/pom.xml b/pom.xml index d23aeb7..7cb12b6 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ demo Demo project for Spring Boot - 15 + 11 springboot-react-fullstack @@ -27,6 +27,7 @@ org.projectlombok lombok + 1.18.20 true @@ -35,6 +36,12 @@ test + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-data-jpa @@ -50,10 +57,27 @@ org.springframework.boot spring-boot-starter-validation + + + com.github.javafaker + javafaker + 1.0.2 + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0-M5 + maven-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + org.springframework.boot spring-boot-maven-plugin @@ -72,7 +96,7 @@ 2.5.2 - openjdk:15 + openjdk:11 @@ -98,7 +122,7 @@ 2.5.2 - openjdk:15 + openjdk:11 @@ -150,7 +174,7 @@ 2.5.2 - openjdk:15 + openjdk:11 diff --git a/src/frontend/src/App.js b/src/frontend/src/App.js index b3477d1..69ba45e 100644 --- a/src/frontend/src/App.js +++ b/src/frontend/src/App.js @@ -11,7 +11,7 @@ import { Badge, Tag, Avatar, - Radio, Popconfirm, Image, Divider + Radio, Popconfirm } from 'antd'; import { @@ -24,6 +24,7 @@ import { PlusOutlined } from '@ant-design/icons'; import StudentDrawerForm from "./StudentDrawerForm"; +import StudentDrawerEditForm from "./StudentDrawerEditForm"; import './App.css'; import {errorNotification, successNotification} from "./Notification"; @@ -61,58 +62,14 @@ const removeStudent = (studentId, callback) => { }) } -const columns = fetchStudents => [ - { - title: '', - dataIndex: 'avatar', - key: 'avatar', - render: (text, student) => - - }, - { - title: 'Id', - dataIndex: 'id', - key: 'id', - }, - { - title: 'Name', - dataIndex: 'name', - key: 'name', - }, - { - title: 'Email', - dataIndex: 'email', - key: 'email', - }, - { - title: 'Gender', - dataIndex: 'gender', - key: 'gender', - }, - { - title: 'Actions', - key: 'actions', - render: (text, student) => - - removeStudent(student.id, fetchStudents)} - okText='Yes' - cancelText='No'> - Delete - - alert("TODO: Implement edit student")} value="small">Edit - - } -]; - const antIcon = ; function App() { const [students, setStudents] = useState([]); + const [student, setStudent] = useState(""); const [collapsed, setCollapsed] = useState(false); const [fetching, setFetching] = useState(true); + const [showEditDrawer, setShowEditDrawer] = useState(false); const [showDrawer, setShowDrawer] = useState(false); const fetchStudents = () => @@ -121,7 +78,7 @@ function App() { .then(data => { console.log(data); setStudents(data); - }).catch(err => { + }).catch(err => { console.log(err.response) err.response.json().then(res => { console.log(res); @@ -130,12 +87,12 @@ function App() { `${res.message} [${res.status}] [${res.error}]` ) }); - }).finally(() => setFetching(false)) + }).finally(() => setFetching(false)); - useEffect(() => { - console.log("component is mounted"); - fetchStudents(); - }, []); + useEffect(() => { + console.log("component is mounted"); + fetchStudents(); + }, []); const renderStudents = () => { if (fetching) { @@ -148,6 +105,12 @@ function App() { type="primary" shape="round" icon={} size="small"> Add New Student + } return <> + } + const columns = fetchStudents => [ + { + title: '', + dataIndex: 'avatar', + key: 'avatar', + render: (text, student) => + + }, + { + title: 'Id', + dataIndex: 'id', + key: 'id', + }, + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Email', + dataIndex: 'email', + key: 'email', + }, + { + title: 'Gender', + dataIndex: 'gender', + key: 'gender', + }, + { + title: 'Actions', + key: 'actions', + render: (text, student) => + + removeStudent(student.id, fetchStudents)} + okText='Yes' + cancelText='No'> + Delete + + + setShowEditDrawer(!showEditDrawer) + + setStudent(student) + + console.log("Student ID: ", student.id)} + value="small">Edit + + + } + ]; + return @@ -221,20 +241,7 @@ function App() { {renderStudents()} - +
By Amigoscode
} diff --git a/src/frontend/src/StudentDrawerEditForm.js b/src/frontend/src/StudentDrawerEditForm.js new file mode 100644 index 0000000..c3719d8 --- /dev/null +++ b/src/frontend/src/StudentDrawerEditForm.js @@ -0,0 +1,135 @@ +import {Drawer, Input, Col, Select, Form, Row, Button, Spin} from 'antd'; +import {LoadingOutlined} from "@ant-design/icons"; +import {useState, useEffect} from 'react'; +import {editStudent} from "./client"; +import {errorNotification, successNotification} from "./Notification"; + +const {Option} = Select; + +const antIcon = ; + +function StudentDrawerEditForm({showDrawer, setShowDrawer, student, fetchStudents}) { + + const [form] = Form.useForm(); + + const loadUserData = () => { + form.setFieldsValue({ + id: student.id, + name: student.name, + email: student.email, + gender: student.gender + }) + }; + + useEffect(() => { + loadUserData(); + }, [loadUserData, student]); + + const onCLose = () => setShowDrawer(false); + const [submitting, setSubmitting] = useState(false); + const onFinish = student => { + setSubmitting(true) + console.log(JSON.stringify(student, null, 2)); + editStudent(student). + then(() => { + console.log("student updated") + onCLose(); + successNotification( + "Student successfully updated", + `${student.name} was updated` + ) + fetchStudents(); + }).catch(err => { + console.log(err); + err.response.json().then(res => { + console.log(res); + errorNotification( + "There was an issue", + `${res.message} [${res.status}] [${res.error}]`, + "bottomLeft" + ) + }); + }).finally(() => { + setSubmitting(false); + }) + }; + const onFinishFailed = errorInfo => { + alert(JSON.stringify(errorInfo, null, 2)); + }; + + return + + + } + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {submitting && } + + +
+
+} + +export default StudentDrawerEditForm; \ No newline at end of file diff --git a/src/frontend/src/client.js b/src/frontend/src/client.js index ad3b90a..55d8f13 100644 --- a/src/frontend/src/client.js +++ b/src/frontend/src/client.js @@ -28,3 +28,13 @@ export const deleteStudent = studentId => fetch(`api/v1/students/${studentId}`, { method: 'DELETE' }).then(checkStatus); + +export const editStudent = student => + fetch("api/v1/students", { + headers: { + 'Content-Type': 'application/json' + }, + method: 'PUT', + body: JSON.stringify(student) + } + ).then(checkStatus) \ No newline at end of file diff --git a/src/main/java/com/example/demo/student/StudentController.java b/src/main/java/com/example/demo/student/StudentController.java index 43e7983..30cee96 100644 --- a/src/main/java/com/example/demo/student/StudentController.java +++ b/src/main/java/com/example/demo/student/StudentController.java @@ -28,4 +28,7 @@ public void deleteStudent( @PathVariable("studentId") Long studentId) { studentService.deleteStudent(studentId); } + + @PutMapping + public void editStudent(@Valid @RequestBody Student student) { studentService.editStudent(student); } } diff --git a/src/main/java/com/example/demo/student/StudentRepository.java b/src/main/java/com/example/demo/student/StudentRepository.java index 8e0a294..99b9d8b 100644 --- a/src/main/java/com/example/demo/student/StudentRepository.java +++ b/src/main/java/com/example/demo/student/StudentRepository.java @@ -5,8 +5,7 @@ import org.springframework.stereotype.Repository; @Repository -public interface StudentRepository - extends JpaRepository { +public interface StudentRepository extends JpaRepository { @Query("" + "SELECT CASE WHEN COUNT(s) > 0 THEN " + "TRUE ELSE FALSE END " + diff --git a/src/main/java/com/example/demo/student/StudentService.java b/src/main/java/com/example/demo/student/StudentService.java index 282e376..0a2e5bf 100644 --- a/src/main/java/com/example/demo/student/StudentService.java +++ b/src/main/java/com/example/demo/student/StudentService.java @@ -3,11 +3,10 @@ import com.example.demo.student.exception.BadRequestException; import com.example.demo.student.exception.StudentNotFoundException; import lombok.AllArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.ResponseStatus; import java.util.List; +import java.util.Optional; @AllArgsConstructor @Service @@ -37,4 +36,8 @@ public void deleteStudent(Long studentId) { } studentRepository.deleteById(studentId); } + + public void editStudent(Student student) { + studentRepository.save(student); + } } diff --git a/src/test/java/com/example/demo/integration/StudentIT.java b/src/test/java/com/example/demo/integration/StudentIT.java new file mode 100644 index 0000000..65cd79b --- /dev/null +++ b/src/test/java/com/example/demo/integration/StudentIT.java @@ -0,0 +1,189 @@ +package com.example.demo.integration; + +import com.example.demo.student.Gender; +import com.example.demo.student.Student; +import com.example.demo.student.StudentRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.javafaker.Faker; +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.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.StringUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@TestPropertySource( + locations = "classpath:application-it.properties" +) +@AutoConfigureMockMvc +public class StudentIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private StudentRepository studentRepository; + + private final Faker faker = new Faker(); + + @Test + void canRegisterNewStudent() throws Exception { + // given + String name = String.format( + "%s %s", + faker.name().firstName(), + faker.name().lastName() + ); + + Student student = new Student( + name, + String.format("%s@amigoscode.edu", + StringUtils.trimAllWhitespace(name.trim().toLowerCase())), + Gender.FEMALE + ); + + // when + ResultActions resultActions = mockMvc + .perform(post("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student))); + // then + resultActions.andExpect(status().isOk()); + List students = studentRepository.findAll(); + assertThat(students) + .usingElementComparatorIgnoringFields("id") + .contains(student); + } + + @Test + void canUpdateExistingStudent() throws Exception { + // given + String name = String.format( + "%s %s", + faker.name().firstName(), + faker.name().lastName() + ); + + String email = String.format("%s@amigoscode.edu", + StringUtils.trimAllWhitespace(name.trim().toLowerCase())); + + Student student = new Student( + name, + email, + Gender.FEMALE + ); + + mockMvc.perform(post("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student))) + .andExpect(status().isOk()); + + MvcResult getStudentsResult = mockMvc.perform(get("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String contentAsString = getStudentsResult + .getResponse() + .getContentAsString(); + + List students = objectMapper.readValue( + contentAsString, + new TypeReference<>() { + } + ); + + long id = students.stream() + .filter(s -> s.getEmail().equals(student.getEmail())) + .map(Student::getId) + .findFirst() + .orElseThrow(() -> + new IllegalStateException( + "student with email: " + email + " not found")); + + // when + student.setId(id); + student.setEmail(String.format("%s@amigoscode.edu", + StringUtils.trimAllWhitespace(name.trim().toLowerCase()))); + + mockMvc.perform(put("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student))) + .andExpect(status().isOk()); + + // then + boolean exists = studentRepository.selectExistsEmail(student.getEmail()); + assertThat(exists).isTrue(); + } + + @Test + void canDeleteStudent() throws Exception { + // given + String name = String.format( + "%s %s", + faker.name().firstName(), + faker.name().lastName() + ); + + String email = String.format("%s@amigoscode.edu", + StringUtils.trimAllWhitespace(name.trim().toLowerCase())); + + Student student = new Student( + name, + email, + Gender.FEMALE + ); + + mockMvc.perform(post("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(student))) + .andExpect(status().isOk()); + + MvcResult getStudentsResult = mockMvc.perform(get("/api/v1/students") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String contentAsString = getStudentsResult + .getResponse() + .getContentAsString(); + + List students = objectMapper.readValue( + contentAsString, + new TypeReference<>() { + } + ); + + long id = students.stream() + .filter(s -> s.getEmail().equals(student.getEmail())) + .map(Student::getId) + .findFirst() + .orElseThrow(() -> + new IllegalStateException( + "student with email: " + email + " not found")); + + // when + ResultActions resultActions = mockMvc + .perform(delete("/api/v1/students/" + id)); + + // then + resultActions.andExpect(status().isOk()); + boolean exists = studentRepository.existsById(id); + assertThat(exists).isFalse(); + } +} diff --git a/src/test/java/com/example/demo/student/StudentRepositoryTest.java b/src/test/java/com/example/demo/student/StudentRepositoryTest.java new file mode 100644 index 0000000..2693607 --- /dev/null +++ b/src/test/java/com/example/demo/student/StudentRepositoryTest.java @@ -0,0 +1,50 @@ +package com.example.demo.student; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@DataJpaTest +class StudentRepositoryTest { + + @Autowired + private StudentRepository underTest; + + @AfterEach + void tearDown() { + underTest.deleteAll(); + } + + @Test + void itShouldCheckWhenStudentEmailExists() { + // given + String email = "jamila@gmail.com"; + Student student = new Student( + "Jamila", + email, + Gender.FEMALE + ); + underTest.save(student); + + // when + boolean expected = underTest.selectExistsEmail(email); + + // then + assertThat(expected).isTrue(); + } + + @Test + void itShouldCheckWhenStudentEmailDoesNotExists() { + // given + String email = "jamila@gmail.com"; + + // when + boolean expected = underTest.selectExistsEmail(email); + + // then + assertThat(expected).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/demo/student/StudentServiceTest.java b/src/test/java/com/example/demo/student/StudentServiceTest.java new file mode 100644 index 0000000..bd1d365 --- /dev/null +++ b/src/test/java/com/example/demo/student/StudentServiceTest.java @@ -0,0 +1,136 @@ +package com.example.demo.student; + +import com.example.demo.student.exception.BadRequestException; +import com.example.demo.student.exception.StudentNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class StudentServiceTest { + + @Mock private StudentRepository studentRepository; + private StudentService underTest; + + @BeforeEach + void setUp() { + underTest = new StudentService(studentRepository); + } + + @Test + void canGetAllStudents() { + // when + underTest.getAllStudents(); + // then + verify(studentRepository).findAll(); + } + + @Test + void canAddStudent() { + // given + Student student = new Student( + "Jamila", + "jamila@gmail.com", + Gender.FEMALE + ); + + // when + underTest.addStudent(student); + + // then + ArgumentCaptor studentArgumentCaptor = + ArgumentCaptor.forClass(Student.class); + + verify(studentRepository) + .save(studentArgumentCaptor.capture()); + + Student capturedStudent = studentArgumentCaptor.getValue(); + + assertThat(capturedStudent).isEqualTo(student); + } + + @Test + void canEditStudent() { + // given + Student student = new Student( + "Jamiroquai", + "jamiroquai@gmail.com", + Gender.MALE + ); + + // when + underTest.editStudent(student); + + // then + ArgumentCaptor studentArgumentCaptor = + ArgumentCaptor.forClass(Student.class); + + verify(studentRepository) + .save(studentArgumentCaptor.capture()); + + Student capturedStudent = studentArgumentCaptor.getValue(); + + assertThat(capturedStudent).isEqualTo(student); + } + + @Test + void willThrowWhenEmailIsTaken() { + // given + Student student = new Student( + "Jamila", + "jamila@gmail.com", + Gender.FEMALE + ); + + given(studentRepository.selectExistsEmail(anyString())) + .willReturn(true); + + // when + // then + assertThatThrownBy(() -> underTest.addStudent(student)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Email " + student.getEmail() + " taken"); + + verify(studentRepository, never()).save(any()); + + } + + @Test + void canDeleteStudent() { + // given + long id = 10; + given(studentRepository.existsById(id)) + .willReturn(true); + // when + underTest.deleteStudent(id); + + // then + verify(studentRepository).deleteById(id); + } + + @Test + void willThrowWhenDeleteStudentNotFound() { + // given + long id = 10; + given(studentRepository.existsById(id)) + .willReturn(false); + // when + // then + assertThatThrownBy(() -> underTest.deleteStudent(id)) + .isInstanceOf(StudentNotFoundException.class) + .hasMessageContaining("Student with id " + id + " does not exists"); + + verify(studentRepository, never()).deleteById(any()); + } +} \ No newline at end of file diff --git a/src/test/resources/application-it.properties b/src/test/resources/application-it.properties new file mode 100644 index 0000000..3241d4d --- /dev/null +++ b/src/test/resources/application-it.properties @@ -0,0 +1,11 @@ +server.error.include-message=always +server.error.include-binding-errors=always + +spring.datasource.url=jdbc:postgresql://localhost:5432/amigoscode +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.username=postgres +spring.datasource.password=password +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..81c8606 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,8 @@ +spring.datasource.url=jdbc:h2://mem:db;DB_CLOSE_DELAY=-1 +spring.datasource.username=sa +spring.datasource.password=sa +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file