1. Overview
In this installment of the Reddit App case study, we're going to start keeping track of the history of submission attempts for a post, and make the statuses more descriptive and easy to understand.
2. Improving the Post Entity
First, let's start by replacing the old String status in the Post entity with a much more complete list of submission responses, keeping track of a lot more information:
public class Post {
...
@OneToMany(fetch = FetchType.EAGER, mappedBy = "post")
private List<SubmissionResponse> submissionsResponse;
}
Next, let's see what we're actually keeping track of in this new submission response entity:
@Entity
public class SubmissionResponse implements IEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private int attemptNumber;
private String content;
private Date submissionDate;
private Date scoreCheckDate;
@JsonIgnore
@ManyToOne
@JoinColumn(name = "post_id", nullable = false)
private Post post;
public SubmissionResponse(int attemptNumber, String content, Post post) {
super();
this.attemptNumber = attemptNumber;
this.content = content;
this.submissionDate = new Date();
this.post = post;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("Attempt No ").append(attemptNumber).append(" : ").append(content);
return builder.toString();
}
}
Note that each consumed submission attempt has a SubmissionResponse, so that:
- attemptNumber: the number of this attempt
- content: the detailed response of this attempt
- submissionDate: the submission date of this attempt
- scoreCheckDate: the date we checked the score of the Reddit Post in this attempt
And here is the simple Spring Data JPA repository:
public interface SubmissionResponseRepository extends JpaRepository<SubmissionResponse, Long> {
SubmissionResponse findOneByPostAndAttemptNumber(Post post, int attemptNumber);
}
3. Scheduling Service
We now need to start modifying the service layer to keep track of this extra information.
We'll first make sure we have a nicely formatted success or failure reasons for why the Post was considered a success or a failure:
private final static String SCORE_TEMPLATE = "score %d %s minimum score %d";
private final static String TOTAL_VOTES_TEMPLATE = "total votes %d %s minimum total votes %d";
protected String getFailReason(Post post, PostScores postScores) {
StringBuilder builder = new StringBuilder();
builder.append("Failed because ");
builder.append(String.format(
SCORE_TEMPLATE, postScores.getScore(), "<", post.getMinScoreRequired()));
if (post.getMinTotalVotes() > 0) {
builder.append(" and ");
builder.append(String.format(TOTAL_VOTES_TEMPLATE,
postScores.getTotalVotes(), "<", post.getMinTotalVotes()));
}
if (post.isKeepIfHasComments()) {
builder.append(" and has no comments");
}
return builder.toString();
}
protected String getSuccessReason(Post post, PostScores postScores) {
StringBuilder builder = new StringBuilder();
if (postScores.getScore() >= post.getMinScoreRequired()) {
builder.append("Succeed because ");
builder.append(String.format(SCORE_TEMPLATE,
postScores.getScore(), ">=", post.getMinScoreRequired()));
return builder.toString();
}
if (
(post.getMinTotalVotes() > 0) &&
(postScores.getTotalVotes() >= post.getMinTotalVotes())
) {
builder.append("Succeed because ");
builder.append(String.format(TOTAL_VOTES_TEMPLATE,
postScores.getTotalVotes(), ">=", post.getMinTotalVotes()));
return builder.toString();
}
return "Succeed because has comments";
}
Now, we'll improve the old logic and keep track of this extra historical information:
private void submitPost(...) {
...
if (errorNode == null) {
post.setSubmissionsResponse(addAttemptResponse(post, "Submitted to Reddit"));
...
} else {
post.setSubmissionsResponse(addAttemptResponse(post, errorNode.toString()));
...
}
}
private void checkAndReSubmit(Post post) {
if (didIntervalPass(...)) {
PostScores postScores = getPostScores(post);
if (didPostGoalFail(post, postScores)) {
...
resetPost(post, getFailReason(post, postScores));
} else {
...
updateLastAttemptResponse(
post, "Post reached target score successfully " +
getSuccessReason(post, postScores));
}
}
}
private void checkAndDeleteInternal(Post post) {
if (didIntervalPass(...)) {
PostScores postScores = getPostScores(post);
if (didPostGoalFail(post, postScores)) {
updateLastAttemptResponse(post,
"Deleted from reddit, consumed all attempts without reaching score " +
getFailReason(post, postScores));
...
} else {
updateLastAttemptResponse(post,
"Post reached target score successfully " +
getSuccessReason(post, postScores));
...
}
}
}
private void resetPost(Post post, String failReason) {
...
updateLastAttemptResponse(post, "Deleted from Reddit, to be resubmitted " + failReason);
...
}
Note what the lower level methods are actually doing:
- addAttemptResponse(): creates a new SubmissionResponse record and adds it to the Post (called on every submission attempt)
- updateLastAttemptResponse(): update the last attempt response (called while checking post's score)
4. Scheduled Post DTO
Next, we'll modify the DTO to make sure this new information gets exposed back to the client:
public class ScheduledPostDto {
...
private String status;
private List<SubmissionResponseDto> detailedStatus;
}
And here's the simple SubmissionResponseDto:
public class SubmissionResponseDto {
private int attemptNumber;
private String content;
private String localSubmissionDate;
private String localScoreCheckDate;
}
We will also modify conversion method in our ScheduledPostRestController:
private ScheduledPostDto convertToDto(Post post) {
...
List<SubmissionResponse> response = post.getSubmissionsResponse();
if ((response != null) && (response.size() > 0)) {
postDto.setStatus(response.get(response.size() - 1).toString().substring(0, 30));
List<SubmissionResponseDto> responsedto =
post.getSubmissionsResponse().stream().
map(res -> generateResponseDto(res)).collect(Collectors.toList());
postDto.setDetailedStatus(responsedto);
} else {
postDto.setStatus("Not sent yet");
postDto.setDetailedStatus(Collections.emptyList());
}
return postDto;
}
private SubmissionResponseDto generateResponseDto(SubmissionResponse responseEntity) {
SubmissionResponseDto dto = modelMapper.map(responseEntity, SubmissionResponseDto.class);
String timezone = userService.getCurrentUser().getPreference().getTimezone();
dto.setLocalSubmissionDate(responseEntity.getSubmissionDate(), timezone);
if (responseEntity.getScoreCheckDate() != null) {
dto.setLocalScoreCheckDate(responseEntity.getScoreCheckDate(), timezone);
}
return dto;
}
5. Front End
Next, we will modify our front-end scheduledPosts.jsp to handle our new response:
<div class="modal">
<h4 class="modal-title">Detailed Status</h4>
<table id="res"></table>
</div>
<script >
var loadedData = [];
var detailedResTable = $('#res').DataTable( {
"searching":false,
"paging": false,
columns: [
{ title: "Attempt Number", data: "attemptNumber" },
{ title: "Detailed Status", data: "content" },
{ title: "Attempt Submitted At", data: "localSubmissionDate" },
{ title: "Attempt Score Checked At", data: "localScoreCheckDate" }
]
} );
$(document).ready(function() {
$('#myposts').dataTable( {
...
"columnDefs": [
{ "targets": 2, "data": "status",
"render": function ( data, type, full, meta ) {
return data +
' <a href="#" onclick="showDetailedStatus('+meta.row+' )">More Details</a>';
}
},
....
],
...
});
});
function showDetailedStatus(row){
detailedResTable.clear().rows.add(loadedData[row].detailedStatus).draw();
$('.modal').modal();
}
</script>
6. Tests
Finally, we will perform a simple unit test on our new methods:
First, we'll test the getSuccessReason() implementation:
@Test
public void whenHasEnoughScore_thenSucceed() {
Post post = new Post();
post.setMinScoreRequired(5);
PostScores postScores = new PostScores(6, 10, 1);
assertTrue(getSuccessReason(post, postScores).contains("Succeed because score"));
}
@Test
public void whenHasEnoughTotalVotes_thenSucceed() {
Post post = new Post();
post.setMinScoreRequired(5);
post.setMinTotalVotes(8);
PostScores postScores = new PostScores(2, 10, 1);
assertTrue(getSuccessReason(post, postScores).contains("Succeed because total votes"));
}
@Test
public void givenKeepPostIfHasComments_whenHasComments_thenSucceed() {
Post post = new Post();
post.setMinScoreRequired(5);
post.setKeepIfHasComments(true);
final PostScores postScores = new PostScores(2, 10, 1);
assertTrue(getSuccessReason(post, postScores).contains("Succeed because has comments"));
}
Next, we will test the getFailReason() implementation:
@Test
public void whenNotEnoughScore_thenFail() {
Post post = new Post();
post.setMinScoreRequired(5);
PostScores postScores = new PostScores(2, 10, 1);
assertTrue(getFailReason(post, postScores).contains("Failed because score"));
}
@Test
public void whenNotEnoughTotalVotes_thenFail() {
Post post = new Post();
post.setMinScoreRequired(5);
post.setMinTotalVotes(15);
PostScores postScores = new PostScores(2, 10, 1);
String reason = getFailReason(post, postScores);
assertTrue(reason.contains("Failed because score"));
assertTrue(reason.contains("and total votes"));
}
@Test
public void givenKeepPostIfHasComments_whenNotHasComments_thenFail() {
Post post = new Post();
post.setMinScoreRequired(5);
post.setKeepIfHasComments(true);
final PostScores postScores = new PostScores(2, 10, 0);
String reason = getFailReason(post, postScores);
assertTrue(reason.contains("Failed because score"));
assertTrue(reason.contains("and has no comments"));
}
7. Conclusion
In this installment, we introduced some very useful visibility into the lifecycle of a Reddit post. We can now see exactly when a post was submitted and deleted each time, along with the exact reason for each operation.