Skip to content

Commit

Permalink
Feature/4 add put endpoint to update a ticket (#29)
Browse files Browse the repository at this point in the history
**Refactoring:**
* Restructured NewTicketForm.tsx
* Renamed StatusChip to TicketStatusChip
* Added ApiRequests util for uniformity
* Add utility class for validation
* Renamed NewTicketForm to TicketForm and added component TicketCardsGrid
* Renamed type ApiResponseStatusSnackbar to SnackbarStatus
* Renamed "openDrawer" and "setOpenDrawer" to "sidepanelStatus" and "setSidepanelStatus"
* Changed sidePanelStatus from boolean to new SidePanelStatus type
* Refactored SidePanelStatus.ts

**Enhancement**
* Add functionality, clicking on ticket card opens sidepanel with ticket title and description
* Add put endpoint
* Add validation, tests and custom exception (for put endpoint)
* Added update functionality to frontend
* Renamed `NewTicket` and `UpdateTicket` to `...DTO`
  • Loading branch information
jonashonecker authored Jun 29, 2024
1 parent d79b4f9 commit b7b127d
Show file tree
Hide file tree
Showing 32 changed files with 778 additions and 253 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.github.jonashonecker.backend.error;

import com.github.jonashonecker.backend.error.domain.ApiErrorResponse;
import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException;
import com.github.jonashonecker.backend.user.exception.UserAuthenticationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
Expand Down Expand Up @@ -34,5 +35,12 @@ public ApiErrorResponse handleUserAuthenticationException(UserAuthenticationExce
);
}

