diff options
| author | Szymon Szukalski <szymon@skas.io> | 2024-07-25 20:36:11 +1000 |
|---|---|---|
| committer | Szymon Szukalski <szymon@skas.io> | 2024-07-25 20:36:11 +1000 |
| commit | aa9bdd514ab90d0da0391b879255a22c29450e9a (patch) | |
| tree | 9ddd1de0ab7e376ead06f55bdb32a6190d3647d5 | |
| parent | ec81d98e90f9fdb4dd12138a365fbbbb3a8efa5f (diff) | |
Validate import payload and return create/update stats
- Add validation to /import payload
- Move import logic to service bean
- Track whether entities have been created or update
- Report number of created and updated entities as return value for the
import endpoint
- Add some test coverage to exercise the validators
15 files changed, 872 insertions, 337 deletions
@@ -52,6 +52,10 @@ <scope>test</scope> </dependency> <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> + <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> </dependency> diff --git a/src/main/java/com/stileeducation/markr/controller/TestResultsController.java b/src/main/java/com/stileeducation/markr/controller/TestResultsController.java index 1376ecb..f5b6070 100644 --- a/src/main/java/com/stileeducation/markr/controller/TestResultsController.java +++ b/src/main/java/com/stileeducation/markr/controller/TestResultsController.java @@ -1,11 +1,8 @@ package com.stileeducation.markr.controller; -import com.stileeducation.markr.dto.AggregatedTestResultsDTO; -import com.stileeducation.markr.dto.MCQTestResultDTO; +import com.stileeducation.markr.dto.AggregateResponseDTO; +import com.stileeducation.markr.dto.ImportResponseDTO; import com.stileeducation.markr.dto.MCQTestResultsDTO; -import com.stileeducation.markr.entity.Student; -import com.stileeducation.markr.entity.Test; -import com.stileeducation.markr.entity.TestResult; import com.stileeducation.markr.repository.TestRepository; import com.stileeducation.markr.repository.TestResultRepository; import com.stileeducation.markr.service.StudentService; @@ -14,81 +11,77 @@ import com.stileeducation.markr.service.TestService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/") public class TestResultsController { - public static final String IMPORT_ENDPOINT = "/import"; - public static final String AGGREGATE_ENDPOINT = "/results/{test-id}/aggregate"; + public static final String IMPORT_ENDPOINT = "/import"; + public static final String AGGREGATE_ENDPOINT = "/results/{test-id}/aggregate"; - @Autowired - private StudentService studentService; + @Autowired + private StudentService studentService; - @Autowired - private TestService testService; + @Autowired + private TestService testService; - @Autowired - private TestResultsService testResultsService; + @Autowired + private TestResultsService testResultsService; - @Autowired - private TestRepository testRepository; + @Autowired + private TestRepository testRepository; - @Autowired - private TestResultRepository testResultRepository; + @Autowired + private TestResultRepository testResultRepository; - public TestResultsController(TestResultsService testResultsService) { - this.testResultsService = testResultsService; - } - - // TODO consider return value - @PostMapping( - value = IMPORT_ENDPOINT, - consumes = "text/xml+markr", - produces = "application/json") - public ResponseEntity<Void> handleXmlRequest(@RequestBody MCQTestResultsDTO testResults) { - - for (MCQTestResultDTO mcqTestResult : testResults.getMcqTestResults()) { - Student student = studentService - .findOrCreateStudent( - mcqTestResult.getFirstName(), - mcqTestResult.getLastName(), - mcqTestResult.getStudentNumber()); - - Test test = testService - .findOrCreateTest( - mcqTestResult.getTestId(), - mcqTestResult.getSummaryMarks().getAvailable()); - - if (test.getMarksAvailable() < mcqTestResult.getSummaryMarks().getAvailable()) { - test.setMarksAvailable(mcqTestResult.getSummaryMarks().getAvailable()); - testRepository.save(test); - } - - // Some edge cases to consider - // obtained is higher than available (assumption?) + public TestResultsController(TestResultsService testResultsService) { + this.testResultsService = testResultsService; + } - TestResult testResult = testResultsService - .findOrCreateTestResult( - student.getId(), - test.getId(), - mcqTestResult.getSummaryMarks().getObtained()); - - if (testResult.getMarksAwarded() < mcqTestResult.getSummaryMarks().getObtained()) { - testResult.setMarksAwarded(mcqTestResult.getSummaryMarks().getObtained()); - testResultRepository.save(testResult); - } - } - - return new ResponseEntity<>(HttpStatus.NO_CONTENT); + @PostMapping( + value = IMPORT_ENDPOINT, + consumes = "text/xml+markr", + produces = "application/json") + public ResponseEntity<ImportResponseDTO> postTestResults(@Validated @RequestBody MCQTestResultsDTO testResults) { + ImportResponseDTO response = testResultsService.processTestResults(testResults); + if ("failure".equals(response.getStatus())) { + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - - @GetMapping( - value = AGGREGATE_ENDPOINT, - produces = "application/json") - public AggregatedTestResultsDTO getAggregatedResults(@PathVariable("test-id") String testId) { - return testResultsService.aggregateTestResults(testId); - } - + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @GetMapping( + value = AGGREGATE_ENDPOINT, + produces = "application/json") + public AggregateResponseDTO getAggregatedResults(@PathVariable("test-id") String testId) { + return testResultsService.aggregateTestResults(testId); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity<ImportResponseDTO> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + ImportResponseDTO response = new ImportResponseDTO(); + response.setStatus("error"); + response.setMessage("Invalid payload"); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity<ImportResponseDTO> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + ImportResponseDTO response = new ImportResponseDTO(); + response.setStatus("error"); + response.setMessage("Invalid XML payload"); + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity<ImportResponseDTO> handleGenericException(Exception ex) { + ImportResponseDTO response = new ImportResponseDTO(); + response.setStatus("error"); + response.setMessage("An unexpected error occurred"); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } } diff --git a/src/main/java/com/stileeducation/markr/dto/AggregatedTestResultsDTO.java b/src/main/java/com/stileeducation/markr/dto/AggregateResponseDTO.java index f5970c3..7232174 100644 --- a/src/main/java/com/stileeducation/markr/dto/AggregatedTestResultsDTO.java +++ b/src/main/java/com/stileeducation/markr/dto/AggregateResponseDTO.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Objects; @JsonInclude(JsonInclude.Include.NON_NULL) -public class AggregatedTestResultsDTO { +public class AggregateResponseDTO { private double mean; private double stddev; @@ -85,7 +85,7 @@ public class AggregatedTestResultsDTO { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - AggregatedTestResultsDTO that = (AggregatedTestResultsDTO) o; + AggregateResponseDTO that = (AggregateResponseDTO) o; return Double.compare(mean, that.mean) == 0 && Double.compare(stddev, that.stddev) == 0 && Double.compare(min, that.min) == 0 diff --git a/src/main/java/com/stileeducation/markr/dto/ImportResponseDTO.java b/src/main/java/com/stileeducation/markr/dto/ImportResponseDTO.java new file mode 100644 index 0000000..8613865 --- /dev/null +++ b/src/main/java/com/stileeducation/markr/dto/ImportResponseDTO.java @@ -0,0 +1,115 @@ +package com.stileeducation.markr.dto; + +public class ImportResponseDTO { + + private String status; + private String message; + private ImportData data; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public ImportData getData() { + return data; + } + + public void setData(ImportData data) { + this.data = data; + } + + public static class ImportData { + private int studentsCreated = 0; + private int studentsUpdated = 0; + + private int testsCreated = 0; + private int testsUpdated = 0; + + private int testResultsCreated = 0; + private int testResultsUpdated = 0; + + public int getStudentsCreated() { + return studentsCreated; + } + + public void setStudentsCreated(int studentsCreated) { + this.studentsCreated = studentsCreated; + } + + public int getStudentsUpdated() { + return studentsUpdated; + } + + public void setStudentsUpdated(int studentsUpdated) { + this.studentsUpdated = studentsUpdated; + } + + public int getTestsCreated() { + return testsCreated; + } + + public void setTestsCreated(int testsCreated) { + this.testsCreated = testsCreated; + } + + public int getTestsUpdated() { + return testsUpdated; + } + + public void setTestsUpdated(int testsUpdated) { + this.testsUpdated = testsUpdated; + } + + public int getTestResultsCreated() { + return testResultsCreated; + } + + public void setTestResultsCreated(int testResultsCreated) { + this.testResultsCreated = testResultsCreated; + } + + public int getTestResultsUpdated() { + return testResultsUpdated; + } + + public void setTestResultsUpdated(int testResultsUpdated) { + this.testResultsUpdated = testResultsUpdated; + } + + public void incrementStudentsCreated() { + this.studentsCreated++; + } + + public void incrementStudentsUpdated() { + this.studentsUpdated++; + } + + public void incrementTestsCreated() { + this.testsCreated++; + } + + public void incrementTestsUpdated() { + this.testsUpdated++; + } + + public void incrementTestResultsCreated() { + this.testResultsCreated++; + } + + public void incrementTestResultsUpdated() { + this.testResultsUpdated++; + } + } +} diff --git a/src/main/java/com/stileeducation/markr/dto/MCQTestResultDTO.java b/src/main/java/com/stileeducation/markr/dto/MCQTestResultDTO.java index b227fe3..da6a0e1 100644 --- a/src/main/java/com/stileeducation/markr/dto/MCQTestResultDTO.java +++ b/src/main/java/com/stileeducation/markr/dto/MCQTestResultDTO.java @@ -1,5 +1,8 @@ package com.stileeducation.markr.dto; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlRootElement; @@ -9,11 +12,23 @@ import java.util.Objects; @XmlRootElement(name = "mcq-test-result") public class MCQTestResultDTO { + @NotBlank private String scannedOn; + + @NotBlank(message = "First name is mandatory") private String firstName; + + @NotBlank(message = "Last name is mandatory") private String lastName; + + @NotBlank(message = "Last name is mandatory") private String studentNumber; + + @NotBlank(message = "Test id is mandatory") private String testId; + + @Valid + @NotNull private SummaryMarksDTO summaryMarks; @XmlAttribute(name = "scanned-on") diff --git a/src/main/java/com/stileeducation/markr/dto/MCQTestResultsDTO.java b/src/main/java/com/stileeducation/markr/dto/MCQTestResultsDTO.java index e9ee8a7..fb77125 100644 --- a/src/main/java/com/stileeducation/markr/dto/MCQTestResultsDTO.java +++ b/src/main/java/com/stileeducation/markr/dto/MCQTestResultsDTO.java @@ -1,5 +1,6 @@ package com.stileeducation.markr.dto; +import jakarta.validation.Valid; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlRootElement; @@ -9,6 +10,7 @@ import java.util.Objects; @XmlRootElement(name = "mcq-test-results") public class MCQTestResultsDTO { + @Valid private List<MCQTestResultDTO> mcqTestResults; @XmlElement(name = "mcq-test-result") diff --git a/src/main/java/com/stileeducation/markr/dto/SummaryMarksDTO.java b/src/main/java/com/stileeducation/markr/dto/SummaryMarksDTO.java index a67d19c..788eea0 100644 --- a/src/main/java/com/stileeducation/markr/dto/SummaryMarksDTO.java +++ b/src/main/java/com/stileeducation/markr/dto/SummaryMarksDTO.java @@ -1,5 +1,7 @@ package com.stileeducation.markr.dto; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import jakarta.xml.bind.annotation.XmlAttribute; import jakarta.xml.bind.annotation.XmlRootElement; @@ -7,8 +9,14 @@ import java.util.Objects; @XmlRootElement(name = "summary-marks") public class SummaryMarksDTO { - private int available; - private int obtained; + + @NotNull(message = "Available marks must not be null") + @Min(value = 0, message = "Available marks must be non-negative") + private Integer available; + + @NotNull(message = "Obtained marks must not be null") + @Min(value = 0, message = "Obtained marks must be non-negative") + private Integer obtained; @XmlAttribute(name = "available") public int getAvailable() { diff --git a/src/main/java/com/stileeducation/markr/entity/Student.java b/src/main/java/com/stileeducation/markr/entity/Student.java index 4e69eab..30a4c2b 100644 --- a/src/main/java/com/stileeducation/markr/entity/Student.java +++ b/src/main/java/com/stileeducation/markr/entity/Student.java @@ -10,87 +10,113 @@ import java.util.Set; @Table(name = "students") public class Student { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "first_name", nullable = false) - private String firstName; - - @Column(name = "last_name", nullable = false) - private String lastName; - - @Column(name = "student_number", nullable = false, unique = true) - private String studentNumber; - - @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true) - private Set<TestResult> testResults = new HashSet<>(); - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getStudentNumber() { - return studentNumber; - } - - public void setStudentNumber(String studentNumber) { - this.studentNumber = studentNumber; - } - - public Set<TestResult> getTestResults() { - return testResults; - } - - public void setTestResults(Set<TestResult> testResults) { - this.testResults = testResults; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Student student = (Student) o; - return Objects.equals(id, student.id) && - Objects.equals(firstName, student.firstName) && - Objects.equals(lastName, student.lastName) && - Objects.equals(studentNumber, student.studentNumber) && - Objects.equals(testResults, student.testResults); - } - - @Override - public int hashCode() { - return Objects.hash(id, firstName, lastName, studentNumber, testResults); - } - - @Override - public String toString() { - return "Student{" + - "id=" + id + - ", firstName='" + firstName + '\'' + - ", lastName='" + lastName + '\'' + - ", studentNumber='" + studentNumber + '\'' + - ", testResults=" + testResults + - '}'; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "first_name", nullable = false) + private String firstName; + + @Column(name = "last_name", nullable = false) + private String lastName; + + @Column(name = "student_number", nullable = false, unique = true) + private String studentNumber; + + @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true) + private Set<TestResult> testResults = new HashSet<>(); + + @Transient + private boolean created = false; + + @Transient + private boolean updated = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getStudentNumber() { + return studentNumber; + } + + public void setStudentNumber(String studentNumber) { + this.studentNumber = studentNumber; + } + + public Set<TestResult> getTestResults() { + return testResults; + } + + public void setTestResults(Set<TestResult> testResults) { + this.testResults = testResults; + } + + public boolean isCreated() { + return created; + } + + public void setCreated(boolean created) { + this.created = created; + } + + public boolean isUpdated() { + return updated; + } + + public void setUpdated(boolean updated) { + this.updated = updated; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Student student = (Student) o; + return Objects.equals(id, student.id) && + Objects.equals(firstName, student.firstName) && + Objects.equals(lastName, student.lastName) && + Objects.equals(studentNumber, student.studentNumber) && + Objects.equals(testResults, student.testResults) && + Objects.equals(created, student.created) && + Objects.equals(updated, student.updated); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, studentNumber, testResults); + } + + @Override + public String toString() { + return "Student{" + + "id=" + id + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", studentNumber='" + studentNumber + '\'' + + ", testResults=" + testResults + + ", created=" + created + + ", updated=" + updated + + '}'; + } }
\ No newline at end of file diff --git a/src/main/java/com/stileeducation/markr/entity/Test.java b/src/main/java/com/stileeducation/markr/entity/Test.java index 615d0e1..ff9088e 100644 --- a/src/main/java/com/stileeducation/markr/entity/Test.java +++ b/src/main/java/com/stileeducation/markr/entity/Test.java @@ -10,74 +10,100 @@ import java.util.Set; @Table(name = "tests") public class Test { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "test_id", nullable = false, unique = true) - private String testId; - - @Column(name = "marks_available", nullable = false) - private Integer marksAvailable; - - @OneToMany(mappedBy = "test", cascade = CascadeType.ALL, orphanRemoval = true) - private Set<TestResult> testResults = new HashSet<>(); - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getTestId() { - return testId; - } - - public void setTestId(String testId) { - this.testId = testId; - } - - public Integer getMarksAvailable() { - return marksAvailable; - } - - public void setMarksAvailable(Integer marksAvailable) { - this.marksAvailable = marksAvailable; - } - - public Set<TestResult> getTestResults() { - return testResults; - } - - public void setTestResults(Set<TestResult> testResults) { - this.testResults = testResults; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Test test = (Test) o; - return Objects.equals(id, test.id) && - Objects.equals(testId, test.testId) && - Objects.equals(marksAvailable, test.marksAvailable) && - Objects.equals(testResults, test.testResults); - } - - @Override - public int hashCode() { - return Objects.hash(id, testId, marksAvailable, testResults); - } - - @Override - public String toString() { - return "Test{" + - "id=" + id + - ", testId='" + testId + '\'' + - ", marksAvailable=" + marksAvailable + - ", testResults=" + testResults + - '}'; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "test_id", nullable = false, unique = true) + private String testId; + + @Column(name = "marks_available", nullable = false) + private Integer marksAvailable; + + @OneToMany(mappedBy = "test", cascade = CascadeType.ALL, orphanRemoval = true) + private Set<TestResult> testResults = new HashSet<>(); + + @Transient + private boolean created = false; + + @Transient + private boolean updated = false; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTestId() { + return testId; + } + + public void setTestId(String testId) { + this.testId = testId; + } + + public Integer getMarksAvailable() { + return marksAvailable; + } + + public void setMarksAvailable(Integer marksAvailable) { + this.marksAvailable = marksAvailable; + } + + public Set<TestResult> getTestResults() { + return testResults; + } + + public void setTestResults(Set<TestResult> testResults) { + this.testResults = testResults; + } + + public boolean isCreated() { + return created; + } + + public void setCreated(boolean created) { + this.created = created; + } + + public boolean isUpdated() { + return updated; + } + + public void setUpdated(boolean updated) { + this.updated = updated; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Test test = (Test) o; + return Objects.equals(id, test.id) && + Objects.equals(testId, test.testId) && + Objects.equals(marksAvailable, test.marksAvailable) && + Objects.equals(testResults, test.testResults) && + Objects.equals(created, test.created) && + Objects.equals(updated, test.updated); + } + + @Override + public int hashCode() { + return Objects.hash(id, testId, marksAvailable, testResults); + } + + @Override + public String toString() { + return "Test{" + + "id=" + id + + ", testId='" + testId + '\'' + + ", marksAvailable=" + marksAvailable + + ", testResults=" + testResults + + ", created=" + created + + ", updated=" + updated + + '}'; + } } diff --git a/src/main/java/com/stileeducation/markr/entity/TestResult.java b/src/main/java/com/stileeducation/markr/entity/TestResult.java index 26b2346..bdbade4 100644 --- a/src/main/java/com/stileeducation/markr/entity/TestResult.java +++ b/src/main/java/com/stileeducation/markr/entity/TestResult.java @@ -8,83 +8,112 @@ import java.util.Objects; @Table(name = "test_results") public class TestResult { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "student_id", nullable = false) - private Student student; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "test_id", nullable = false) - private Test test; - @Column(name = "marks_awarded", nullable = false) - private Integer marksAwarded; - - public TestResult() { - } - - public TestResult(Long id, Student student, Test test, Integer marksAwarded) { - this.id = id; - this.student = student; - this.test = test; - this.marksAwarded = marksAwarded; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Student getStudent() { - return student; - } - - public void setStudent(Student student) { - this.student = student; - } - - public Test getTest() { - return test; - } - - public void setTest(Test test) { - this.test = test; - } - - public Integer getMarksAwarded() { - return marksAwarded; - } - - public void setMarksAwarded(Integer marksAwarded) { - this.marksAwarded = marksAwarded; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TestResult that = (TestResult) o; - return Objects.equals(id, that.id) && - Objects.equals(student, that.student) && - Objects.equals(test, that.test) && - Objects.equals(marksAwarded, that.marksAwarded); - } - - @Override - public int hashCode() { - return Objects.hash(id, student, test, marksAwarded); - } - - @Override - public String toString() { - return "TestResult{" + - "id=" + id + - ", student=" + student + - ", test=" + test + - ", marksAwarded=" + marksAwarded + - '}'; - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id", nullable = false) + private Student student; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "test_id", nullable = false) + private Test test; + + @Column(name = "marks_awarded", nullable = false) + private Integer marksAwarded; + + @Transient + private boolean created = false; + + @Transient + private boolean updated = false; + + public TestResult() { + } + + public TestResult(Long id, Student student, Test test, Integer marksAwarded) { + this.id = id; + this.student = student; + this.test = test; + this.marksAwarded = marksAwarded; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Student getStudent() { + return student; + } + + public void setStudent(Student student) { + this.student = student; + } + + public Test getTest() { + return test; + } + + public void setTest(Test test) { + this.test = test; + } + + public Integer getMarksAwarded() { + return marksAwarded; + } + + public void setMarksAwarded(Integer marksAwarded) { + this.marksAwarded = marksAwarded; + } + + public boolean isCreated() { + return created; + } + + public void setCreated(boolean created) { + this.created = created; + } + + public boolean isUpdated() { + return updated; + } + + public void setUpdated(boolean updated) { + this.updated = updated; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TestResult that = (TestResult) o; + return Objects.equals(id, that.id) && + Objects.equals(student, that.student) && + Objects.equals(test, that.test) && + Objects.equals(marksAwarded, that.marksAwarded) && + Objects.equals(created, that.created) && + Objects.equals(updated, that.updated); + } + + @Override + public int hashCode() { + return Objects.hash(id, student, test, marksAwarded); + } + + @Override + public String toString() { + return "TestResult{" + + "id=" + id + + ", student=" + student + + ", test=" + test + + ", marksAwarded=" + marksAwarded + + ", created=" + created + + ", updated=" + updated + + '}'; + } }
\ No newline at end of file diff --git a/src/main/java/com/stileeducation/markr/service/StudentService.java b/src/main/java/com/stileeducation/markr/service/StudentService.java index 4af4d63..3ce28c0 100644 --- a/src/main/java/com/stileeducation/markr/service/StudentService.java +++ b/src/main/java/com/stileeducation/markr/service/StudentService.java @@ -22,6 +22,7 @@ public class StudentService { student.setFirstName(firstName); student.setLastName(lastName); student.setStudentNumber(studentNumber); + student.setCreated(true); return studentRepository.save(student); } } diff --git a/src/main/java/com/stileeducation/markr/service/TestResultsService.java b/src/main/java/com/stileeducation/markr/service/TestResultsService.java index 95b42f3..51efe95 100644 --- a/src/main/java/com/stileeducation/markr/service/TestResultsService.java +++ b/src/main/java/com/stileeducation/markr/service/TestResultsService.java @@ -1,11 +1,12 @@ package com.stileeducation.markr.service; -import com.stileeducation.markr.dto.AggregatedTestResultsDTO; +import com.stileeducation.markr.dto.AggregateResponseDTO; +import com.stileeducation.markr.dto.ImportResponseDTO; +import com.stileeducation.markr.dto.MCQTestResultDTO; +import com.stileeducation.markr.dto.MCQTestResultsDTO; import com.stileeducation.markr.entity.Student; import com.stileeducation.markr.entity.Test; import com.stileeducation.markr.entity.TestResult; -import com.stileeducation.markr.repository.StudentRepository; -import com.stileeducation.markr.repository.TestRepository; import com.stileeducation.markr.repository.TestResultRepository; import org.apache.commons.math3.stat.descriptive.moment.StandardDeviation; import org.apache.commons.math3.stat.descriptive.rank.Percentile; @@ -19,27 +20,34 @@ import java.util.Optional; public class TestResultsService { public static final boolean IS_BIAS_CORRECTED = false; + @Autowired private TestResultRepository testResultRepository; @Autowired - private StudentRepository studentRepository; + private StudentService studentService; @Autowired - private TestRepository testRepository; - - public TestResult findOrCreateTestResult(Long studentId, Long testId, Integer marksAwarded) { - Student student = studentRepository.findById(studentId).orElseThrow(() -> new RuntimeException("Student not found")); - Test test = testRepository.findById(testId).orElseThrow(() -> new RuntimeException("Test not found")); + private TestService testService; + public TestResult findOrCreateTestResult(Student student, Test test, Integer marksAwarded) { Optional<TestResult> optionalTestResult = testResultRepository.findByStudentAndTest(student, test); if (optionalTestResult.isPresent()) { - return optionalTestResult.get(); + TestResult testResult = optionalTestResult.get(); + if (marksAwarded > testResult.getMarksAwarded()) { + testResult.setMarksAwarded(marksAwarded); + testResult.setUpdated(true); + testResultRepository.save(testResult); + } else { + testResult.setUpdated(false); + } + return testResult; } else { TestResult testResult = new TestResult(); testResult.setStudent(student); testResult.setTest(test); testResult.setMarksAwarded(marksAwarded); + testResult.setCreated(true); return testResultRepository.save(testResult); } } @@ -126,10 +134,10 @@ public class TestResultsService { return new Percentile().evaluate(marks, 75.0); } - public AggregatedTestResultsDTO aggregateTestResults(String testId) { + public AggregateResponseDTO aggregateTestResults(String testId) { List<TestResult> testResults = findAllByTestId(testId); - AggregatedTestResultsDTO results = new AggregatedTestResultsDTO(); + AggregateResponseDTO results = new AggregateResponseDTO(); results.setMean(calculateMeanOfTestResults(testResults)); results.setStddev(calculateStandardDeviationOfTestResults(testResults)); @@ -143,4 +151,66 @@ public class TestResultsService { return results; } + public ImportResponseDTO processTestResults(MCQTestResultsDTO testResults) { + ImportResponseDTO.ImportData importData = new ImportResponseDTO.ImportData(); + boolean isValid = true; + + for (MCQTestResultDTO mcqTestResult : testResults.getMcqTestResults()) { + try { + + Student student = studentService + .findOrCreateStudent( + mcqTestResult.getFirstName(), + mcqTestResult.getLastName(), + mcqTestResult.getStudentNumber()); + + if (student.isCreated()) { + importData.incrementStudentsCreated(); + } + if (student.isUpdated()) { + importData.incrementStudentsUpdated(); + } + + Test test = testService + .findOrCreateTest( + mcqTestResult.getTestId(), + mcqTestResult.getSummaryMarks().getAvailable()); + + if (test.isCreated()) { + importData.incrementTestsCreated(); + } + if (test.isUpdated()) { + importData.incrementTestsUpdated(); + } + + TestResult testResult = + findOrCreateTestResult( + student, + test, + mcqTestResult.getSummaryMarks().getObtained()); + + if (testResult.isCreated()) { + importData.incrementTestResultsCreated(); + } + if (testResult.isUpdated()) { + importData.incrementTestResultsUpdated(); + } + } catch (Exception e) { + isValid = false; + } + } + + ImportResponseDTO response = new ImportResponseDTO(); + response.setData(importData); + + if (isValid) { + response.setStatus("success"); + response.setMessage("Import operation completed successfully."); + } else { + response.setStatus("failure"); + response.setMessage("Data was invalid or processing failed."); + } + + return response; + } }
\ No newline at end of file diff --git a/src/main/java/com/stileeducation/markr/service/TestService.java b/src/main/java/com/stileeducation/markr/service/TestService.java index f3ba98c..f3231b9 100644 --- a/src/main/java/com/stileeducation/markr/service/TestService.java +++ b/src/main/java/com/stileeducation/markr/service/TestService.java @@ -16,13 +16,21 @@ public class TestService { public Test findOrCreateTest(String testId, Integer marksAvailable) { Optional<Test> optionalTest = testRepository.findByTestId(testId); if (optionalTest.isPresent()) { - return optionalTest.get(); + Test test = optionalTest.get(); + if (test.getMarksAvailable() < marksAvailable) { + test.setMarksAvailable(marksAvailable); + test.setUpdated(true); + testRepository.save(test); + } else { + test.setUpdated(false); + } + return test; } else { Test test = new Test(); test.setTestId(testId); test.setMarksAvailable(marksAvailable); + test.setCreated(true); return testRepository.save(test); } } - } diff --git a/src/test/java/com/stileeducation/markr/controller/TestResultsControllerTest.java b/src/test/java/com/stileeducation/markr/controller/TestResultsControllerTest.java index b486812..c0358c9 100644 --- a/src/test/java/com/stileeducation/markr/controller/TestResultsControllerTest.java +++ b/src/test/java/com/stileeducation/markr/controller/TestResultsControllerTest.java @@ -2,11 +2,11 @@ package com.stileeducation.markr.controller; import com.stileeducation.markr.MarkrApplication; import com.stileeducation.markr.converter.XmlMarkrMessageConverter; +import com.stileeducation.markr.dto.ImportResponseDTO; import com.stileeducation.markr.dto.MCQTestResultsDTO; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; -import jakarta.xml.bind.Unmarshaller; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,29 +17,26 @@ import org.springframework.http.*; import org.springframework.test.context.ActiveProfiles; import java.io.IOException; -import java.io.InputStream; import java.io.StringWriter; import static com.stileeducation.markr.controller.TestResultsController.IMPORT_ENDPOINT; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(classes = MarkrApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") public class TestResultsControllerTest { - private static MCQTestResultsDTO sampleResults; + private static String validPayload; + private static String invalidPayload; @Autowired private TestRestTemplate restTemplate; @BeforeAll - static void setup() throws JAXBException, IOException { - ClassPathResource resource = new ClassPathResource("sample-results.xml"); - try (InputStream inputStream = resource.getInputStream()) { - JAXBContext jaxbContext = JAXBContext.newInstance(MCQTestResultsDTO.class); - Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); - sampleResults = (MCQTestResultsDTO) unmarshaller.unmarshal(inputStream); - } + static void setup() throws IOException { + validPayload = new String(new ClassPathResource("sample-results.xml").getInputStream().readAllBytes(), UTF_8); + invalidPayload = new String(new ClassPathResource("invalid-payload.xml").getInputStream().readAllBytes(), UTF_8); } private static String toXmlString(MCQTestResultsDTO mcqTestResultsDTO) throws JAXBException { @@ -51,36 +48,267 @@ public class TestResultsControllerTest { } @Test - void testPost() throws Exception { + void testSupportedMediaType() throws Exception { // Given - String requestXml = toXmlString(sampleResults); - HttpHeaders headers = new HttpHeaders(); headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(validPayload, headers); // When - HttpEntity<String> entity = new HttpEntity<>(requestXml, headers); ResponseEntity<String> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, String.class); // Then - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test void testUnsupportedMediaType() throws Exception { // Given - MCQTestResultsDTO request = new MCQTestResultsDTO(); - String requestXml = toXmlString(request); - HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_XML); + HttpEntity<String> entity = new HttpEntity<>(validPayload, headers); // When - HttpEntity<String> entity = new HttpEntity<>(requestXml, headers); ResponseEntity<String> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, String.class); // Then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); } + @Test + void testInvalidImport() throws Exception { + // Given + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<String> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void testInvalidImport_MissingFirstName() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name></first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks available="20" obtained="13"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingLastName() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name></last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks available="20" obtained="13"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingStudentNumber() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number></student-number> + <test-id>9863</test-id> + <summary-marks available="20" obtained="13"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingTestId() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id></test-id> + <summary-marks available="20" obtained="13"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingSummaryMarks() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingSummaryMarksAvailable() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks obtained="13"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_MissingSummaryMarksObtained() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks available="20"/> + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid payload"); + } + + @Test + void testInvalidImport_InvalidXmlFormat() throws Exception { + // Given + String invalidPayload = """ + <mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name>KJ</first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks available="20" obtained="1 + </mcq-test-result> + </mcq-test-results> + """; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(XmlMarkrMessageConverter.MEDIA_TYPE); + HttpEntity<String> entity = new HttpEntity<>(invalidPayload, headers); + + // When + ResponseEntity<ImportResponseDTO> response = restTemplate.postForEntity(IMPORT_ENDPOINT, entity, ImportResponseDTO.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Invalid XML payload"); + } }
\ No newline at end of file diff --git a/src/test/resources/invalid-payload.xml b/src/test/resources/invalid-payload.xml new file mode 100644 index 0000000..02d3b76 --- /dev/null +++ b/src/test/resources/invalid-payload.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<mcq-test-results> + <mcq-test-result scanned-on="2017-12-04T12:12:10+11:00"> + <first-name></first-name> + <last-name>Alysander</last-name> + <student-number>002299</student-number> + <test-id>9863</test-id> + <summary-marks available="20" obtained="13" /> + </mcq-test-result> +</mcq-test-results>
\ No newline at end of file |
