diff --git a/pom.xml b/pom.xml index b0cfd757..5ba25417 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.4 + 3.2.2 record-manager @@ -48,7 +48,6 @@ ${jdk.version} 2.0.0-SNAPSHOT - 1.9.20 4.11.0 1.5.5.Final @@ -71,6 +70,10 @@ org.springframework.boot spring-boot-starter-oauth2-resource-server + + org.springframework.data + spring-data-commons + org.apache.httpcomponents.client5 @@ -98,7 +101,7 @@ com.github.ledsoft jopa-spring-transaction - 0.2.0 + 0.3.0-SNAPSHOT diff --git a/src/main/java/cz/cvut/kbss/study/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/study/config/SecurityConfig.java index 148aade2..ded8b3bf 100644 --- a/src/main/java/cz/cvut/kbss/study/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/study/config/SecurityConfig.java @@ -115,6 +115,7 @@ static CorsConfigurationSource createCorsConfiguration(ConfigReader configReader corsConfiguration.addExposedHeader(HttpHeaders.AUTHORIZATION); corsConfiguration.addExposedHeader(HttpHeaders.LOCATION); corsConfiguration.addExposedHeader(HttpHeaders.CONTENT_DISPOSITION); + corsConfiguration.addExposedHeader(HttpHeaders.LINK); final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); diff --git a/src/main/java/cz/cvut/kbss/study/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/study/config/WebAppConfig.java index df53da8b..8034cf95 100644 --- a/src/main/java/cz/cvut/kbss/study/config/WebAppConfig.java +++ b/src/main/java/cz/cvut/kbss/study/config/WebAppConfig.java @@ -44,7 +44,7 @@ public ObjectMapper objectMapper() { */ public static ObjectMapper createJsonObjectMapper() { final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // Ignore UoW references injected into entities objectMapper.addMixIn(UnitOfWorkImpl.class, ManageableIgnoreMixin.class); diff --git a/src/main/java/cz/cvut/kbss/study/dto/PatientRecordDto.java b/src/main/java/cz/cvut/kbss/study/dto/PatientRecordDto.java index 1489461b..8dddf25e 100644 --- a/src/main/java/cz/cvut/kbss/study/dto/PatientRecordDto.java +++ b/src/main/java/cz/cvut/kbss/study/dto/PatientRecordDto.java @@ -4,6 +4,7 @@ import cz.cvut.kbss.study.model.*; import cz.cvut.kbss.study.model.util.HasOwlKey; +import java.net.URI; import java.util.Date; @OWLClass(iri = Vocabulary.s_c_patient_record) @@ -21,8 +22,8 @@ public class PatientRecordDto extends AbstractEntity implements HasOwlKey { private String localName; @ParticipationConstraints(nonEmpty = true) - @OWLObjectProperty(iri = Vocabulary.s_p_has_author, fetch = FetchType.EAGER) - private User author; + @OWLObjectProperty(iri = Vocabulary.s_p_has_author) + private URI author; @OWLDataProperty(iri = Vocabulary.s_p_created) private Date dateCreated; @@ -30,8 +31,8 @@ public class PatientRecordDto extends AbstractEntity implements HasOwlKey { @OWLDataProperty(iri = Vocabulary.s_p_modified) private Date lastModified; - @OWLObjectProperty(iri = Vocabulary.s_p_has_last_editor, fetch = FetchType.EAGER) - private User lastModifiedBy; + @OWLObjectProperty(iri = Vocabulary.s_p_has_last_editor) + private URI lastModifiedBy; @OWLObjectProperty(iri = Vocabulary.s_p_was_treated_at, fetch = FetchType.EAGER) private Institution institution; @@ -58,11 +59,11 @@ public void setLocalName(String localName) { this.localName = localName; } - public User getAuthor() { + public URI getAuthor() { return author; } - public void setAuthor(User author) { + public void setAuthor(URI author) { this.author = author; } @@ -82,11 +83,11 @@ public void setLastModified(Date lastModified) { this.lastModified = lastModified; } - public User getLastModifiedBy() { + public URI getLastModifiedBy() { return lastModifiedBy; } - public void setLastModifiedBy(User lastModifiedBy) { + public void setLastModifiedBy(URI lastModifiedBy) { this.lastModifiedBy = lastModifiedBy; } diff --git a/src/main/java/cz/cvut/kbss/study/model/RecordPhase.java b/src/main/java/cz/cvut/kbss/study/model/RecordPhase.java index 20a09a20..666b602b 100644 --- a/src/main/java/cz/cvut/kbss/study/model/RecordPhase.java +++ b/src/main/java/cz/cvut/kbss/study/model/RecordPhase.java @@ -31,7 +31,7 @@ public String getIri() { * @return matching {@code RecordPhase} * @throws IllegalArgumentException When no matching phase is found */ - public static RecordPhase fromString(String iri) { + public static RecordPhase fromIri(String iri) { for (RecordPhase p : values()) { if (p.getIri().equals(iri)) { return p; @@ -39,4 +39,38 @@ public static RecordPhase fromString(String iri) { } throw new IllegalArgumentException("Unknown record phase identifier '" + iri + "'."); } + + /** + * Returns {@link RecordPhase} with the specified constant name. + * + * @param name record phase name + * @return matching {@code RecordPhase} + * @throws IllegalArgumentException When no matching phase is found + */ + public static RecordPhase fromName(String name) { + for (RecordPhase p : values()) { + if (p.name().equalsIgnoreCase(name)) { + return p; + } + } + throw new IllegalArgumentException("Unknown record phase '" + name + "'."); + } + + /** + * Returns a {@link RecordPhase} with the specified IRI or constant name. + *

+ * This function first tries to find the enum constant by IRI. If it is not found, constant name matching is + * attempted. + * + * @param identification Constant IRI or name to find match by + * @return matching {@code RecordPhase} + * @throws IllegalArgumentException When no matching phase is found + */ + public static RecordPhase fromIriOrName(String identification) { + try { + return fromIri(identification); + } catch (IllegalArgumentException e) { + return fromName(identification); + } + } } diff --git a/src/main/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDao.java b/src/main/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDao.java index ad377150..928126bb 100644 --- a/src/main/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDao.java +++ b/src/main/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDao.java @@ -7,10 +7,12 @@ import cz.cvut.kbss.study.model.User; import cz.cvut.kbss.study.model.Vocabulary; import cz.cvut.kbss.study.util.Constants; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.net.URI; -import java.util.List; import java.util.Objects; @Repository @@ -35,7 +37,7 @@ public ActionHistory findByKey(String key) { } } - public List findAllWithParams(String typeFilter, User author, int pageNumber) { + public Page findAllWithParams(String typeFilter, User author, Pageable pageSpec) { String params; if (typeFilter == null && author == null) { params = " } "; @@ -52,10 +54,11 @@ public List findAllWithParams(String typeFilter, User author, int params + "ORDER BY DESC(?timestamp)", ActionHistory.class) .setParameter("type", typeUri) - .setParameter("isCreated", URI.create(Vocabulary.s_p_created)) - .setFirstResult((pageNumber - 1) * Constants.ACTIONS_PER_PAGE) - .setMaxResults(Constants.ACTIONS_PER_PAGE + 1); - + .setParameter("isCreated", URI.create(Vocabulary.s_p_created)); + if (pageSpec.isPaged()) { + q.setFirstResult((int) pageSpec.getOffset()); + q.setMaxResults(pageSpec.getPageSize()); + } if (author != null) { q.setParameter("hasOwner", URI.create(Vocabulary.s_p_has_owner)) .setParameter("author", author.getUri()); @@ -64,6 +67,6 @@ public List findAllWithParams(String typeFilter, User author, int q.setParameter("typeFilter", typeFilter, Constants.PU_LANGUAGE) .setParameter("isType", URI.create(Vocabulary.s_p_action_type)); } - return q.getResultList(); + return new PageImpl<>(q.getResultList(), pageSpec, 0L); } } diff --git a/src/main/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDao.java b/src/main/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDao.java index e185f217..cb72d921 100644 --- a/src/main/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDao.java +++ b/src/main/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDao.java @@ -16,8 +16,13 @@ import cz.cvut.kbss.study.model.Vocabulary; import cz.cvut.kbss.study.persistence.dao.util.QuestionSaver; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; +import cz.cvut.kbss.study.persistence.dao.util.RecordSort; import cz.cvut.kbss.study.util.Constants; import cz.cvut.kbss.study.util.IdentificationUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.math.BigInteger; @@ -177,40 +182,85 @@ public void requireUniqueNonEmptyLocalName(PatientRecord entity) { em.clear(); } + /** + * Retrieves DTOs of records matching the specified filtering criteria. + *

+ * Note that since the record modification is tracked by a timestamp and the filter uses dates, this method uses + * beginning of the min date and end of the max date. + *

+ * The returned page contains also information about total number of matching records. + * + * @param filters Record filtering criteria + * @param pageSpec Specification of page and sorting + * @return Page with matching records + * @see #findAllRecordsFull(RecordFilterParams, Pageable) + */ + public Page findAllRecords(RecordFilterParams filters, Pageable pageSpec) { + Objects.requireNonNull(filters); + Objects.requireNonNull(pageSpec); + return findRecords(filters, pageSpec, PatientRecordDto.class); + } + /** * Retrieves records matching the specified filtering criteria. *

* Note that since the record modification is tracked by a timestamp and the filter uses dates, this method uses * beginning of the min date and end of the max date. + *

+ * The returned page contains also information about total number of matching records. * - * @param filterParams Record filtering criteria - * @return List of matching records + * @param filters Record filtering criteria + * @param pageSpec Specification of page and sorting + * @return Page with matching records + * @see #findAllRecords(RecordFilterParams, Pageable) */ - public List findAllFull(RecordFilterParams filterParams) { - Objects.requireNonNull(filterParams); + public Page findAllRecordsFull(RecordFilterParams filters, Pageable pageSpec) { + Objects.requireNonNull(filters); + Objects.requireNonNull(pageSpec); + return findRecords(filters, pageSpec, PatientRecord.class); + } + + private Page findRecords(RecordFilterParams filters, Pageable pageSpec, Class resultClass) { + final Map queryParams = new HashMap<>(); + final String whereClause = constructWhereClause(filters, queryParams); + final String queryString = "SELECT ?r WHERE " + whereClause + resolveOrderBy(pageSpec.getSortOr(RecordSort.defaultSort())); + final TypedQuery query = em.createNativeQuery(queryString, resultClass); + setQueryParameters(query, queryParams); + if (pageSpec.isPaged()) { + query.setFirstResult((int) pageSpec.getOffset()); + query.setMaxResults(pageSpec.getPageSize()); + } + final List records = query.getResultList(); + final TypedQuery countQuery = em.createNativeQuery("SELECT (COUNT(?r) as ?cnt) WHERE " + whereClause, Integer.class); + setQueryParameters(countQuery, queryParams); + final Integer totalCount = countQuery.getSingleResult(); + return new PageImpl<>(records, pageSpec, totalCount); + } + + private void setQueryParameters(TypedQuery query, Map queryParams) { + query.setParameter("type", typeUri) + .setParameter("hasPhase", URI.create(Vocabulary.s_p_has_phase)) + .setParameter("hasInstitution", + URI.create(Vocabulary.s_p_was_treated_at)) + .setParameter("hasKey", URI.create(Vocabulary.s_p_key)) + .setParameter("hasCreatedDate", URI.create(Vocabulary.s_p_created)) + .setParameter("hasLastModified", URI.create(Vocabulary.s_p_modified)); + queryParams.forEach(query::setParameter); + } + + private static String constructWhereClause(RecordFilterParams filters, Map queryParams) { // Could not use Criteria API because it does not support OPTIONAL - String queryString = "SELECT ?r WHERE {" + + String whereClause = "{" + "?r a ?type ; " + "?hasCreatedDate ?created ; " + "?hasInstitution ?institution . " + "?institution ?hasKey ?institutionKey ." + "OPTIONAL { ?r ?hasPhase ?phase . } " + "OPTIONAL { ?r ?hasLastModified ?lastModified . } " + - "BIND (IF (BOUND(?lastModified), ?lastModified, ?created) AS ?edited) "; - final Map queryParams = new HashMap<>(); - queryString += mapParamsToQuery(filterParams, queryParams); - queryString += "} ORDER BY ?edited"; - - final TypedQuery query = em.createNativeQuery(queryString, PatientRecord.class) - .setParameter("type", typeUri) - .setParameter("hasPhase", URI.create(Vocabulary.s_p_has_phase)) - .setParameter("hasInstitution", - URI.create(Vocabulary.s_p_was_treated_at)) - .setParameter("hasKey", URI.create(Vocabulary.s_p_key)) - .setParameter("hasCreatedDate", URI.create(Vocabulary.s_p_created)) - .setParameter("hasLastModified", URI.create(Vocabulary.s_p_modified)); - queryParams.forEach(query::setParameter); - return query.getResultList(); + "BIND (COALESCE(?lastModified, ?created) AS ?date) "; + whereClause += mapParamsToQuery(filters, queryParams); + whereClause += "}"; + return whereClause; } private static String mapParamsToQuery(RecordFilterParams filterParams, Map queryParams) { @@ -218,11 +268,11 @@ private static String mapParamsToQuery(RecordFilterParams filterParams, Map queryParams.put("institutionKey", new LangString(key, Constants.PU_LANGUAGE))); filterParams.getMinModifiedDate().ifPresent(date -> { - filters.add("FILTER (?edited >= ?minDate)"); + filters.add("FILTER (?date >= ?minDate)"); queryParams.put("minDate", date.atStartOfDay(ZoneOffset.UTC).toInstant()); }); filterParams.getMaxModifiedDate().ifPresent(date -> { - filters.add("FILTER (?edited < ?maxDate)"); + filters.add("FILTER (?date < ?maxDate)"); queryParams.put("maxDate", date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()); }); if (!filterParams.getPhaseIds().isEmpty()) { @@ -232,4 +282,20 @@ private static String mapParamsToQuery(RecordFilterParams filterParams, Map phaseIds) { diff --git a/src/main/java/cz/cvut/kbss/study/persistence/dao/util/RecordSort.java b/src/main/java/cz/cvut/kbss/study/persistence/dao/util/RecordSort.java new file mode 100644 index 00000000..aa2e0f8c --- /dev/null +++ b/src/main/java/cz/cvut/kbss/study/persistence/dao/util/RecordSort.java @@ -0,0 +1,36 @@ +package cz.cvut.kbss.study.persistence.dao.util; + +import org.springframework.data.domain.Sort; + +import java.util.Set; + +/** + * Provides constants for sorting records. + */ +public class RecordSort { + + /** + * Property used to sort records by date of last modification (if available) or creation. + */ + public static final String SORT_DATE_PROPERTY = "date"; + + /** + * Supported sorting properties. + */ + public static final Set SORTING_PROPERTIES = Set.of(SORT_DATE_PROPERTY); + + private RecordSort() { + throw new AssertionError(); + } + + /** + * Returns the default sort for retrieving records. + *

+ * By default, records are sorted by date of last modification/creation in descending order. + * + * @return Default sort + */ + public static Sort defaultSort() { + return Sort.by(Sort.Order.desc("date")); + } +} diff --git a/src/main/java/cz/cvut/kbss/study/rest/ActionHistoryController.java b/src/main/java/cz/cvut/kbss/study/rest/ActionHistoryController.java index af56bfb8..026d7f92 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/ActionHistoryController.java +++ b/src/main/java/cz/cvut/kbss/study/rest/ActionHistoryController.java @@ -3,14 +3,27 @@ import cz.cvut.kbss.study.exception.NotFoundException; import cz.cvut.kbss.study.model.ActionHistory; import cz.cvut.kbss.study.model.User; +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; +import cz.cvut.kbss.study.rest.util.RestUtils; import cz.cvut.kbss.study.security.SecurityConstants; import cz.cvut.kbss.study.service.ActionHistoryService; import cz.cvut.kbss.study.service.UserService; -import org.springframework.beans.factory.annotation.Autowired; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; import java.util.Collections; import java.util.List; @@ -23,10 +36,13 @@ public class ActionHistoryController extends BaseController { private final UserService userService; + private final ApplicationEventPublisher eventPublisher; + public ActionHistoryController(ActionHistoryService actionHistoryService, - UserService userService) { + UserService userService, ApplicationEventPublisher eventPublisher) { this.actionHistoryService = actionHistoryService; this.userService = userService; + this.eventPublisher = eventPublisher; } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) @@ -42,7 +58,8 @@ public void create(@RequestBody ActionHistory actionHistory) { @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public List getActions(@RequestParam(value = "author", required = false) String authorUsername, @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "page") int pageNumber) { + @RequestParam MultiValueMap params, + UriComponentsBuilder uriBuilder, HttpServletResponse response) { User author = null; if (authorUsername != null) { try { @@ -51,7 +68,9 @@ public List getActions(@RequestParam(value = "author", required = return Collections.emptyList(); } } - return actionHistoryService.findAllWithParams(type, author, pageNumber); + final Page result = actionHistoryService.findAllWithParams(type, author, RestUtils.resolvePaging(params)); + eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result)); + return result.getContent(); } @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')") diff --git a/src/main/java/cz/cvut/kbss/study/rest/InstitutionController.java b/src/main/java/cz/cvut/kbss/study/rest/InstitutionController.java index 2ac7fc0f..d96e4bae 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/InstitutionController.java +++ b/src/main/java/cz/cvut/kbss/study/rest/InstitutionController.java @@ -8,17 +8,27 @@ import cz.cvut.kbss.study.security.SecurityConstants; import cz.cvut.kbss.study.service.InstitutionService; import cz.cvut.kbss.study.service.PatientRecordService; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; +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; -import java.util.Collections; +import java.util.Comparator; import java.util.List; +import static cz.cvut.kbss.study.rest.util.RecordFilterMapper.constructRecordFilter; + @RestController @PreAuthorize("hasRole('" + SecurityConstants.ROLE_USER + "')") @RequestMapping("/institutions") @@ -38,7 +48,7 @@ public InstitutionController(InstitutionService institutionService, @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public List getAllInstitutions() { final List institutions = institutionService.findAll(); - Collections.sort(institutions, (a, b) -> a.getName().compareTo(b.getName())); + institutions.sort(Comparator.comparing(Institution::getName)); return institutions; } @@ -60,8 +70,9 @@ private Institution findInternal(String key) { @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isRecordInUsersInstitution(#key)") @GetMapping(value = "/{key}/patients", produces = MediaType.APPLICATION_JSON_VALUE) public List getTreatedPatientRecords(@PathVariable("key") String key) { - final Institution institution = findInternal(key); - return recordService.findByInstitution(institution); + final Institution inst = findInternal(key); + assert inst != null; + return recordService.findAll(constructRecordFilter("institution", key), Pageable.unpaged()).getContent(); } @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')") @@ -86,6 +97,7 @@ public void updateInstitution(@PathVariable("key") String key, @RequestBody Inst } final Institution original = findInternal(key); assert original != null; + institutionService.update(institution); if (LOG.isTraceEnabled()) { LOG.trace("Institution {} successfully updated.", institution); diff --git a/src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java b/src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java index fa0c4bbe..5bc4d9ff 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java +++ b/src/main/java/cz/cvut/kbss/study/rest/PatientRecordController.java @@ -3,15 +3,17 @@ import cz.cvut.kbss.study.dto.PatientRecordDto; import cz.cvut.kbss.study.dto.RecordImportResult; import cz.cvut.kbss.study.exception.NotFoundException; -import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.PatientRecord; import cz.cvut.kbss.study.model.RecordPhase; +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; import cz.cvut.kbss.study.rest.exception.BadRequestException; import cz.cvut.kbss.study.rest.util.RecordFilterMapper; import cz.cvut.kbss.study.rest.util.RestUtils; import cz.cvut.kbss.study.security.SecurityConstants; -import cz.cvut.kbss.study.service.InstitutionService; import cz.cvut.kbss.study.service.PatientRecordService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; import java.util.List; @@ -38,36 +41,36 @@ public class PatientRecordController extends BaseController { private final PatientRecordService recordService; - private final InstitutionService institutionService; + private final ApplicationEventPublisher eventPublisher; - public PatientRecordController(PatientRecordService recordService, InstitutionService institutionService) { + public PatientRecordController(PatientRecordService recordService, ApplicationEventPublisher eventPublisher) { this.recordService = recordService; - this.institutionService = institutionService; + this.eventPublisher = eventPublisher; } @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)") @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public List getRecords( - @RequestParam(value = "institution", required = false) String institutionKey) { - return institutionKey != null ? recordService.findByInstitution(getInstitution(institutionKey)) : - recordService.findAllRecords(); - } - - private Institution getInstitution(String institutionKey) { - final Institution institution = institutionService.findByKey(institutionKey); - if (institution == null) { - throw NotFoundException.create("Institution", institutionKey); - } - return institution; + @RequestParam(value = "institution", required = false) String institutionKey, + @RequestParam MultiValueMap params, + UriComponentsBuilder uriBuilder, HttpServletResponse response) { + final Page result = recordService.findAll(RecordFilterMapper.constructRecordFilter(params), + RestUtils.resolvePaging(params)); + eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result)); + return result.getContent(); } @PreAuthorize( "hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isMemberOfInstitution(#institutionKey)") @GetMapping(value = "/export", produces = MediaType.APPLICATION_JSON_VALUE) public List exportRecords( - @RequestParam(name = "institutionKey", required = false) String institutionKey, - @RequestParam MultiValueMap params) { - return recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params)); + @RequestParam(name = "institution", required = false) String institutionKey, + @RequestParam MultiValueMap params, + UriComponentsBuilder uriBuilder, HttpServletResponse response) { + final Page result = recordService.findAllFull(RecordFilterMapper.constructRecordFilter(params), + RestUtils.resolvePaging(params)); + eventPublisher.publishEvent(new PaginatedResultRetrievedEvent(this, uriBuilder, response, result)); + return result.getContent(); } @PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "') or @securityUtils.isRecordInUsersInstitution(#key)") @@ -98,10 +101,10 @@ public ResponseEntity createRecord(@RequestBody PatientRecord record) { @PostMapping(value = "/import", consumes = MediaType.APPLICATION_JSON_VALUE) public RecordImportResult importRecords(@RequestBody List records, - @RequestParam(name = "phase", required = false) String phaseIri) { + @RequestParam(name = "phase", required = false) String phase) { final RecordImportResult importResult; - if (phaseIri != null) { - final RecordPhase targetPhase = RecordPhase.fromString(phaseIri); + if (phase != null) { + final RecordPhase targetPhase = RecordPhase.fromIriOrName(phase); importResult = recordService.importRecords(records, targetPhase); } else { importResult = recordService.importRecords(records); diff --git a/src/main/java/cz/cvut/kbss/study/rest/StatisticsController.java b/src/main/java/cz/cvut/kbss/study/rest/StatisticsController.java index 86fcf065..ca1b70d5 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/StatisticsController.java +++ b/src/main/java/cz/cvut/kbss/study/rest/StatisticsController.java @@ -2,7 +2,6 @@ import cz.cvut.kbss.study.security.SecurityConstants; import cz.cvut.kbss.study.service.StatisticsService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/cz/cvut/kbss/study/rest/event/PaginatedResultRetrievedEvent.java b/src/main/java/cz/cvut/kbss/study/rest/event/PaginatedResultRetrievedEvent.java new file mode 100644 index 00000000..8311c834 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/study/rest/event/PaginatedResultRetrievedEvent.java @@ -0,0 +1,37 @@ +package cz.cvut.kbss.study.rest.event; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.ApplicationEvent; +import org.springframework.data.domain.Page; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Fired when a paginated result is retrieved by a REST controller, so that HATEOAS headers can be added to the + * response. + */ +public class PaginatedResultRetrievedEvent extends ApplicationEvent { + + private final UriComponentsBuilder uriBuilder; + private final HttpServletResponse response; + private final Page page; + + public PaginatedResultRetrievedEvent(Object source, UriComponentsBuilder uriBuilder, HttpServletResponse response, + Page page) { + super(source); + this.uriBuilder = uriBuilder; + this.response = response; + this.page = page; + } + + public UriComponentsBuilder getUriBuilder() { + return uriBuilder; + } + + public HttpServletResponse getResponse() { + return response; + } + + public Page getPage() { + return page; + } +} diff --git a/src/main/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListener.java b/src/main/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListener.java new file mode 100644 index 00000000..7dd87f15 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListener.java @@ -0,0 +1,84 @@ +package cz.cvut.kbss.study.rest.handler; + +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; +import cz.cvut.kbss.study.rest.util.HttpPaginationLink; +import cz.cvut.kbss.study.util.Constants; +import org.springframework.context.ApplicationListener; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Generates HATEOAS paging headers based on the paginated result retrieved by a REST controller. + */ +@Component +public class HateoasPagingListener implements ApplicationListener { + + @Override + public void onApplicationEvent(PaginatedResultRetrievedEvent event) { + final Page page = event.getPage(); + final LinkHeader header = new LinkHeader(); + if (!page.isEmpty() || page.getTotalPages() > 0) { + // Always add first and last links, even when there is just one page. This allows clients to know where the limits + // are + header.addLink(generateFirstPageLink(page, event.getUriBuilder()), HttpPaginationLink.FIRST); + header.addLink(generateLastPageLink(page, event.getUriBuilder()), HttpPaginationLink.LAST); + } + if (page.hasNext()) { + header.addLink(generateNextPageLink(page, event.getUriBuilder()), HttpPaginationLink.NEXT); + } + if (page.hasPrevious()) { + header.addLink(generatePreviousPageLink(page, event.getUriBuilder()), HttpPaginationLink.PREVIOUS); + } + if (header.hasLinks()) { + event.getResponse().addHeader(HttpHeaders.LINK, header.toString()); + } + } + + private String generateNextPageLink(Page page, UriComponentsBuilder uriBuilder) { + return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getNumber() + 1) + .replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize()) + .build().encode().toUriString(); + } + + private String generatePreviousPageLink(Page page, UriComponentsBuilder uriBuilder) { + return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getNumber() - 1) + .replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize()) + .build().encode().toUriString(); + } + + private String generateFirstPageLink(Page page, UriComponentsBuilder uriBuilder) { + return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, 0) + .replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize()) + .build().encode().toUriString(); + } + + private String generateLastPageLink(Page page, UriComponentsBuilder uriBuilder) { + return uriBuilder.replaceQueryParam(Constants.PAGE_PARAM, page.getTotalPages() - 1) + .replaceQueryParam(Constants.PAGE_SIZE_PARAM, page.getSize()) + .build().encode().toUriString(); + } + + private static class LinkHeader { + + private final StringBuilder linkBuilder = new StringBuilder(); + + private void addLink(String url, HttpPaginationLink type) { + if (!linkBuilder.isEmpty()) { + linkBuilder.append(", "); + } + linkBuilder.append('<').append(url).append('>').append("; ").append("rel=\"").append(type.getName()) + .append('"'); + } + + private boolean hasLinks() { + return !linkBuilder.isEmpty(); + } + + @Override + public String toString() { + return linkBuilder.toString(); + } + } +} diff --git a/src/main/java/cz/cvut/kbss/study/rest/util/HttpPaginationLink.java b/src/main/java/cz/cvut/kbss/study/rest/util/HttpPaginationLink.java new file mode 100644 index 00000000..abdd1a95 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/study/rest/util/HttpPaginationLink.java @@ -0,0 +1,18 @@ +package cz.cvut.kbss.study.rest.util; + +/** + * Types of HTTP pagination links. + */ +public enum HttpPaginationLink { + NEXT("next"), PREVIOUS("prev"), FIRST("first"), LAST("last"); + + private final String name; + + HttpPaginationLink(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/cz/cvut/kbss/study/rest/util/RecordFilterMapper.java b/src/main/java/cz/cvut/kbss/study/rest/util/RecordFilterMapper.java index f6c0abb9..d565f059 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/util/RecordFilterMapper.java +++ b/src/main/java/cz/cvut/kbss/study/rest/util/RecordFilterMapper.java @@ -1,18 +1,21 @@ package cz.cvut.kbss.study.rest.util; +import cz.cvut.kbss.study.model.RecordPhase; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; import cz.cvut.kbss.study.rest.exception.BadRequestException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.util.Collections; -import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * Maps query parameters to {@link RecordFilterParams} instances. @@ -29,11 +32,22 @@ public class RecordFilterMapper { private static final String PHASE_ID_PARAM = "phase"; + /** + * Maps the specified single parameter and value to a new {@link RecordFilterParams} instance. + * + * @param param Parameter name + * @param value Parameter value + * @return New {@code RecordFilterParams} instance + */ + public static RecordFilterParams constructRecordFilter(String param, String value) { + return constructRecordFilter(new LinkedMultiValueMap<>(Map.of(param, List.of(value)))); + } + /** * Maps the specified parameters to a new {@link RecordFilterParams} instance. * * @param params Request parameters to map - * @return New {@code RecordFilter} instance + * @return New {@code RecordFilterParams} instance */ public static RecordFilterParams constructRecordFilter(MultiValueMap params) { Objects.requireNonNull(params); @@ -53,7 +67,8 @@ public static RecordFilterParams constructRecordFilter(MultiValueMap(params.getOrDefault(PHASE_ID_PARAM, Collections.emptyList()))); + result.setPhaseIds(params.getOrDefault(PHASE_ID_PARAM, Collections.emptyList()).stream() + .map(s -> RecordPhase.fromIriOrName(s).getIri()).collect(Collectors.toSet())); return result; } diff --git a/src/main/java/cz/cvut/kbss/study/rest/util/RestUtils.java b/src/main/java/cz/cvut/kbss/study/rest/util/RestUtils.java index 0ebc2892..6848f18c 100644 --- a/src/main/java/cz/cvut/kbss/study/rest/util/RestUtils.java +++ b/src/main/java/cz/cvut/kbss/study/rest/util/RestUtils.java @@ -1,9 +1,14 @@ package cz.cvut.kbss.study.rest.util; +import cz.cvut.kbss.study.util.Constants; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.util.MultiValueMap; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -12,9 +17,21 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.util.Optional; +import java.util.stream.Collectors; public class RestUtils { + /** + * Prefix indicating ascending sort order. + */ + public static final char SORT_ASC = '+'; + + /** + * Prefix indicating descending sort order. + */ + public static final char SORT_DESC = '-'; + private RestUtils() { throw new AssertionError(); } @@ -94,8 +111,39 @@ public static LocalDate parseDate(String dateStr) { try { return LocalDate.parse(dateStr); } catch (DateTimeParseException | NullPointerException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Value '" + dateStr + "' is not a valid date in ISO format."); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Value '" + dateStr + "' is not a valid date in ISO format."); } } + /** + * Resolves paging and sorting configuration from the specified request parameters. + *

+ * If no paging and filtering info is specified, an {@link Pageable#unpaged()} object is returned. + *

+ * Note that for sorting, {@literal +} should be used before sorting property name to specify ascending order, + * {@literal -} for descending order, for example, {@literal -date} indicates sorting by date in descending order. + * + * @param params Request parameters + * @return {@code Pageable} containing values resolved from the params or defaults + */ + public static Pageable resolvePaging(MultiValueMap params) { + if (params.getFirst(Constants.PAGE_PARAM) == null) { + return Pageable.unpaged(); + } + final int page = Integer.parseInt(params.getFirst(Constants.PAGE_PARAM)); + final int size = Optional.ofNullable(params.getFirst(Constants.PAGE_SIZE_PARAM)).map(Integer::parseInt) + .orElse(Constants.DEFAULT_PAGE_SIZE); + if (params.containsKey(Constants.SORT_PARAM)) { + final Sort sort = Sort.by(params.get(Constants.SORT_PARAM).stream().map(sp -> { + if (sp.charAt(0) == SORT_ASC || sp.charAt(0) == SORT_DESC) { + final String property = sp.substring(1); + return sp.charAt(0) == SORT_DESC ? Sort.Order.desc(property) : Sort.Order.asc(property); + } + return Sort.Order.asc(sp); + }).collect(Collectors.toList())); + return PageRequest.of(page, size, sort); + } + return PageRequest.of(page, size); + } } diff --git a/src/main/java/cz/cvut/kbss/study/service/ActionHistoryService.java b/src/main/java/cz/cvut/kbss/study/service/ActionHistoryService.java index 90f360a8..5bd83524 100644 --- a/src/main/java/cz/cvut/kbss/study/service/ActionHistoryService.java +++ b/src/main/java/cz/cvut/kbss/study/service/ActionHistoryService.java @@ -2,12 +2,12 @@ import cz.cvut.kbss.study.model.ActionHistory; import cz.cvut.kbss.study.model.User; - -import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface ActionHistoryService extends BaseService { ActionHistory findByKey(String key); - List findAllWithParams(String type, User author, int pageNumber); + Page findAllWithParams(String type, User author, Pageable pageSpec); } diff --git a/src/main/java/cz/cvut/kbss/study/service/PatientRecordService.java b/src/main/java/cz/cvut/kbss/study/service/PatientRecordService.java index 6e57840f..91322af1 100644 --- a/src/main/java/cz/cvut/kbss/study/service/PatientRecordService.java +++ b/src/main/java/cz/cvut/kbss/study/service/PatientRecordService.java @@ -2,11 +2,11 @@ import cz.cvut.kbss.study.dto.PatientRecordDto; import cz.cvut.kbss.study.dto.RecordImportResult; -import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.PatientRecord; import cz.cvut.kbss.study.model.RecordPhase; -import cz.cvut.kbss.study.model.User; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; @@ -21,38 +21,24 @@ public interface PatientRecordService extends BaseService { PatientRecord findByKey(String key); /** - * Gets records of patients treated at the specified institution. + * Gets records corresponding to the specified filtering, paging, and sorting criteria. * - * @param institution The institution to filter by - * @return Records of matching patients + * @param filters Record filtering criteria + * @param pageSpec Specification of page and sorting to retrieve + * @return List of matching record DTOs + * @see #findAllFull(RecordFilterParams, Pageable) */ - List findByInstitution(Institution institution); + Page findAll(RecordFilterParams filters, Pageable pageSpec); /** - * Gets records of patients created by specified author. + * Gets records corresponding to the specified filtering, paging, and sorting criteria. * - * @param author The author to filter by - * @return Records of matching patients - */ - List findByAuthor(User author); - - /** - * Gets records of all patients. - * - * @return Records of matching patients - */ - List findAllRecords(); - - /** - * Finds all records that match the specified parameters. - *

- * In contrast to {@link #findAll()}, this method returns full records, not DTOs. - * - * @param filterParams Record filtering criteria + * @param filters Record filtering criteria + * @param pageSpec Specification of page and sorting to retrieve * @return List of matching records - * @see #findAllRecords() + * @see #findAll(RecordFilterParams, Pageable) */ - List findAllFull(RecordFilterParams filterParams); + Page findAllFull(RecordFilterParams filters, Pageable pageSpec); /** * Imports the specified records. @@ -64,8 +50,8 @@ public interface PatientRecordService extends BaseService { * records. *

* If the current user is an admin, the import procedure retains provenance data of the record. Otherwise, the - * current user is set as the record's author. Also, if the current user is not an admin, the phase of all - * the imported records is set to {@link RecordPhase#open}, for admin, the phase of the records is retained. + * current user is set as the record's author. Also, if the current user is not an admin, the phase of all the + * imported records is set to {@link RecordPhase#open}, for admin, the phase of the records is retained. * * @param records Records to import * @return Instance representing the import result diff --git a/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryActionHistoryService.java b/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryActionHistoryService.java index 82522b0b..bbde2b61 100644 --- a/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryActionHistoryService.java +++ b/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryActionHistoryService.java @@ -5,11 +5,11 @@ import cz.cvut.kbss.study.persistence.dao.ActionHistoryDao; import cz.cvut.kbss.study.persistence.dao.GenericDao; import cz.cvut.kbss.study.service.ActionHistoryService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service public class RepositoryActionHistoryService extends BaseRepositoryService implements ActionHistoryService { @@ -32,7 +32,7 @@ public ActionHistory findByKey(String key) { @Transactional(readOnly = true) @Override - public List findAllWithParams(String type, User author, int pageNumber) { - return actionHistoryDao.findAllWithParams(type, author, pageNumber); + public Page findAllWithParams(String type, User author, Pageable pageSpec) { + return actionHistoryDao.findAllWithParams(type, author, pageSpec); } } diff --git a/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryPatientRecordService.java b/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryPatientRecordService.java index cc20e7ba..8aaf2c9c 100644 --- a/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryPatientRecordService.java +++ b/src/main/java/cz/cvut/kbss/study/service/repository/RepositoryPatientRecordService.java @@ -3,7 +3,6 @@ import cz.cvut.kbss.study.dto.PatientRecordDto; import cz.cvut.kbss.study.dto.RecordImportResult; import cz.cvut.kbss.study.exception.RecordAuthorNotFoundException; -import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.PatientRecord; import cz.cvut.kbss.study.model.RecordPhase; import cz.cvut.kbss.study.model.User; @@ -16,6 +15,8 @@ import cz.cvut.kbss.study.util.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,26 +51,14 @@ protected OwlKeySupportingDao getPrimaryDao() { @Transactional(readOnly = true) @Override - public List findByInstitution(Institution institution) { - return recordDao.findByInstitution(institution); + public Page findAll(RecordFilterParams filters, Pageable pageSpec) { + return recordDao.findAllRecords(filters, pageSpec); } @Transactional(readOnly = true) @Override - public List findByAuthor(User user) { - return recordDao.findByAuthor(user); - } - - @Transactional(readOnly = true) - @Override - public List findAllRecords() { - return recordDao.findAllRecords(); - } - - @Transactional(readOnly = true) - @Override - public List findAllFull(RecordFilterParams filterParams) { - return recordDao.findAllFull(filterParams); + public Page findAllFull(RecordFilterParams filters, Pageable pageSpec) { + return recordDao.findAllRecordsFull(filters, pageSpec); } @Override diff --git a/src/main/java/cz/cvut/kbss/study/util/Constants.java b/src/main/java/cz/cvut/kbss/study/util/Constants.java index 0a22c590..e49f7f38 100644 --- a/src/main/java/cz/cvut/kbss/study/util/Constants.java +++ b/src/main/java/cz/cvut/kbss/study/util/Constants.java @@ -28,7 +28,7 @@ private Constants() { /** * Number of history actions fetched from database. Needs to be changes also in front-end. */ - public static final int ACTIONS_PER_PAGE = 25; + public static final int DEFAULT_PAGE_SIZE = 25; /** * Path to directory containing queries used by the system. @@ -37,4 +37,19 @@ private Constants() { * ClassLoader#getResourceAsStream(String)}. */ public static final String QUERY_DIRECTORY = "query"; + + /** + * Name of the request parameter specifying page number. + */ + public static final String PAGE_PARAM = "page"; + + /** + * Name of the request parameter specifying page size. + */ + public static final String PAGE_SIZE_PARAM = "size"; + + /** + * Name of the request parameter specifying sorting. + */ + public static final String SORT_PARAM = "sort"; } diff --git a/src/test/java/cz/cvut/kbss/study/environment/generator/Generator.java b/src/test/java/cz/cvut/kbss/study/environment/generator/Generator.java index 2a8b5693..93f8a21c 100644 --- a/src/test/java/cz/cvut/kbss/study/environment/generator/Generator.java +++ b/src/test/java/cz/cvut/kbss/study/environment/generator/Generator.java @@ -26,7 +26,7 @@ private Generator() { * @return Random URI */ public static URI generateUri() { - return URI.create(Vocabulary.ONTOLOGY_IRI_record_manager + "/randomInstance" + randomInt()); + return URI.create(Vocabulary.ONTOLOGY_IRI_RECORD_MANAGER + "/randomInstance" + randomInt()); } /** @@ -183,7 +183,7 @@ public static PatientRecord generatePatientRecord(User author) { public static PatientRecordDto generatePatientRecordDto(User author) { final PatientRecordDto rec = new PatientRecordDto(); rec.setLocalName("RandomRecordDto" + randomInt()); - rec.setAuthor(author); + rec.setAuthor(author.getUri()); rec.setUri(generateUri()); rec.setInstitution(author.getInstitution()); return rec; diff --git a/src/test/java/cz/cvut/kbss/study/model/RecordPhaseTest.java b/src/test/java/cz/cvut/kbss/study/model/RecordPhaseTest.java index 59ad29af..ce3d204a 100644 --- a/src/test/java/cz/cvut/kbss/study/model/RecordPhaseTest.java +++ b/src/test/java/cz/cvut/kbss/study/model/RecordPhaseTest.java @@ -8,19 +8,51 @@ class RecordPhaseTest { @Test - void fromStringReturnsMatchingRecordPhase() { + void fromIriReturnsMatchingRecordPhase() { for (RecordPhase p : RecordPhase.values()) { - assertEquals(p, RecordPhase.fromString(p.getIri())); + assertEquals(p, RecordPhase.fromIri(p.getIri())); } } @Test - void fromStringThrowsIllegalArgumentForUnknownPhaseIri() { - assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromString(Generator.generateUri().toString())); + void fromIriThrowsIllegalArgumentForUnknownPhaseIri() { + assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromIri(Generator.generateUri().toString())); } @Test - void fromStringThrowsIllegalArgumentForNullArgument() { - assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromString(null)); + void fromIriThrowsIllegalArgumentForNullArgument() { + assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromIri(null)); + } + + @Test + void fromNameReturnsMatchingRecordPhase() { + for (RecordPhase p : RecordPhase.values()) { + assertEquals(p, RecordPhase.fromName(p.name())); + } + } + + @Test + void fromNameMatchesIgnoringCase() { + for (RecordPhase p : RecordPhase.values()) { + assertEquals(p, RecordPhase.fromName(p.name().toUpperCase())); + } + } + + @Test + void fromNameThrowsIllegalArgumentForUnknownPhaseIri() { + assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromName("unknown")); + } + + @Test + void fromNameThrowsIllegalArgumentForNullArgument() { + assertThrows(IllegalArgumentException.class, () -> RecordPhase.fromName(null)); + } + + @Test + void fromNameOrIriMatchesPhaseByIriAndName() { + for (RecordPhase p : RecordPhase.values()) { + assertEquals(p, RecordPhase.fromIriOrName(p.getIri())); + assertEquals(p, RecordPhase.fromIriOrName(p.name())); + } } } \ No newline at end of file diff --git a/src/test/java/cz/cvut/kbss/study/model/UserTest.java b/src/test/java/cz/cvut/kbss/study/model/UserTest.java index 759cfce6..3b384e27 100644 --- a/src/test/java/cz/cvut/kbss/study/model/UserTest.java +++ b/src/test/java/cz/cvut/kbss/study/model/UserTest.java @@ -82,7 +82,7 @@ public void generateUriThrowsIllegalStateForEmptyLastName() { @Test public void generateUriDoesNothingIfTheUriIsAlreadySet() { - final String uri = Vocabulary.ONTOLOGY_IRI_record_manager + "/test"; + final String uri = Vocabulary.ONTOLOGY_IRI_RECORD_MANAGER + "/test"; user.setUri(URI.create(uri)); user.generateUri(); assertEquals(uri, user.getUri().toString()); diff --git a/src/test/java/cz/cvut/kbss/study/model/qam/AnswerTest.java b/src/test/java/cz/cvut/kbss/study/model/qam/AnswerTest.java index fff4d364..8659c130 100644 --- a/src/test/java/cz/cvut/kbss/study/model/qam/AnswerTest.java +++ b/src/test/java/cz/cvut/kbss/study/model/qam/AnswerTest.java @@ -14,7 +14,7 @@ public void copyConstructorsCopiesValuesAndTypesNoUri() { final Answer a = new Answer(); a.setTextValue("Cough"); a.setCodeValue(Generator.generateUri()); - a.getTypes().add(Vocabulary.ONTOLOGY_IRI_record_manager + "/infectious-disease/"); + a.getTypes().add(Vocabulary.ONTOLOGY_IRI_RECORD_MANAGER + "/infectious-disease/"); final Answer res = new Answer(a); assertNull(res.getUri()); diff --git a/src/test/java/cz/cvut/kbss/study/model/qam/QuestionTest.java b/src/test/java/cz/cvut/kbss/study/model/qam/QuestionTest.java index 7980c937..3059e75a 100644 --- a/src/test/java/cz/cvut/kbss/study/model/qam/QuestionTest.java +++ b/src/test/java/cz/cvut/kbss/study/model/qam/QuestionTest.java @@ -13,7 +13,7 @@ public class QuestionTest { public void copyConstructorCopiesSubQuestions() { final Question q = new Question(); q.setUri(Generator.generateUri()); - q.getTypes().add(Vocabulary.ONTOLOGY_IRI_record_manager + "/infectious-disease/"); + q.getTypes().add(Vocabulary.ONTOLOGY_IRI_RECORD_MANAGER + "/infectious-disease/"); for (int i = 0; i < Generator.randomInt(10); i++) { final Question child = new Question(); child.setUri(Generator.generateUri()); diff --git a/src/test/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDaoTest.java b/src/test/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDaoTest.java index db39b0e7..a046a3cb 100644 --- a/src/test/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDaoTest.java +++ b/src/test/java/cz/cvut/kbss/study/persistence/dao/ActionHistoryDaoTest.java @@ -5,10 +5,14 @@ import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.User; import cz.cvut.kbss.study.persistence.BaseDaoTestRunner; +import cz.cvut.kbss.study.util.Constants; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import java.util.List; +import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -24,9 +28,9 @@ public class ActionHistoryDaoTest extends BaseDaoTestRunner { @Autowired ActionHistoryDao actionHistoryDao; - private final String LOAD_SUCCESS = "LOAD_SUCCESS"; - private final String LOAD_ERROR = "LOAD_ERROR"; - private final String LOAD_PENDING = "LOAD_PENDING"; + private static final String LOAD_SUCCESS = "LOAD_SUCCESS"; + private static final String LOAD_ERROR = "LOAD_ERROR"; + private static final String LOAD_PENDING = "LOAD_PENDING"; @Test public void findByKeyReturnsActionWithPayload() { @@ -61,9 +65,9 @@ public void findAllWithParamsWithoutParamsReturnsAllActions() { actionHistoryDao.persist(List.of(action1, action2, action3)); }); - List actionsList = actionHistoryDao.findAllWithParams(null, null, 1); + Page actionsList = actionHistoryDao.findAllWithParams(null, null, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); - assertEquals(3, actionsList.size()); + assertEquals(3, actionsList.getNumberOfElements()); } @Test @@ -82,13 +86,13 @@ public void findAllWithParamsWithAuthorReturnsAuthorsActions() { actionHistoryDao.persist(List.of(action1, action2, action3)); }); - List actionsList1 = actionHistoryDao.findAllWithParams(null, user1, 1); - List actionsList2 = actionHistoryDao.findAllWithParams(null, user2, 1); - List actionsList3 = actionHistoryDao.findAllWithParams(null, user3, 1); + Page actionsList1 = actionHistoryDao.findAllWithParams(null, user1, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList2 = actionHistoryDao.findAllWithParams(null, user2, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList3 = actionHistoryDao.findAllWithParams(null, user3, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); - assertEquals(2, actionsList1.size()); - assertEquals(1, actionsList2.size()); - assertEquals(0, actionsList3.size()); + assertEquals(2, actionsList1.getNumberOfElements()); + assertEquals(1, actionsList2.getNumberOfElements()); + assertEquals(0, actionsList3.getNumberOfElements()); } @Test @@ -108,13 +112,13 @@ public void findAllWithParamsWithTypeReturnsActionsWithExactType() { actionHistoryDao.persist(List.of(action1, action2, action3)); }); - List actionsList1 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, null, 1); - List actionsList2 = actionHistoryDao.findAllWithParams(LOAD_ERROR, null, 1); - List actionsList3 = actionHistoryDao.findAllWithParams(LOAD_PENDING, null, 1); + Page actionsList1 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, null, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList2 = actionHistoryDao.findAllWithParams(LOAD_ERROR, null, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList3 = actionHistoryDao.findAllWithParams(LOAD_PENDING, null, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); - assertEquals(2, actionsList1.size()); - assertEquals(1, actionsList2.size()); - assertEquals(0, actionsList3.size()); + assertEquals(2, actionsList1.getNumberOfElements()); + assertEquals(1, actionsList2.getNumberOfElements()); + assertEquals(0, actionsList3.getNumberOfElements()); } @Test @@ -133,9 +137,9 @@ public void findAllWithParamsWithTypeReturnsActionsWithTypeContained() { actionHistoryDao.persist(List.of(action1, action2, action3)); }); - List actionsList = actionHistoryDao.findAllWithParams("LOAD", null, 1); + Page actionsList = actionHistoryDao.findAllWithParams("LOAD", null, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); - assertEquals(2, actionsList.size()); + assertEquals(2, actionsList.getNumberOfElements()); } @Test @@ -156,14 +160,30 @@ public void findAllWithParamsReturnsMatchingActions() { actionHistoryDao.persist(List.of(action1, action2, action3)); }); - List actionsList1 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, user1, 1); - List actionsList2 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, user2, 1); - List actionsList3 = actionHistoryDao.findAllWithParams(LOAD_ERROR, user2, 1); - List actionsList4 = actionHistoryDao.findAllWithParams("LOAD", user2, 1); + Page actionsList1 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, user1, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList2 = actionHistoryDao.findAllWithParams(LOAD_SUCCESS, user2, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList3 = actionHistoryDao.findAllWithParams(LOAD_ERROR, user2, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); + Page actionsList4 = actionHistoryDao.findAllWithParams("LOAD", user2, PageRequest.of(0, Constants.DEFAULT_PAGE_SIZE)); - assertEquals(2, actionsList1.size()); - assertEquals(0, actionsList2.size()); - assertEquals(1, actionsList3.size()); - assertEquals(1, actionsList4.size()); + assertEquals(2, actionsList1.getNumberOfElements()); + assertEquals(0, actionsList2.getNumberOfElements()); + assertEquals(1, actionsList3.getNumberOfElements()); + assertEquals(1, actionsList4.getNumberOfElements()); + } + + @Test + void findAllReturnsActionsOnMatchingPage() { + Institution institution = Generator.generateInstitution(); + User user = Generator.generateUser(institution); + final List allActions = IntStream.range(0, 10).mapToObj(i -> Generator.generateActionHistory(user)).toList(); + transactional(() -> { + institutionDao.persist(institution); + userDao.persist(user); + actionHistoryDao.persist(allActions); + }); + + final PageRequest pageSpec = PageRequest.of(2, allActions.size() / 2); + final Page result = actionHistoryDao.findAllWithParams(null, null, pageSpec); + assertEquals(allActions.subList((int) pageSpec.getOffset(), allActions.size()), result.getContent()); } } diff --git a/src/test/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDaoTest.java b/src/test/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDaoTest.java index 93ff7cb7..dac6c38f 100644 --- a/src/test/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDaoTest.java +++ b/src/test/java/cz/cvut/kbss/study/persistence/dao/PatientRecordDaoTest.java @@ -15,17 +15,24 @@ import cz.cvut.kbss.study.persistence.BaseDaoTestRunner; import cz.cvut.kbss.study.persistence.dao.util.QuestionSaver; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; +import cz.cvut.kbss.study.persistence.dao.util.RecordSort; import cz.cvut.kbss.study.util.IdentificationUtils; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import java.net.URI; import java.time.LocalDate; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.List; +import java.util.ListIterator; import java.util.Set; import java.util.stream.IntStream; @@ -34,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; public class PatientRecordDaoTest extends BaseDaoTestRunner { @@ -255,7 +263,10 @@ private void persistRecordWithIdentification(PatientRecord record) { @Test void findAllFullReturnsRecordsMatchingSpecifiedDatePeriod() { final User author = generateAuthorWithInstitution(); - final List allRecords = generateRecordsForAuthor(author); + final List allRecords = generateRecordsForAuthor(author, 5); + for (int i = 0; i < allRecords.size(); i++) { + allRecords.get(i).setDateCreated(new Date(System.currentTimeMillis() - i * Environment.MILLIS_PER_DAY)); + } transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); final LocalDate minDate = LocalDate.now().minusDays(3); final LocalDate maxDate = LocalDate.now().minusDays(1); @@ -265,23 +276,26 @@ void findAllFullReturnsRecordsMatchingSpecifiedDatePeriod() { return !modifiedDate.isBefore(minDate) && !modifiedDate.isAfter(maxDate); }).toList(); - final List result = - sut.findAllFull(new RecordFilterParams(null, minDate, maxDate, Collections.emptySet())); + final Page result = + sut.findAllRecordsFull(new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), + Pageable.unpaged()); assertFalse(result.isEmpty()); - assertThat(result, containsSameEntities(expected)); + assertThat(result.getContent(), containsSameEntities(expected)); } - private List generateRecordsForAuthor(User author) { - return IntStream.range(0, 5).mapToObj(i -> { - final PatientRecord r = Generator.generatePatientRecord(author); - if (Generator.randomBoolean()) { - r.setDateCreated(new Date(System.currentTimeMillis() - i * Environment.MILLIS_PER_DAY)); - } else { - r.setDateCreated(new Date(System.currentTimeMillis() - 365 * Environment.MILLIS_PER_DAY)); - r.setLastModified(new Date(System.currentTimeMillis() - i * Environment.MILLIS_PER_DAY)); - } - return r; - }).toList(); + private List generateRecordsForAuthor(User author, int count) { + return IntStream.range(0, count).mapToObj(i -> { + final PatientRecord r = Generator.generatePatientRecord(author); + if (Generator.randomBoolean()) { + r.setDateCreated(new Date(System.currentTimeMillis() - 30 * i * Environment.MILLIS_PER_DAY)); + } else { + r.setDateCreated(new Date(System.currentTimeMillis() - 30 * i * Environment.MILLIS_PER_DAY)); + r.setLastModified(new Date(System.currentTimeMillis() - i * Environment.MILLIS_PER_DAY)); + } + return r; + }).sorted(Comparator.comparing( + (PatientRecord r) -> r.getLastModified() != null ? r.getLastModified() : r.getDateCreated()).reversed()) + .toList(); } @Test @@ -289,10 +303,10 @@ void findAllFullReturnsRecordsMatchingSpecifiedDatePeriodAndInstitution() { final User authorOne = generateAuthorWithInstitution(); final Institution institution = authorOne.getInstitution(); final User authorTwo = generateAuthorWithInstitution(); - final List allRecords = new ArrayList<>(generateRecordsForAuthor(authorOne)); - allRecords.addAll(generateRecordsForAuthor(authorTwo)); + final List allRecords = new ArrayList<>(generateRecordsForAuthor(authorOne, 5)); + allRecords.addAll(generateRecordsForAuthor(authorTwo, 5)); transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); - final LocalDate minDate = LocalDate.now().minusDays(3); + final LocalDate minDate = LocalDate.now().minusDays(31); final LocalDate maxDate = LocalDate.now().minusDays(1); final List expected = allRecords.stream().filter(r -> { final Date modified = r.getLastModified() != null ? r.getLastModified() : r.getDateCreated(); @@ -301,16 +315,17 @@ void findAllFullReturnsRecordsMatchingSpecifiedDatePeriodAndInstitution() { .equals(institution.getUri()); }).toList(); - final List result = - sut.findAllFull(new RecordFilterParams(institution.getKey(), minDate, maxDate, Collections.emptySet())); + final Page result = + sut.findAllRecordsFull(new RecordFilterParams(institution.getKey(), minDate, maxDate, Collections.emptySet()), + Pageable.unpaged()); assertFalse(result.isEmpty()); - assertThat(result, containsSameEntities(expected)); + assertThat(result.getContent(), containsSameEntities(expected)); } @Test void findAllFullReturnsRecordsMatchingSpecifiedPhase() { final User author = generateAuthorWithInstitution(); - final List allRecords = generateRecordsForAuthor(author); + final List allRecords = generateRecordsForAuthor(author, 5); transactional(() -> allRecords.forEach(r -> { r.setPhase(RecordPhase.values()[Generator.randomInt(RecordPhase.values().length)]); persistRecordWithIdentification(r); @@ -319,11 +334,89 @@ void findAllFullReturnsRecordsMatchingSpecifiedPhase() { final RecordFilterParams filterParams = new RecordFilterParams(); filterParams.setPhaseIds(Set.of(phase.getIri())); - final List result = sut.findAllFull(filterParams); + final Page result = sut.findAllRecordsFull(filterParams, Pageable.unpaged()); assertFalse(result.isEmpty()); result.forEach(res -> assertEquals(phase, res.getPhase())); } + @Test + void findAllFullReturnsRecordsMatchingSpecifiedPage() { + final User author = generateAuthorWithInstitution(); + final List allRecords = generateRecordsForAuthor(author, 10); + transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); + final int pageSize = 5; + + final Page result = sut.findAllRecordsFull(new RecordFilterParams(), PageRequest.of(1, pageSize)); + assertFalse(result.isEmpty()); + assertEquals(pageSize, result.getNumberOfElements()); + assertThat(result.getContent(), containsSameEntities(allRecords.subList(pageSize, allRecords.size()))); + } + + @Test + void findAllFullReturnsRecordsSortedAccordingToSortSpecification() { + final User author = generateAuthorWithInstitution(); + final List allRecords = generateRecordsForAuthor(author, 5); + transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); + final Page result = + sut.findAllRecordsFull(new RecordFilterParams(), PageRequest.of(0, allRecords.size(), Sort.Direction.ASC, + RecordSort.SORT_DATE_PROPERTY)); + assertFalse(result.isEmpty()); + assertEquals(allRecords.size(), result.getNumberOfElements()); + final ListIterator itExp = allRecords.listIterator(allRecords.size()); + final ListIterator itAct = result.getContent().listIterator(); + while (itExp.hasPrevious() && itAct.hasNext()) { + assertEquals(itExp.previous().getUri(), itAct.next().getUri()); + } + } + + @Test + void findAllFullThrowsIllegalArgumentExceptionForUnsupportedSortProperty() { + assertThrows(IllegalArgumentException.class, + () -> sut.findAllRecordsFull(new RecordFilterParams(), PageRequest.of(0, 10, Sort.Direction.ASC, + "unknownProperty"))); + } + + @Test + void findAllFullReturnsPageWithTotalNumberOfMatchingRecords() { + final User author = generateAuthorWithInstitution(); + final List allRecords = generateRecordsForAuthor(author, 10); + transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); + final int pageSize = 5; + + final Page result = sut.findAllRecordsFull(new RecordFilterParams(), PageRequest.of(1, pageSize)); + assertFalse(result.isEmpty()); + assertEquals(pageSize, result.getNumberOfElements()); + assertEquals(allRecords.size(), result.getTotalElements()); + } + + @Test + void findAllRecordsReturnsPageWithMatchingRecords() { + final User author = generateAuthorWithInstitution(); + final int totalCount = 10; + final List allRecords = generateRecordsForAuthor(author, totalCount); + for (int i = 0; i < allRecords.size(); i++) { + allRecords.get(i).setDateCreated(new Date(System.currentTimeMillis() - i * Environment.MILLIS_PER_DAY)); + } + transactional(() -> allRecords.forEach(this::persistRecordWithIdentification)); + final LocalDate minDate = LocalDate.now().minusDays(3); + final LocalDate maxDate = LocalDate.now().minusDays(1); + final List allMatching = allRecords.stream().filter(r -> { + final Date modified = r.getLastModified() != null ? r.getLastModified() : r.getDateCreated(); + final LocalDate modifiedDate = modified.toInstant().atZone(ZoneOffset.UTC).toLocalDate(); + return !modifiedDate.isBefore(minDate) && !modifiedDate.isAfter(maxDate); + }).sorted(Comparator.comparing(r -> r.getLastModified() != null ? r.getLastModified() : r.getDateCreated())).toList(); + final int pageSize = 3; + + final Page result = + sut.findAllRecords(new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), + PageRequest.of(0, pageSize, Sort.Direction.ASC, RecordSort.SORT_DATE_PROPERTY)); + assertEquals(Math.min(pageSize, allMatching.size()), result.getNumberOfElements()); + assertEquals(allMatching.size(), result.getTotalElements()); + for (int i = 0; i < result.getNumberOfElements(); i++) { + assertEquals(allMatching.get(i).getUri(), result.getContent().get(i).getUri()); + } + } + @Test void persistDoesNotGenerateIdentificationWhenRecordAlreadyHasIt() { final User author = generateAuthorWithInstitution(); diff --git a/src/test/java/cz/cvut/kbss/study/rest/ActionHistoryControllerTest.java b/src/test/java/cz/cvut/kbss/study/rest/ActionHistoryControllerTest.java index 393a0ded..52e3d31a 100644 --- a/src/test/java/cz/cvut/kbss/study/rest/ActionHistoryControllerTest.java +++ b/src/test/java/cz/cvut/kbss/study/rest/ActionHistoryControllerTest.java @@ -7,24 +7,34 @@ import cz.cvut.kbss.study.model.ActionHistory; import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.User; +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; import cz.cvut.kbss.study.service.ActionHistoryService; import cz.cvut.kbss.study.service.UserService; +import cz.cvut.kbss.study.util.Constants; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -39,23 +49,29 @@ public class ActionHistoryControllerTest extends BaseControllerTestRunner { @Mock private UserService userServiceMock; + @Mock + private ApplicationEventPublisher eventPublisherMock; + @InjectMocks private ActionHistoryController controller; + private User user; + @BeforeEach public void setUp() { super.setUp(controller); Institution institution = Generator.generateInstitution(); - User user = Generator.generateUser(institution); + this.user = Generator.generateUser(institution); Environment.setCurrentUser(user); } @Test public void createActionReturnsResponseStatusCreated() throws Exception { - ActionHistory action = Generator.generateActionHistory(Environment.getCurrentUser()); + ActionHistory action = Generator.generateActionHistory(user); final MvcResult result = mockMvc.perform(post("/history").content(toJson(action)) - .contentType(MediaType.APPLICATION_JSON_VALUE)).andReturn(); + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andReturn(); assertEquals(HttpStatus.CREATED, HttpStatus.valueOf(result.getResponse().getStatus())); } @@ -72,49 +88,48 @@ public void getByKeyThrowsNotFoundWhenActionIsNotFound() throws Exception { @Test public void getByKeyReturnsFoundAction() throws Exception { final String key = "12345"; - ActionHistory action = Generator.generateActionHistory(Environment.getCurrentUser()); + ActionHistory action = Generator.generateActionHistory(user); action.setKey(key); when(actionHistoryServiceMock.findByKey(key)).thenReturn(action); final MvcResult result = mockMvc.perform(get("/history/" + key)).andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); - final ActionHistory res = objectMapper.readValue(result.getResponse().getContentAsString(), ActionHistory.class); - assertEquals(res.getUri(),action.getUri()); + final ActionHistory res = + objectMapper.readValue(result.getResponse().getContentAsString(), ActionHistory.class); + assertEquals(res.getUri(), action.getUri()); verify(actionHistoryServiceMock).findByKey(key); } @Test public void getActionsReturnsEmptyListWhenNoActionsAreFound() throws Exception { - when(actionHistoryServiceMock.findAllWithParams(null, null, 1)).thenReturn(Collections.emptyList()); + when(actionHistoryServiceMock.findAllWithParams(any(), any(), any())).thenReturn(Page.empty()); - final MvcResult result = mockMvc.perform(get("/history/").param("page", "1")).andReturn(); + final MvcResult result = mockMvc.perform(get("/history/")).andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertTrue(body.isEmpty()); } @Test public void getActionsReturnsAllActions() throws Exception { - ActionHistory action1 = Generator.generateActionHistory(Environment.getCurrentUser()); - ActionHistory action2 = Generator.generateActionHistory(Environment.getCurrentUser()); - List actions = new ArrayList<>(); - - actions.add(action1); - actions.add(action2); + ActionHistory action1 = Generator.generateActionHistory(user); + ActionHistory action2 = Generator.generateActionHistory(user); + List actions = List.of(action1, action2); - when(actionHistoryServiceMock.findAllWithParams(null, null, 1)).thenReturn(actions); + when(actionHistoryServiceMock.findAllWithParams(any(), any(), any( + Pageable.class))).thenReturn(new PageImpl<>(actions)); final MvcResult result = mockMvc.perform(get("/history/").param("page", "1")).andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals(2, body.size()); - verify(actionHistoryServiceMock).findAllWithParams(null , null, 1); + verify(actionHistoryServiceMock).findAllWithParams(null, null, PageRequest.of(1, Constants.DEFAULT_PAGE_SIZE)); } @Test @@ -123,90 +138,104 @@ public void getActionsByUnknownAuthorReturnsEmptyList() throws Exception { when(userServiceMock.findByUsername(username)).thenThrow(NotFoundException.create("User", username)); final MvcResult result = mockMvc.perform(get("/history/") - .param("author", username) - .param("page", "1")) - .andReturn(); + .param("author", username) + .param("page", "1")) + .andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertTrue(body.isEmpty()); + verify(actionHistoryServiceMock, never()).findAllWithParams(any(), any(), any()); } @Test public void getActionsByAuthorReturnsActions() throws Exception { - ActionHistory action1 = Generator.generateActionHistory(Environment.getCurrentUser()); - ActionHistory action2 = Generator.generateActionHistory(Environment.getCurrentUser()); - List actions = new ArrayList<>(); - - actions.add(action1); - actions.add(action2); + ActionHistory action1 = Generator.generateActionHistory(user); + ActionHistory action2 = Generator.generateActionHistory(user); + List actions = List.of(action1, action2); - when(actionHistoryServiceMock.findAllWithParams(null, Environment.getCurrentUser(), 1)).thenReturn(actions); - when(userServiceMock.findByUsername(Environment.getCurrentUser().getUsername())).thenReturn(Environment.getCurrentUser()); + when(actionHistoryServiceMock.findAllWithParams(any(), eq(user), any( + Pageable.class))).thenReturn(new PageImpl<>(actions)); + when(userServiceMock.findByUsername(user.getUsername())).thenReturn( + user); final MvcResult result = mockMvc.perform(get("/history/") - .param("author", Environment.getCurrentUser().getUsername()) - .param("page", "1")) - .andReturn(); + .param("author", user.getUsername()) + .param("page", "1")) + .andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals(2, body.size()); - verify(actionHistoryServiceMock).findAllWithParams(null, Environment.getCurrentUser(), 1); + verify(actionHistoryServiceMock).findAllWithParams(null, user, + PageRequest.of(1, Constants.DEFAULT_PAGE_SIZE)); } @Test public void getActionsByTypeReturnsActions() throws Exception { - ActionHistory action1 = Generator.generateActionHistory(Environment.getCurrentUser()); - ActionHistory action2 = Generator.generateActionHistory(Environment.getCurrentUser()); - List actions = new ArrayList<>(); - - actions.add(action1); - actions.add(action2); + ActionHistory action1 = Generator.generateActionHistory(user); + ActionHistory action2 = Generator.generateActionHistory(user); + List actions = List.of(action1, action2); - when(actionHistoryServiceMock.findAllWithParams("TYPE", null, 1)).thenReturn(actions); + when(actionHistoryServiceMock.findAllWithParams(eq("TYPE"), any(), any(Pageable.class))).thenReturn( + new PageImpl<>(actions)); final MvcResult result = mockMvc.perform(get("/history/") - .param("type", "TYPE") - .param("page", "1")) - .andReturn(); + .param("type", "TYPE") + .param("page", "1")) + .andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals(2, body.size()); - verify(actionHistoryServiceMock).findAllWithParams("TYPE", null, 1); + verify(actionHistoryServiceMock).findAllWithParams("TYPE", null, + PageRequest.of(1, Constants.DEFAULT_PAGE_SIZE)); } @Test public void getActionsByTypeAndAuthorReturnsActions() throws Exception { - User user = Environment.getCurrentUser(); ActionHistory action1 = Generator.generateActionHistory(user); ActionHistory action2 = Generator.generateActionHistory(user); - List actions = new ArrayList<>(); - - actions.add(action1); - actions.add(action2); + List actions = List.of(action1, action2); when(userServiceMock.findByUsername(user.getUsername())).thenReturn(user); - when(actionHistoryServiceMock.findAllWithParams("TYPE", user, 1)).thenReturn(actions); + when(actionHistoryServiceMock.findAllWithParams(eq("TYPE"), eq(user), any(Pageable.class))).thenReturn( + new PageImpl<>(actions)); final MvcResult result = mockMvc.perform(get("/history/") - .param("author", user.getUsername()) - .param("type", "TYPE") - .param("page", "1")) - .andReturn(); + .param("author", user.getUsername()) + .param("type", "TYPE") + .param("page", "1")) + .andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals(2, body.size()); - verify(actionHistoryServiceMock).findAllWithParams("TYPE", user, 1); + verify(actionHistoryServiceMock).findAllWithParams("TYPE", user, + PageRequest.of(1, Constants.DEFAULT_PAGE_SIZE)); + } + + @Test + void getActionsPublishesPagingEvent() throws Exception { + List actions = + IntStream.range(0, 5).mapToObj(i -> Generator.generateActionHistory(user)).toList(); + final Page page = new PageImpl<>(actions, PageRequest.of(2, 5), 0L); + when(actionHistoryServiceMock.findAllWithParams(any(), any(), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/history/").param("page", "2").param("size", "5")); + verify(actionHistoryServiceMock).findAllWithParams(null, null, PageRequest.of(2, 5)); + final ArgumentCaptor captor = ArgumentCaptor.forClass( + PaginatedResultRetrievedEvent.class); + verify(eventPublisherMock).publishEvent(captor.capture()); + final PaginatedResultRetrievedEvent event = captor.getValue(); + assertEquals(page, event.getPage()); } } diff --git a/src/test/java/cz/cvut/kbss/study/rest/InstitutionControllerTest.java b/src/test/java/cz/cvut/kbss/study/rest/InstitutionControllerTest.java index 57db1670..03c5b2be 100644 --- a/src/test/java/cz/cvut/kbss/study/rest/InstitutionControllerTest.java +++ b/src/test/java/cz/cvut/kbss/study/rest/InstitutionControllerTest.java @@ -6,6 +6,7 @@ import cz.cvut.kbss.study.environment.util.Environment; import cz.cvut.kbss.study.model.Institution; import cz.cvut.kbss.study.model.User; +import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; import cz.cvut.kbss.study.service.InstitutionService; import cz.cvut.kbss.study.service.PatientRecordService; import org.junit.jupiter.api.BeforeEach; @@ -14,6 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; @@ -24,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -58,8 +62,8 @@ public void getAllInstitutionsReturnsEmptyListWhenNoInstitutionsAreFound() throw assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertTrue(body.isEmpty()); } @@ -82,8 +86,8 @@ public void getAllInstitutionsReturnsAlphabeticallySortedInstitutions() throws E assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals("A", body.get(0).getName()); assertEquals("B", body.get(1).getName()); @@ -102,7 +106,7 @@ public void findByKeyReturnsInstitution() throws Exception { assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final Institution res = objectMapper.readValue(result.getResponse().getContentAsString(), Institution.class); - assertEquals(res.getUri(),institution.getUri()); + assertEquals(res.getUri(), institution.getUri()); verify(institutionServiceMock).findByKey(key); } @@ -130,16 +134,17 @@ public void getTreatedPatientRecordsReturnsRecords() throws Exception { records.add(record2); when(institutionServiceMock.findByKey(key)).thenReturn(institution); - when(patientRecordServiceMock.findByInstitution(institution)).thenReturn(records); + when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn( + new PageImpl<>(records)); final MvcResult result = mockMvc.perform(get("/institutions/" + key + "/patients/")).andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), - new TypeReference>() { - }); + new TypeReference<>() { + }); assertEquals(2, body.size()); verify(institutionServiceMock).findByKey(key); - verify(patientRecordServiceMock).findByInstitution(institution); + verify(patientRecordServiceMock).findAll(new RecordFilterParams(key), Pageable.unpaged()); } @Test @@ -147,7 +152,8 @@ public void createInstitutionReturnsResponseStatusCreated() throws Exception { Institution institution = Generator.generateInstitution(); final MvcResult result = mockMvc.perform(post("/institutions/").content(toJson(institution)) - .contentType(MediaType.APPLICATION_JSON_VALUE)).andReturn(); + .contentType(MediaType.APPLICATION_JSON_VALUE)) + .andReturn(); assertEquals(HttpStatus.CREATED, HttpStatus.valueOf(result.getResponse().getStatus())); } @@ -162,7 +168,9 @@ public void updateInstitutionReturnsResponseStatusNoContent() throws Exception { when(institutionServiceMock.findByKey(key)).thenReturn(institution); final MvcResult result = mockMvc.perform(put("/institutions/" + key).content(toJson(institution)) - .contentType(MediaType.APPLICATION_JSON_VALUE)).andReturn(); + .contentType( + MediaType.APPLICATION_JSON_VALUE)) + .andReturn(); assertEquals(HttpStatus.NO_CONTENT, HttpStatus.valueOf(result.getResponse().getStatus())); verify(institutionServiceMock).findByKey(key); @@ -175,8 +183,10 @@ public void updateInstitutionWithNonMatchingKeyReturnsResponseStatusBadRequest() Institution institution = Generator.generateInstitution(); institution.setKey(key); - final MvcResult result = mockMvc.perform(put("/institutions/123456" ).content(toJson(institution)) - .contentType(MediaType.APPLICATION_JSON_VALUE)).andReturn(); + final MvcResult result = mockMvc.perform(put("/institutions/123456").content(toJson(institution)) + .contentType( + MediaType.APPLICATION_JSON_VALUE)) + .andReturn(); assertEquals(HttpStatus.BAD_REQUEST, HttpStatus.valueOf(result.getResponse().getStatus())); } @@ -191,7 +201,9 @@ public void updateInstitutionReturnsResponseStatusNotFound() throws Exception { when(institutionServiceMock.findByKey(key)).thenReturn(null); final MvcResult result = mockMvc.perform(put("/institutions/" + key).content(toJson(institution)) - .contentType(MediaType.APPLICATION_JSON_VALUE)).andReturn(); + .contentType( + MediaType.APPLICATION_JSON_VALUE)) + .andReturn(); assertEquals(HttpStatus.NOT_FOUND, HttpStatus.valueOf(result.getResponse().getStatus())); verify(institutionServiceMock).findByKey(key); @@ -206,7 +218,7 @@ public void deleteInstitutionReturnsResponseStatusNoContent() throws Exception { when(institutionServiceMock.findByKey(key)).thenReturn(institution); - final MvcResult result = mockMvc.perform(delete("/institutions/12345" )).andReturn(); + final MvcResult result = mockMvc.perform(delete("/institutions/12345")).andReturn(); assertEquals(HttpStatus.NO_CONTENT, HttpStatus.valueOf(result.getResponse().getStatus())); verify(institutionServiceMock).findByKey(key); diff --git a/src/test/java/cz/cvut/kbss/study/rest/PatientRecordControllerTest.java b/src/test/java/cz/cvut/kbss/study/rest/PatientRecordControllerTest.java index b3ec5d22..901120cb 100644 --- a/src/test/java/cz/cvut/kbss/study/rest/PatientRecordControllerTest.java +++ b/src/test/java/cz/cvut/kbss/study/rest/PatientRecordControllerTest.java @@ -11,8 +11,11 @@ import cz.cvut.kbss.study.model.RecordPhase; import cz.cvut.kbss.study.model.User; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; -import cz.cvut.kbss.study.service.InstitutionService; +import cz.cvut.kbss.study.persistence.dao.util.RecordSort; +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; +import cz.cvut.kbss.study.rest.util.RestUtils; import cz.cvut.kbss.study.service.PatientRecordService; +import cz.cvut.kbss.study.util.Constants; import cz.cvut.kbss.study.util.IdentificationUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,6 +24,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; @@ -55,7 +64,7 @@ public class PatientRecordControllerTest extends BaseControllerTestRunner { private PatientRecordService patientRecordServiceMock; @Mock - private InstitutionService institutionServiceMock; + private ApplicationEventPublisher eventPublisherMock; @InjectMocks private PatientRecordController controller; @@ -97,7 +106,8 @@ public void getRecordReturnsFoundRecord() throws Exception { @Test public void getRecordsReturnsEmptyListWhenNoReportsAreFound() throws Exception { - when(patientRecordServiceMock.findAllRecords()).thenReturn(Collections.emptyList()); + when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn( + Page.empty()); final MvcResult result = mockMvc.perform(get("/records/")).andReturn(); @@ -115,28 +125,25 @@ public void getRecordsReturnsAllRecords() throws Exception { User user1 = Generator.generateUser(institution); User user2 = Generator.generateUser(institution); - PatientRecordDto record1 = Generator.generatePatientRecordDto(user1); - PatientRecordDto record2 = Generator.generatePatientRecordDto(user1); - PatientRecordDto record3 = Generator.generatePatientRecordDto(user2); - List records = new ArrayList<>(); - records.add(record1); - records.add(record2); - records.add(record3); + List records = + List.of(Generator.generatePatientRecordDto(user1), Generator.generatePatientRecordDto(user1), + Generator.generatePatientRecordDto(user2)); - when(patientRecordServiceMock.findAllRecords()).thenReturn(records); + when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn( + new PageImpl<>(records)); - final MvcResult result = mockMvc.perform(get("/records/")).andReturn(); + final MvcResult result = mockMvc.perform(get("/records")).andReturn(); assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); final List body = objectMapper.readValue(result.getResponse().getContentAsString(), new TypeReference<>() { }); assertEquals(3, body.size()); - verify(patientRecordServiceMock).findAllRecords(); + verify(patientRecordServiceMock).findAll(new RecordFilterParams(), Pageable.unpaged()); } @Test - public void finByInstitutionReturnsRecords() throws Exception { + public void findByInstitutionReturnsRecords() throws Exception { final String key = "12345"; Institution institution = Generator.generateInstitution(); @@ -151,8 +158,8 @@ public void finByInstitutionReturnsRecords() throws Exception { records.add(record1); records.add(record2); - when(institutionServiceMock.findByKey(institution.getKey())).thenReturn(institution); - when(patientRecordServiceMock.findByInstitution(institution)).thenReturn(records); + when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn( + new PageImpl<>(records)); System.out.println(institution.getKey()); final MvcResult result = mockMvc.perform(get("/records").param("institution", institution.getKey())).andReturn(); @@ -162,17 +169,7 @@ public void finByInstitutionReturnsRecords() throws Exception { new TypeReference<>() { }); assertEquals(2, body.size()); - verify(institutionServiceMock).findByKey(institution.getKey()); - } - - @Test - public void findByInstitutionReturnsNotFound() throws Exception { - final String key = "12345"; - - when(institutionServiceMock.findByKey(key)).thenReturn(null); - final MvcResult result = mockMvc.perform(get("/records").param("institution", key)).andReturn(); - - assertEquals(HttpStatus.NOT_FOUND, HttpStatus.valueOf(result.getResponse().getStatus())); + verify(patientRecordServiceMock).findAll(new RecordFilterParams(institution.getKey()), Pageable.unpaged()); } @Test @@ -255,7 +252,8 @@ void exportRecordsParsesProvidedDateBoundsAndPassesThemToService() throws Except final LocalDate maxDate = LocalDate.now().minusDays(5); final List records = List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user)); - when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class))).thenReturn(records); + when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any( + Pageable.class))).thenReturn(new PageImpl<>(records)); final MvcResult mvcResult = mockMvc.perform(get("/records/export") .param("minDate", minDate.toString()) @@ -265,20 +263,21 @@ void exportRecordsParsesProvidedDateBoundsAndPassesThemToService() throws Except }); assertThat(result, containsSameEntities(records)); verify(patientRecordServiceMock).findAllFull( - new RecordFilterParams(null, minDate, maxDate, Collections.emptySet())); + new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), Pageable.unpaged()); } @Test void exportRecordsUsesDefaultValuesForMinAndMaxDateWhenTheyAreNotProvidedByRequest() throws Exception { final List records = List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user)); - when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class))).thenReturn(records); + when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any( + Pageable.class))).thenReturn(new PageImpl<>(records)); final MvcResult mvcResult = mockMvc.perform(get("/records/export")).andReturn(); final List result = readValue(mvcResult, new TypeReference<>() { }); assertThat(result, containsSameEntities(records)); - verify(patientRecordServiceMock).findAllFull(new RecordFilterParams()); + verify(patientRecordServiceMock).findAllFull(new RecordFilterParams(), Pageable.unpaged()); } @Test @@ -287,7 +286,8 @@ void exportRecordsExportsRecordsForProvidedInstitutionForSpecifiedPeriod() throw final LocalDate maxDate = LocalDate.now().minusDays(5); final List records = List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user)); - when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class))).thenReturn(records); + when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any( + Pageable.class))).thenReturn(new PageImpl<>(records)); final MvcResult mvcResult = mockMvc.perform(get("/records/export") .param("minDate", minDate.toString()) @@ -298,7 +298,8 @@ void exportRecordsExportsRecordsForProvidedInstitutionForSpecifiedPeriod() throw }); assertThat(result, containsSameEntities(records)); verify(patientRecordServiceMock).findAllFull( - new RecordFilterParams(user.getInstitution().getKey(), minDate, maxDate, Collections.emptySet())); + new RecordFilterParams(user.getInstitution().getKey(), minDate, maxDate, Collections.emptySet()), + Pageable.unpaged()); } @Test @@ -343,4 +344,84 @@ void importRecordsReturnsConflictWhenServiceThrowsRecordAuthorNotFound() throws mockMvc.perform(post("/records/import").content(toJson(records)).contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isConflict()); } + + @Test + void getRecordsResolvesPagingConfigurationFromRequestParameters() throws Exception { + final LocalDate minDate = LocalDate.now().minusDays(35); + final LocalDate maxDate = LocalDate.now().minusDays(5); + final int page = Generator.randomInt(0, 5); + final int pageSize = Generator.randomInt(30, 50); + final List records = + List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user)); + when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any( + Pageable.class))).thenReturn(new PageImpl<>(records)); + + final MvcResult mvcResult = mockMvc.perform(get("/records/export") + .param("minDate", minDate.toString()) + .param("maxDate", maxDate.toString()) + .param(Constants.PAGE_PARAM, Integer.toString(page)) + .param(Constants.PAGE_SIZE_PARAM, + Integer.toString(pageSize)) + .param(Constants.SORT_PARAM, + RestUtils.SORT_DESC + RecordSort.SORT_DATE_PROPERTY)) + .andReturn(); + final List result = readValue(mvcResult, new TypeReference<>() { + }); + assertThat(result, containsSameEntities(records)); + verify(patientRecordServiceMock).findAllFull( + new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), + PageRequest.of(page, pageSize, Sort.Direction.DESC, RecordSort.SORT_DATE_PROPERTY)); + } + + @Test + void getRecordsPublishesPagingEvent() throws Exception { + List records = + List.of(Generator.generatePatientRecordDto(user), Generator.generatePatientRecordDto(user), + Generator.generatePatientRecordDto(user)); + + final Page page = new PageImpl<>(records, PageRequest.of(0, 5), 3); + when(patientRecordServiceMock.findAll(any(RecordFilterParams.class), any(Pageable.class))).thenReturn(page); + final MvcResult result = mockMvc.perform(get("/records").queryParam(Constants.PAGE_PARAM, "0") + .queryParam(Constants.PAGE_SIZE_PARAM, "5")) + .andReturn(); + + assertEquals(HttpStatus.OK, HttpStatus.valueOf(result.getResponse().getStatus())); + final List body = objectMapper.readValue(result.getResponse().getContentAsString(), + new TypeReference<>() { + }); + assertEquals(3, body.size()); + verify(patientRecordServiceMock).findAll(new RecordFilterParams(), PageRequest.of(0, 5)); + final ArgumentCaptor captor = ArgumentCaptor.forClass( + PaginatedResultRetrievedEvent.class); + verify(eventPublisherMock).publishEvent(captor.capture()); + final PaginatedResultRetrievedEvent event = captor.getValue(); + assertEquals(page, event.getPage()); + } + + @Test + void exportRecordsPublishesPagingEvent() throws Exception { + final LocalDate minDate = LocalDate.now().minusDays(35); + final LocalDate maxDate = LocalDate.now().minusDays(5); + final List records = + List.of(Generator.generatePatientRecord(user), Generator.generatePatientRecord(user)); + final Page page = new PageImpl<>(records, PageRequest.of(0, 50), 100); + when(patientRecordServiceMock.findAllFull(any(RecordFilterParams.class), any(Pageable.class))).thenReturn(page); + + final MvcResult mvcResult = mockMvc.perform(get("/records/export") + .param("minDate", minDate.toString()) + .param("maxDate", maxDate.toString()) + .param(Constants.PAGE_PARAM, "0") + .param(Constants.PAGE_SIZE_PARAM, "50")) + .andReturn(); + final List result = readValue(mvcResult, new TypeReference<>() { + }); + assertThat(result, containsSameEntities(records)); + verify(patientRecordServiceMock).findAllFull( + new RecordFilterParams(null, minDate, maxDate, Collections.emptySet()), PageRequest.of(0, 50)); + final ArgumentCaptor captor = + ArgumentCaptor.forClass(PaginatedResultRetrievedEvent.class); + verify(eventPublisherMock).publishEvent(captor.capture()); + final PaginatedResultRetrievedEvent event = captor.getValue(); + assertEquals(page, event.getPage()); + } } diff --git a/src/test/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListenerTest.java b/src/test/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListenerTest.java new file mode 100644 index 00000000..0d582ce9 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/study/rest/handler/HateoasPagingListenerTest.java @@ -0,0 +1,185 @@ +package cz.cvut.kbss.study.rest.handler; + +import cz.cvut.kbss.study.dto.PatientRecordDto; +import cz.cvut.kbss.study.environment.generator.Generator; +import cz.cvut.kbss.study.model.User; +import cz.cvut.kbss.study.rest.event.PaginatedResultRetrievedEvent; +import cz.cvut.kbss.study.rest.util.HttpPaginationLink; +import cz.cvut.kbss.study.util.Constants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class HateoasPagingListenerTest { + + private static final String BASE_URL = "http://localhost/rest/records"; + + private UriComponentsBuilder uriBuilder; + private MockHttpServletResponse responseMock; + + private List records; + + private HateoasPagingListener listener; + + @BeforeEach + public void setUp() { + this.listener = new HateoasPagingListener(); + this.uriBuilder = UriComponentsBuilder.newInstance().scheme("http").host("localhost").path("rest/records"); + this.responseMock = new MockHttpServletResponse(); + final User author = Generator.generateUser(null); + this.records = IntStream.range(0, 10).mapToObj(i -> Generator.generatePatientRecordDto(author)) + .collect(Collectors.toList()); + } + + @Test + public void generatesNextRelativeLink() { + final int size = 5; + final Page page = + new PageImpl<>(records.subList(0, size), PageRequest.of(0, size), records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String nextLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.NEXT.getName()); + assertThat(nextLink, containsString(BASE_URL)); + assertThat(nextLink, containsString(page(1))); + assertThat(nextLink, containsString(pageSize(size))); + } + + private static String page(int pageNo) { + return Constants.PAGE_PARAM + "=" + pageNo; + } + + private static String pageSize(int size) { + return Constants.PAGE_SIZE_PARAM + "=" + size; + } + + private PaginatedResultRetrievedEvent event(Page page) { + return new PaginatedResultRetrievedEvent(this, uriBuilder, responseMock, page); + } + + @Test + public void generatesLastRelativeLink() { + final int size = 5; + final Page page = + new PageImpl<>(records.subList(0, size), PageRequest.of(0, size), records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String lastLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.LAST.getName()); + assertThat(lastLink, containsString(BASE_URL)); + assertThat(lastLink, containsString(page(1))); + assertThat(lastLink, containsString(pageSize(size))); + } + + @Test + public void generatesPreviousRelativeLink() { + final int size = 5; + final Page page = + new PageImpl<>(records.subList(size, records.size()), PageRequest.of(1, size), + records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String lastLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.PREVIOUS.getName()); + assertThat(lastLink, containsString(BASE_URL)); + assertThat(lastLink, containsString(page(0))); + assertThat(lastLink, containsString(pageSize(size))); + } + + @Test + public void generatesFirstRelativeLink() { + final int size = 5; + final Page page = + new PageImpl<>(records.subList(size, records.size()), PageRequest.of(1, size), + records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String lastLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.FIRST.getName()); + assertThat(lastLink, containsString(BASE_URL)); + assertThat(lastLink, containsString(page(0))); + assertThat(lastLink, containsString(pageSize(size))); + } + + @Test + public void generatesAllRelativeLinks() { + final int size = 3; + final int pageNum = 2; + final Page page = new PageImpl<>(records.subList(pageNum * size, pageNum * size + size), + PageRequest.of(pageNum, size), records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String nextLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.NEXT.getName()); + assertThat(nextLink, containsString(page(pageNum + 1))); + assertThat(nextLink, containsString(pageSize(size))); + final String previousLink = + HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.PREVIOUS.getName()); + assertThat(previousLink, containsString(page(pageNum - 1))); + assertThat(previousLink, containsString(pageSize(size))); + final String firstLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.FIRST.getName()); + assertThat(firstLink, containsString(page(0))); + assertThat(firstLink, containsString(pageSize(size))); + final String lastLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.LAST.getName()); + assertThat(lastLink, containsString(page(3))); + assertThat(lastLink, containsString(pageSize(size))); + } + + @Test + public void generatesNoLinksForEmptyPage() { + final int size = 5; + final Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, size), 0); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNull(linkHeader); + } + + @Test + public void generatesPreviousAndFirstLinkForEmptyPageAfterEnd() { + final int size = 5; + final int pageNum = 4; + final Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(pageNum, size), + records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String previousLink = + HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.PREVIOUS.getName()); + assertThat(previousLink, containsString(page(pageNum - 1))); + assertThat(previousLink, containsString(pageSize(size))); + final String firstLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.FIRST.getName()); + assertThat(firstLink, containsString(page(0))); + assertThat(firstLink, containsString(pageSize(size))); + } + + @Test + public void generatesFirstAndLastLinksForOnlyPage() { + final int size = records.size(); + final Page page = new PageImpl<>(records, PageRequest.of(0, size), records.size()); + listener.onApplicationEvent(event(page)); + final String linkHeader = responseMock.getHeader(HttpHeaders.LINK); + assertNotNull(linkHeader); + final String firstLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.FIRST.getName()); + assertThat(firstLink, containsString(page(0))); + assertThat(firstLink, containsString(pageSize(size))); + final String lastLink = HttpLinkHeaderUtil.extractURIByRel(linkHeader, HttpPaginationLink.LAST.getName()); + assertThat(lastLink, containsString(page(0))); + assertThat(lastLink, containsString(pageSize(size))); + } + +} \ No newline at end of file diff --git a/src/test/java/cz/cvut/kbss/study/rest/handler/HttpLinkHeaderUtil.java b/src/test/java/cz/cvut/kbss/study/rest/handler/HttpLinkHeaderUtil.java new file mode 100644 index 00000000..75905662 --- /dev/null +++ b/src/test/java/cz/cvut/kbss/study/rest/handler/HttpLinkHeaderUtil.java @@ -0,0 +1,32 @@ +package cz.cvut.kbss.study.rest.handler; + +public class HttpLinkHeaderUtil { + + private HttpLinkHeaderUtil() { + throw new AssertionError(); + } + + public static String extractURIByRel(final String linkHeader, final String rel) { + if (linkHeader == null) { + return null; + } + String uriWithSpecifiedRel = null; + final String[] links = linkHeader.split(", "); + String linkRelation; + for (final String link : links) { + final int positionOfSeparator = link.indexOf(';'); + linkRelation = link.substring(positionOfSeparator + 1).trim(); + if (extractTypeOfRelation(linkRelation).equals(rel)) { + uriWithSpecifiedRel = link.substring(0, positionOfSeparator); + break; + } + } + + return uriWithSpecifiedRel; + } + + private static Object extractTypeOfRelation(final String linkRelation) { + final int positionOfEquals = linkRelation.indexOf('='); + return linkRelation.substring(positionOfEquals + 2, linkRelation.length() - 1).trim(); + } +} diff --git a/src/test/java/cz/cvut/kbss/study/rest/util/RecordFilterMapperTest.java b/src/test/java/cz/cvut/kbss/study/rest/util/RecordFilterMapperTest.java index 959b08c0..ec36d28d 100644 --- a/src/test/java/cz/cvut/kbss/study/rest/util/RecordFilterMapperTest.java +++ b/src/test/java/cz/cvut/kbss/study/rest/util/RecordFilterMapperTest.java @@ -1,5 +1,6 @@ package cz.cvut.kbss.study.rest.util; +import cz.cvut.kbss.study.model.RecordPhase; import cz.cvut.kbss.study.persistence.dao.util.RecordFilterParams; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -40,16 +41,16 @@ static Stream testValues() { )), new RecordFilterParams("1111111", LocalDate.EPOCH, LocalDate.now(), Collections.emptySet())), Arguments.of(new LinkedMultiValueMap<>(Map.of( "institution", List.of("1111111"), - "phase", List.of("http://example.org/phaseOne", "http://example.org/phaseTwo") + "phase", List.of(RecordPhase.open.getIri(), RecordPhase.completed.name()) )), new RecordFilterParams("1111111", LocalDate.EPOCH, LocalDate.now(), - Set.of("http://example.org/phaseOne", "http://example.org/phaseTwo"))), + Set.of(RecordPhase.open.getIri(), RecordPhase.completed.getIri()))), Arguments.of(new LinkedMultiValueMap<>(Map.of( "minDate", List.of(LocalDate.now().minusYears(1).toString()), "maxDate", List.of(LocalDate.now().minusDays(1).toString()), "institution", List.of("1111111"), - "phase", List.of("http://example.org/phaseOne") + "phase", List.of(RecordPhase.published.name()) )), new RecordFilterParams("1111111", LocalDate.now().minusYears(1), LocalDate.now().minusDays(1), - Set.of("http://example.org/phaseOne"))) + Set.of(RecordPhase.published.getIri()))) ); } } \ No newline at end of file diff --git a/src/test/java/cz/cvut/kbss/study/rest/util/RestUtilsTest.java b/src/test/java/cz/cvut/kbss/study/rest/util/RestUtilsTest.java index a2045f83..ca7df782 100644 --- a/src/test/java/cz/cvut/kbss/study/rest/util/RestUtilsTest.java +++ b/src/test/java/cz/cvut/kbss/study/rest/util/RestUtilsTest.java @@ -1,14 +1,30 @@ package cz.cvut.kbss.study.rest.util; +import cz.cvut.kbss.study.environment.generator.Generator; +import cz.cvut.kbss.study.persistence.dao.util.RecordSort; +import cz.cvut.kbss.study.util.Constants; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDate; import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class RestUtilsTest { @@ -22,14 +38,86 @@ void parseTimestampReturnsLocalDateParsedFromSpecifiedString() { void parseTimestampThrowsResponseStatusExceptionWithStatus400ForUnparseableString() { final Date date = new Date(); final ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> RestUtils.parseDate(date.toString())); + () -> RestUtils.parseDate(date.toString())); assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); } @Test void parseTimestampThrowsResponseStatusExceptionWithStatus400ForNullArgument() { final ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> RestUtils.parseDate(null)); + () -> RestUtils.parseDate(null)); assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); } + + @Test + void resolvePagingCreatesPageableObjectWithSpecifiedPageSizeAndNumber() { + final int page = Generator.randomInt(0, 10); + final int size = Generator.randomInt(20, 50); + final MultiValueMap params = new LinkedMultiValueMap<>(Map.of( + Constants.PAGE_PARAM, List.of(Integer.toString(page)), + Constants.PAGE_SIZE_PARAM, List.of(Integer.toString(size)))); + + final Pageable result = RestUtils.resolvePaging(params); + assertEquals(PageRequest.of(page, size), result); + } + + @Test + void resolvePagingReturnsUnpagedObjectWhenNoPageNumberIsSpecified() { + final MultiValueMap params = new LinkedMultiValueMap<>(); + final Pageable result = RestUtils.resolvePaging(params); + assertTrue(result.isUnpaged()); + } + + @Test + void resolvePagingReturnsPagedObjectWithDefaultPageSizeWhenNoPageSizeIsSpecified() { + final int page = Generator.randomInt(0, 10); + final MultiValueMap params = new LinkedMultiValueMap<>(Map.of( + Constants.PAGE_PARAM, List.of(Integer.toString(page)))); + + final Pageable result = RestUtils.resolvePaging(params); + assertEquals(PageRequest.of(page, Constants.DEFAULT_PAGE_SIZE), result); + } + + @ParameterizedTest + @MethodSource("sortTestArguments") + void resolvePagingAddsSpecifiedSortPropertyAndDirectionToResult(String sortParam, Sort.Order expectedOrder) { + final int page = Generator.randomInt(0, 10); + final int size = Generator.randomInt(20, 50); + final MultiValueMap params = new LinkedMultiValueMap<>(Map.of( + Constants.PAGE_PARAM, List.of(Integer.toString(page)), + Constants.PAGE_SIZE_PARAM, List.of(Integer.toString(size)), + Constants.SORT_PARAM, List.of(sortParam))); + + final Pageable result = RestUtils.resolvePaging(params); + assertNotNull(result.getSort()); + assertEquals(expectedOrder, result.getSort().getOrderFor(expectedOrder.getProperty())); + } + + protected static Stream sortTestArguments() { + return Stream.of( + Arguments.of(RestUtils.SORT_DESC + RecordSort.SORT_DATE_PROPERTY, + Sort.Order.desc(RecordSort.SORT_DATE_PROPERTY)), + Arguments.of(RestUtils.SORT_ASC + RecordSort.SORT_DATE_PROPERTY, + Sort.Order.asc(RecordSort.SORT_DATE_PROPERTY)), + Arguments.of(RecordSort.SORT_DATE_PROPERTY, Sort.Order.asc(RecordSort.SORT_DATE_PROPERTY)) + ); + } + + @Test + void resolvePagingSupportsMultipleSortValues() { + final String anotherSort = "name"; + final int page = Generator.randomInt(0, 10); + final int size = Generator.randomInt(20, 50); + final MultiValueMap params = new LinkedMultiValueMap<>(Map.of( + Constants.PAGE_PARAM, List.of(Integer.toString(page)), + Constants.PAGE_SIZE_PARAM, List.of(Integer.toString(size)), + Constants.SORT_PARAM, List.of(RecordSort.SORT_DATE_PROPERTY, RestUtils.SORT_ASC + anotherSort))); + + final Pageable result = RestUtils.resolvePaging(params); + assertNotNull(result.getSort()); + assertEquals(Sort.Order.asc(RecordSort.SORT_DATE_PROPERTY), + result.getSort().getOrderFor(RecordSort.SORT_DATE_PROPERTY)); + assertEquals(Sort.Order.asc(anotherSort), + result.getSort().getOrderFor(anotherSort)); + } } \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 93b37394..a0506b13 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -24,12 +24,7 @@ - - - - - - +