@ExceptionHandler(NoSuchTicketException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiErrorResponse handleNoSuchTicketException(NoSuchTicketException ex) {

return new ApiErrorResponse(
ex.getMessage()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.github.jonashonecker.backend.ticket;

import com.github.jonashonecker.backend.ticket.domain.NewTicket;
import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO;
import com.github.jonashonecker.backend.ticket.domain.Ticket;
import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

Expand All @@ -22,14 +23,12 @@ public List<Ticket> getAllTickets() {
}

@PostMapping
public Ticket createTicket(@Valid @RequestBody NewTicket newTicket) {
return ticketService.createTicket(new Ticket(
null,
null,
newTicket.title(),
newTicket.description(),
null,
null
));
public Ticket createTicket(@Valid @RequestBody NewTicketDTO newTicketDTO) {
return ticketService.createTicket(newTicketDTO);
}

@PutMapping
public Ticket updateTicket(@Valid @RequestBody UpdateTicketDTO updateTicketDTO) {
return ticketService.updateTicket(updateTicketDTO);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.github.jonashonecker.backend.ticket;

import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO;
import com.github.jonashonecker.backend.ticket.domain.Status;
import com.github.jonashonecker.backend.ticket.domain.Ticket;
import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO;
import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException;
import com.github.jonashonecker.backend.user.UserService;
import org.springframework.stereotype.Service;

Expand All @@ -23,16 +26,32 @@ public List<Ticket> getAllTickets() {
return ticketRepository.findAll();
}

public Ticket createTicket(Ticket ticket) {
public Ticket getTicketById(String id) {
return ticketRepository.findById(id).orElseThrow(() -> new NoSuchTicketException("Could not find ticket with id: " + id));
}

public Ticket createTicket(NewTicketDTO newTicketDTO) {
String defaultProjectName = "Default Project";
Status defaultStatus = Status.OPEN;
Ticket newTicket = new Ticket(idService.getUUID(),
return ticketRepository.insert(new Ticket(
idService.getUUID(),
defaultProjectName,
ticket.title(),
ticket.description(),
newTicketDTO.title(),
newTicketDTO.description(),
defaultStatus,
userService.getCurrentUser()
);
return ticketRepository.insert(newTicket);
));
}

public Ticket updateTicket(UpdateTicketDTO updateTicketDTO) {
Ticket existingTicket = getTicketById(updateTicketDTO.id());
return ticketRepository.save(new Ticket(
existingTicket.id(),
existingTicket.projectName(),
updateTicketDTO.title(),
updateTicketDTO.description(),
existingTicket.status(),
existingTicket.author()
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import jakarta.validation.constraints.NotBlank;

public record NewTicket(
public record NewTicketDTO(
@NotBlank(message = "Title must not be empty")
String title,
@NotBlank(message = "Description must not be empty")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.github.jonashonecker.backend.ticket.domain;

import jakarta.validation.constraints.NotBlank;

public record UpdateTicketDTO(
@NotBlank(message = "Id must not be empty")
String id,
@NotBlank(message = "Title must not be empty")
String title,
@NotBlank(message = "Description must not be empty")
String description
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.github.jonashonecker.backend.ticket.exception;

import java.util.NoSuchElementException;

public class NoSuchTicketException extends NoSuchElementException {
public NoSuchTicketException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
Expand Down Expand Up @@ -230,4 +231,98 @@ void createTicket_whenInvalidUser_thenReturnApiErrorMessage() throws Exception {
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("There is an issue with your user login. Please contact support."));
}

@Test
void updateTicket_whenUnauthenticated_returnUnauthorized() throws Exception {
//GIVEN
//WHEN
mockMvc.perform(put("/api/ticket"))
//THEN
.andExpect(status().isUnauthorized())
.andExpect(content().string("")
);
}

@Test
@DirtiesContext
@WithMockUser
void updateTicket_whenUpdateTitleAndDescription_returnTicketWithUpdatedTitleAndDescription() throws Exception {
//GIVEN
TicketScoutUser ticketScoutUser = new TicketScoutUser("test-name", "test-avatarUrl");
ticketRepository.insert(new Ticket(
"test-id",
"test-projectName",
"test-title",
"test-description",
Status.IN_PROGRESS,
ticketScoutUser
));

//WHEN
mockMvc.perform(put("/api/ticket")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "test-id",
"title": "updated-title",
"description": "updated-description"
}
"""))
//THEN
.andExpect(status().isOk())
.andExpect(content().json("""
{
"id": "test-id",
"projectName": "test-projectName",
"title": "updated-title",
"description": "updated-description",
"status": "IN_PROGRESS",
"author": {"name": "test-name", "avatarUrl": "test-avatarUrl"}
}
"""));
}

@Test
@WithMockUser
void updateTicket_whenTicketNotInRepository_returnApiErrorMessage() throws Exception {
//GIVEN
//WHEN
mockMvc.perform(put("/api/ticket")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "test-id",
"title": "test-title",
"description": "test-description"
}
"""))
//THEN
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Could not find ticket with id: test-id"));
}

@Test
@WithMockUser
@DirtiesContext
void updateTicket_whenInvalidIdAndTitleAndDescription_thenReturnApiErrorMessage() throws Exception {
//GIVEN
//WHEN
mockMvc.perform(put("/api/ticket")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "",
"title": "",
"description": ""
}
"""))
//THEN
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error", containsString("Input validation failed for")))
.andExpect(jsonPath("$.error", containsString("and")))
.andExpect(jsonPath("$.error", containsString("description (Description must not be empty)")))
.andExpect(jsonPath("$.error", containsString("id (Id must not be empty)")))
.andExpect(jsonPath("$.error", containsString("title (Title must not be empty)")));
}

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.github.jonashonecker.backend.ticket;

import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO;
import com.github.jonashonecker.backend.ticket.domain.Status;
import com.github.jonashonecker.backend.ticket.domain.Ticket;
import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO;
import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException;
import com.github.jonashonecker.backend.user.UserService;
import com.github.jonashonecker.backend.user.domain.TicketScoutUser;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
Expand All @@ -31,6 +35,41 @@ void getAllTickets_whenRepositoryEmpty_returnEmptyList() {
assertEquals(List.of(), actual);
}

@Test
void getTicketById_whenTicketNotInRepository_throwNoSuchTicketException() {
//GIVEN
String id = "test-id";
when(ticketRepository.findById(id)).thenReturn(Optional.empty());

//WHEN
NoSuchTicketException actual = assertThrows(NoSuchTicketException.class, () -> ticketService.getTicketById(id));

//THEN
assertEquals("Could not find ticket with id: " + id, actual.getMessage());
}

@Test
void getTicketById_whenTicketInRepository_returnTicket() {
//GIVEN
String id = "test-id";
Ticket expected = new Ticket(
id,
"test-projectName",
"test-title",
"test-description",
Status.OPEN,
new TicketScoutUser("test-name", "test-avatarUrl")
);

when(ticketRepository.findById(id)).thenReturn(Optional.of(expected));

//WHEN
Ticket actual = ticketService.getTicketById(id);

//THEN
assertEquals(expected, actual);
}

@Test
void getAllTickets_whenRepositoryContainsTickets_returnTickets() {
//GIVEN
Expand Down Expand Up @@ -59,13 +98,9 @@ void createTicket_whenNewTicket_thenReturnsTicketWithDefaultProjectAndStatus() {
String defaultProject = "Default Project";
Status defaultStatus = Status.OPEN;
TicketScoutUser ticketScoutUser = new TicketScoutUser("test-name", "test-avatarUrl");
Ticket ticket = new Ticket(
"1",
"test-projectName",
NewTicketDTO newTicketDTO = new NewTicketDTO(
"test-title",
"test-description",
Status.IN_PROGRESS,
ticketScoutUser
"test-description"
);

Ticket expected = new Ticket(
Expand All @@ -82,12 +117,46 @@ void createTicket_whenNewTicket_thenReturnsTicketWithDefaultProjectAndStatus() {
when(ticketRepository.insert(any(Ticket.class))).thenReturn(expected);

//WHEN
Ticket actual = ticketService.createTicket(ticket);
Ticket actual = ticketService.createTicket(newTicketDTO);

//THEN
verify(ticketRepository, times(1)).insert(expected);
verify(idService, times(1)).getUUID();
verify(userService, times(1)).getCurrentUser();
assertEquals(expected, actual);
}

@Test
void updateTicket_whenUpdateTicketTitle_returnTicketWithUpdatedTitle() {
//GIVEN
String id = "test-id";
String description = "test-description";
UpdateTicketDTO updateTicketDTO = new UpdateTicketDTO(id, "new-updated-title", description);
Ticket ticketInDb = new Ticket(
id,
"test-projectName",
"test-title",
description,
Status.OPEN,
new TicketScoutUser("test-name", "test-avatarUrl")
);
Ticket expected = new Ticket(
id,
ticketInDb.projectName(),
"new-updated-title",
ticketInDb.description(),
ticketInDb.status(),
ticketInDb.author()
);
when(ticketRepository.findById(id)).thenReturn(Optional.of(ticketInDb));
when(ticketRepository.save(expected)).thenReturn(expected);

//WHEN
Ticket actual = ticketService.updateTicket(updateTicketDTO);

//THEN
verify(ticketRepository, times(1)).save(expected);
verify(ticketRepository, times(1)).findById(id);
assertEquals(expected, actual);
}
}
5 changes: 2 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import LoginPage from "./components/pages/LoginPage.tsx";
import { Route, Routes } from "react-router-dom";
import MainPage from "./components/pages/MainPage.tsx";
import { useEffect, useState } from "react";
import axios from "axios";
import ProtectedRoute from "./components/utils/ProtectedRoute.tsx";
import { User } from "./types/User.ts";
import Theme from "./components/theme/Theme.tsx";
import { Ticket } from "./types/Ticket.ts";
import ApiUtils from "./components/utils/ApiRequests.tsx";

export default function App() {
const [user, setUser] = useState<User | null | undefined>(undefined);
Expand All @@ -15,8 +15,7 @@ export default function App() {
);

const loadUser = () => {
axios
.get("/api/auth/me")
ApiUtils.getUser()
.then((response) => {
setUser(response.data);
})
Expand Down
Loading

0 comments on commit b7b127d

Please sign in to comment.