Skip to content

Commit

Permalink
Merge pull request #691 from Health-Education-England/feat/setLifecyc…
Browse files Browse the repository at this point in the history
…leState

feat: set lifecycle status (and new submit endpoint)
  • Loading branch information
ReubenRobertsHEE authored Feb 28, 2025
2 parents 5c9a83e + e171f1e commit 305b2f6
Show file tree
Hide file tree
Showing 19 changed files with 851 additions and 35 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group = "uk.nhs.hee.tis.trainee"
version = "0.30.2"
version = "0.31.0"

configurations {
compileOnly {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
public class TestJwtUtil {

public static final String TIS_ID_ATTRIBUTE = "custom:tisId";
public static final String EMAIL_ATTRIBUTE = "email";
public static final String GIVEN_NAME_ATTRIBUTE = "given_name";
public static final String FAMILY_NAME_ATTRIBUTE = "family_name";

/**
* Generate a token with the given payload.
Expand All @@ -56,6 +59,26 @@ public static String generateTokenForTisId(String traineeTisId) {
return generateToken(payload);
}

/**
* Generate a token with the various attributes as the payload.
*
* @param traineeTisId The TIS ID to inject in to the payload.
* @param email The email to inject in to the payload.
* @param givenName The given name to inject in to the payload.
* @param familyName The family name to inject in to the payload.
* @return The generated token.
*/
public static String generateTokenForTrainee(String traineeTisId, String email, String givenName,
String familyName) {
String payload = String.format("{\"%s\":\"%s\"", TIS_ID_ATTRIBUTE, traineeTisId)
+ (email == null ? "" : String.format(",\"%s\":\"%s\"", EMAIL_ATTRIBUTE, email)
+ (givenName == null ? "" : String.format(",\"%s\":\"%s\"", GIVEN_NAME_ATTRIBUTE, givenName)
+ (familyName == null ? "" : String.format(",\"%s\":\"%s\"", FAMILY_NAME_ATTRIBUTE,
familyName))))
+ "}"; // :tears:
return generateToken(payload);
}

/**
* Generate an admin token with the given groups and default attributes for other fields.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE;
import static org.junit.jupiter.params.provider.EnumSource.Mode.INCLUDE;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
Expand Down Expand Up @@ -59,7 +61,7 @@
import uk.nhs.hee.tis.trainee.forms.TestJwtUtil;
import uk.nhs.hee.tis.trainee.forms.dto.LtftFormDto;
import uk.nhs.hee.tis.trainee.forms.dto.enumeration.LifecycleState;
import uk.nhs.hee.tis.trainee.forms.model.AbstractAuditedForm;
import uk.nhs.hee.tis.trainee.forms.dto.identity.TraineeIdentity;
import uk.nhs.hee.tis.trainee.forms.model.LtftForm;
import uk.nhs.hee.tis.trainee.forms.model.Person;
import uk.nhs.hee.tis.trainee.forms.model.content.LtftContent;
Expand Down Expand Up @@ -375,14 +377,12 @@ void shouldReturnNotFoundWhenDeletingNonExistentLtftForm() throws Exception {
}

@ParameterizedTest
@EnumSource(value = LifecycleState.class, names = {"DRAFT"}, mode = EnumSource.Mode.EXCLUDE)
@EnumSource(value = LifecycleState.class, mode = EXCLUDE, names = {"DRAFT"})
void shouldReturnBadRequestWhenServiceCantDeleteLtftForm(LifecycleState lifecycleState)
throws Exception {
LtftForm form = new LtftForm();
form.setTraineeTisId(TRAINEE_ID);
AbstractAuditedForm.Status.StatusInfo statusInfo
= AbstractAuditedForm.Status.StatusInfo.builder().state(lifecycleState).build();
form.setStatus(new AbstractAuditedForm.Status(statusInfo, List.of(statusInfo)));
form.setLifecycleState(lifecycleState);
LtftForm formSaved = template.save(form);

UUID savedId = formSaved.getId();
Expand All @@ -397,9 +397,7 @@ void shouldReturnBadRequestWhenServiceCantDeleteLtftForm(LifecycleState lifecycl
void shouldDeleteLtftForm() throws Exception {
LtftForm form = new LtftForm();
form.setTraineeTisId(TRAINEE_ID);
AbstractAuditedForm.Status.StatusInfo statusInfo
= AbstractAuditedForm.Status.StatusInfo.builder().state(LifecycleState.DRAFT).build();
form.setStatus(new AbstractAuditedForm.Status(statusInfo, List.of(statusInfo)));
form.setLifecycleState(LifecycleState.DRAFT);
LtftForm formSaved = template.save(form);

UUID savedId = formSaved.getId();
Expand All @@ -412,4 +410,89 @@ void shouldDeleteLtftForm() throws Exception {
assertThat("Unexpected saved record count.", template.count(new Query(), LtftForm.class),
is(0L));
}

@Test
void shouldBeForbiddenFromSubmittingLtftFormWhenNoToken() throws Exception {
mockMvc.perform(put("/api/ltft/{id}/submit", ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$").doesNotExist());
}

@Test
void shouldBeForbiddenFromSubmittingLtftFormWhenTokenLacksTraineeId() throws Exception {
String token = TestJwtUtil.generateToken("{}");
mockMvc.perform(put("/api/ltft/{id}/submit", ID)
.header(HttpHeaders.AUTHORIZATION, token)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$").doesNotExist());
}

@Test
void shouldReturnBadRequestWhenSubmittingLtftFormNotOwnedByUser() throws Exception {
LtftForm ltft = new LtftForm();
ltft.setId(ID);
ltft.setTraineeTisId("another trainee");
template.insert(ltft);

String token = TestJwtUtil.generateTokenForTisId(TRAINEE_ID);
mockMvc.perform(put("/api/ltft/{id}/submit", ID)
.header(HttpHeaders.AUTHORIZATION, token)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$").doesNotExist());
}

@ParameterizedTest
@EnumSource(value = LifecycleState.class, mode = EXCLUDE, names = {"DRAFT", "UNSUBMITTED"})
void shouldReturnBadRequestWhenSubmittingLtftFormInInvalidState(LifecycleState state)
throws Exception {
LtftForm ltft = new LtftForm();
ltft.setId(ID);
ltft.setTraineeTisId(TRAINEE_ID);
ltft.setLifecycleState(state);
ltft.setContent(LtftContent.builder().name("test").build());
template.insert(ltft);

String token = TestJwtUtil.generateTokenForTisId(TRAINEE_ID);
mockMvc.perform(put("/api/ltft/{id}/submit", ID)
.header(HttpHeaders.AUTHORIZATION, token)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$").doesNotExist());
}

@ParameterizedTest
@EnumSource(value = LifecycleState.class, mode = INCLUDE, names = {"DRAFT", "UNSUBMITTED"})
void shouldSubmitLtftForm(LifecycleState state) throws Exception {
LtftForm ltft = new LtftForm();
ltft.setId(ID);
ltft.setTraineeTisId(TRAINEE_ID);
ltft.setLifecycleState(state);
ltft.setContent(LtftContent.builder().name("test").build());
template.insert(ltft);

LtftFormDto.StatusDto.LftfStatusInfoDetailDto detail
= new LtftFormDto.StatusDto.LftfStatusInfoDetailDto("reason", "message");
String detailJson = mapper.writeValueAsString(detail);
String token = TestJwtUtil.generateTokenForTrainee(TRAINEE_ID, "email", "given", "family");
mockMvc.perform(put("/api/ltft/{id}/submit", ID)
.header(HttpHeaders.AUTHORIZATION, token)
.contentType(MediaType.APPLICATION_JSON)
.content(detailJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(ID.toString()))
.andExpect(jsonPath("$.traineeTisId").value(TRAINEE_ID))
.andExpect(jsonPath("$.status.current.state").value(LifecycleState.SUBMITTED.name()))
.andExpect(jsonPath("$.status.current.detail.reason").value("reason"))
.andExpect(jsonPath("$.status.current.detail.message").value("message"))
.andExpect(jsonPath("$.status.current.modifiedBy.name").value("given family"))
.andExpect(jsonPath("$.status.current.modifiedBy.email").value("email"))
.andExpect(jsonPath("$.status.current.modifiedBy.role").value(TraineeIdentity.ROLE));
}
}
16 changes: 16 additions & 0 deletions src/main/java/uk/nhs/hee/tis/trainee/forms/api/LtftResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ public ResponseEntity<LtftFormDto> updateLtft(@PathVariable UUID formId,
return savedLtft.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.badRequest().build());
}

/**
* Allow a trainee to submit an existing LTFT form.
*
* @param formId The id of the LTFT form to submit.
*
* @return The DTO of the submitted form, or a bad request if the form could not be submitted.
*/
@PutMapping("/{formId}/submit")
public ResponseEntity<LtftFormDto> submitLtft(@PathVariable UUID formId,
@RequestBody LtftFormDto.StatusDto.LftfStatusInfoDetailDto reason) {
log.info("Request to submit LTFT form {} with reason {}.", formId, reason);
Optional<LtftFormDto> submittedLtft = service.submitLtftForm(formId, reason);
return submittedLtft.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.badRequest().build());
}

/**
* Get an existing LTFT form.
*
Expand Down
18 changes: 16 additions & 2 deletions src/main/java/uk/nhs/hee/tis/trainee/forms/dto/LtftFormDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public record StatusDto(
* Form status information.
*
* @param state The lifecycle state of the form.
* @param detail Any status detail.
* @param detail Status reason detail.
* @param modifiedBy The Person who made this status change.
* @param timestamp The timestamp of the status change.
* @param revision The revision number associated with this status change.
Expand All @@ -167,13 +167,27 @@ public record StatusDto(
public record StatusInfoDto(

LifecycleState state,
String detail,
LftfStatusInfoDetailDto detail,
PersonDto modifiedBy,
Instant timestamp,
Integer revision
) {

}

/**
* A DTO for state change details.
*
* @param reason The reason for the state change.
* @param message A message associated with the state change.
*/
@Builder
public record LftfStatusInfoDetailDto(

String reason,
String message) {

}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public enum LifecycleState {
private Set<Class<? extends AbstractForm>> allowedFormTypes;
private Set<LifecycleState> allowedTransitions;

// NOTE: allowedFormTypes can effectively disallow specific allowedTransitions.
// Example:
// UNSUBMITTED -> WITHDRAWN is allowed, but if the AbstractForm form is not a LtftForm
// (or subclass), then WITHDRAWN is not allowed and the only allowed transition is SUBMITTED.
static {
APPROVED.allowedTransitions = Set.of();
APPROVED.allowedFormTypes = Set.of(LtftForm.class);
Expand All @@ -51,7 +55,7 @@ public enum LifecycleState {
REJECTED.allowedTransitions = Set.of();
REJECTED.allowedFormTypes = Set.of(LtftForm.class);

SUBMITTED.allowedTransitions = Set.of(APPROVED, REJECTED, UNSUBMITTED, WITHDRAWN);
SUBMITTED.allowedTransitions = Set.of(APPROVED, DELETED, REJECTED, UNSUBMITTED, WITHDRAWN);
SUBMITTED.allowedFormTypes = Set.of(AbstractForm.class);

UNSUBMITTED.allowedTransitions = Set.of(SUBMITTED, WITHDRAWN);
Expand All @@ -70,6 +74,10 @@ public enum LifecycleState {
*/
public static boolean canTransitionTo(AbstractForm form, LifecycleState newLifecycleState) {
LifecycleState currentState = form.getLifecycleState();
return currentState != null && currentState.allowedTransitions.contains(newLifecycleState);

return currentState != null && currentState.allowedTransitions.contains(newLifecycleState)
&& newLifecycleState.allowedFormTypes.stream()
.anyMatch(type -> type.isAssignableFrom(form.getClass()));
// NOTE: we check the _new_ state's allowedFormTypes, not the current state's
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@
@Data
public class TraineeIdentity {

String traineeId;
//TODO: consider generic UserIdentity abstract class shared with AdminIdentity.

public static final String ROLE = "TRAINEE";

private String traineeId;
private String email;
private String name;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
public class TraineeIdentityInterceptor implements HandlerInterceptor {

private static final String TIS_ID_ATTRIBUTE = "custom:tisId";
private static final String EMAIL_ATTRIBUTE = "email";
private static final String GIVEN_NAME_ATTRIBUTE = "given_name";
private static final String FAMILY_NAME_ATTRIBUTE = "family_name";

private final TraineeIdentity traineeIdentity;

Expand All @@ -54,6 +57,13 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
try {
String traineeId = AuthTokenUtil.getAttribute(authToken, TIS_ID_ATTRIBUTE);
traineeIdentity.setTraineeId(traineeId);
String email = AuthTokenUtil.getAttribute(authToken, EMAIL_ATTRIBUTE);
traineeIdentity.setEmail(email);
String givenName = AuthTokenUtil.getAttribute(authToken, GIVEN_NAME_ATTRIBUTE);
String familyName = AuthTokenUtil.getAttribute(authToken, FAMILY_NAME_ATTRIBUTE);
if (givenName != null && familyName != null) {
traineeIdentity.setName("%s %s".formatted(givenName, familyName));
}
} catch (IOException e) {
log.warn("Unable to extract trainee ID from authorization token.", e);
}
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/uk/nhs/hee/tis/trainee/forms/mapper/LtftMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import uk.nhs.hee.tis.trainee.forms.dto.LtftFormDto.CctChangeDto;
import uk.nhs.hee.tis.trainee.forms.dto.LtftSummaryDto;
import uk.nhs.hee.tis.trainee.forms.dto.PersonalDetailsDto;
import uk.nhs.hee.tis.trainee.forms.model.AbstractAuditedForm;
import uk.nhs.hee.tis.trainee.forms.model.LtftForm;
import uk.nhs.hee.tis.trainee.forms.model.content.CctChange;
import uk.nhs.hee.tis.trainee.forms.model.content.LtftContent;
Expand Down Expand Up @@ -161,6 +162,16 @@ public abstract class LtftMapper {
@Mapping(target = "content", source = "dto")
public abstract LtftForm toEntity(LtftFormDto dto);

/**
* Convert a {@link LtftFormDto.StatusDto.LftfStatusInfoDetailDto} to a
* {@link AbstractAuditedForm.Status.StatusDetail}.
*
* @param dto The DTO to convert.
* @return The equivalent status detail.
*/
public abstract AbstractAuditedForm.Status.StatusDetail toStatusDetail(
LtftFormDto.StatusDto.LftfStatusInfoDetailDto dto);

/**
* Joins a list of strings with a comma, sorted alphabetically for consistency.
*
Expand Down
Loading

0 comments on commit 305b2f6

Please sign in to comment.