1 package fr.tiogars.chat.domains.task.controllers;
2
3 import static org.mockito.ArgumentMatchers.any;
4 import static org.mockito.ArgumentMatchers.eq;
5 import static org.mockito.Mockito.when;
6 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
7 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
8 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
9 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
10 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
11 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
12 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
13 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
14
15 import java.time.Instant;
16 import java.sql.SQLException;
17 import java.util.List;
18 import java.util.Optional;
19
20 import org.junit.jupiter.api.Test;
21 import org.hibernate.exception.ConstraintViolationException;
22 import org.springframework.dao.DataIntegrityViolationException;
23 import org.springframework.dao.OptimisticLockingFailureException;
24 import org.springframework.beans.factory.annotation.Autowired;
25 import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
26 import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
27 import org.springframework.context.annotation.Import;
28 import org.springframework.http.HttpHeaders;
29 import org.springframework.http.MediaType;
30 import org.springframework.security.core.userdetails.UserDetailsService;
31 import org.springframework.test.context.bean.override.mockito.MockitoBean;
32 import org.springframework.test.web.servlet.MockMvc;
33
34 import fr.tiogars.chat.common.models.DeleteSelectedResponse;
35 import fr.tiogars.chat.config.ApiCorsConfiguration;
36 import fr.tiogars.chat.domains.auth.services.JwtTokenService;
37 import fr.tiogars.chat.domains.task.models.CreateTaskRequest;
38 import fr.tiogars.chat.domains.task.models.Task;
39 import fr.tiogars.chat.domains.task.models.UpdateTaskRequest;
40 import fr.tiogars.chat.domains.task.services.TaskCsvCodec;
41 import fr.tiogars.chat.domains.task.services.TaskCreateService;
42 import fr.tiogars.chat.domains.task.services.TaskDeleteSelectedService;
43 import fr.tiogars.chat.domains.task.services.TaskDeleteService;
44 import fr.tiogars.chat.domains.task.services.TaskFindAllService;
45 import fr.tiogars.chat.domains.task.services.TaskFindByIdService;
46 import fr.tiogars.chat.domains.task.services.TaskSeedService;
47 import fr.tiogars.chat.domains.task.services.TaskUpdateService;
48
49 @WebMvcTest({
50 TaskCreateController.class,
51 TaskUpdateController.class,
52 TasksListController.class,
53 TaskReadController.class,
54 TaskDeleteController.class,
55 TaskDeleteSelectedController.class,
56 TaskSeedController.class,
57 TaskImportExportController.class
58 })
59 @AutoConfigureMockMvc(addFilters = false)
60 @Import(ApiCorsConfiguration.class)
61 public class TaskControllerTest {
62
63 @Autowired
64 private MockMvc mockMvc;
65
66 @MockitoBean
67 private TaskCreateService createService;
68
69 @MockitoBean
70 private TaskUpdateService updateService;
71
72 @MockitoBean
73 private TaskDeleteService deleteService;
74
75 @MockitoBean
76 private TaskFindAllService findAllService;
77
78 @MockitoBean
79 private TaskFindByIdService findByIdService;
80
81 @MockitoBean
82 private TaskSeedService seedService;
83
84 @MockitoBean
85 private TaskCsvCodec csvCodec;
86
87 @MockitoBean
88 private TaskDeleteSelectedService deleteSelectedService;
89
90 @MockitoBean
91 private JwtTokenService jwtTokenService;
92
93 @MockitoBean
94 private UserDetailsService userDetailsService;
95
96 @Test
97 public void deleteSelected_returnsDeletedAndSkippedIds() throws Exception {
98 when(deleteSelectedService.deleteSelected(List.of(1L, 2L)))
99 .thenReturn(new DeleteSelectedResponse(List.of(1L), List.of(2L)));
100
101 mockMvc.perform(post("/api/v1/task/delete-selected")
102 .contentType(MediaType.APPLICATION_JSON)
103 .content("{\"ids\":[1,2]}"))
104 .andExpect(status().isOk())
105 .andExpect(jsonPath("$.deletedIds[0]").value(1))
106 .andExpect(jsonPath("$.skippedIds[0]").value(2));
107 }
108
109 @Test
110 public void deleteSelected_rejectsEmptySelection() throws Exception {
111 mockMvc.perform(post("/api/v1/task/delete-selected")
112 .contentType(MediaType.APPLICATION_JSON)
113 .content("{\"ids\":[]}"))
114 .andExpect(status().isBadRequest());
115 }
116
117 @Test
118 public void create_returnsConflict_whenConcurrentWriterClaimsCode() throws Exception {
119 when(createService.create(any(CreateTaskRequest.class)))
120 .thenThrow(new DataIntegrityViolationException("Duplicate active code",
121 new ConstraintViolationException("Duplicate active code",
122 new SQLException(), "uk_tasks_active_code")));
123
124 mockMvc.perform(post("/api/v1/task")
125 .contentType(MediaType.APPLICATION_JSON)
126 .content("{\"code\":\"CUSTOMER\",\"label\":\"Customer\"}"))
127 .andExpect(status().isConflict());
128 }
129
130 @Test
131 public void update_returnsConflict_whenConcurrentWriterChangesRow() throws Exception {
132 when(updateService.update(eq(42L), any(UpdateTaskRequest.class)))
133 .thenThrow(new OptimisticLockingFailureException("Concurrent modification"));
134
135 mockMvc.perform(put("/api/v1/task/42")
136 .contentType(MediaType.APPLICATION_JSON)
137 .content("{\"label\":\"Customer\"}"))
138 .andExpect(status().isConflict());
139 }
140
141 @Test
142 public void create_returnsCreatedTemplateAndLocation() throws Exception {
143 when(createService.create(any(CreateTaskRequest.class))).thenReturn(Optional.of(template()));
144
145 mockMvc.perform(post("/api/v1/task")
146 .contentType(MediaType.APPLICATION_JSON)
147 .content("{\"code\":\"CUSTOMER\",\"label\":\"Customer\",\"description\":\"Example\"}"))
148 .andExpect(status().isCreated())
149 .andExpect(header().string("Location", "/api/v1/task/42"))
150 .andExpect(jsonPath("$.code").value("CUSTOMER"));
151 }
152
153 @Test
154 public void create_rejectsBlankCode() throws Exception {
155 mockMvc.perform(post("/api/v1/task")
156 .contentType(MediaType.APPLICATION_JSON)
157 .content("{\"code\":\" \",\"label\":\"Customer\"}"))
158 .andExpect(status().isBadRequest());
159 }
160
161 @Test
162 public void findAllAndFindById_returnTemplates() throws Exception {
163 when(findAllService.findAll()).thenReturn(List.of(template()));
164 when(findByIdService.findById(42L)).thenReturn(Optional.of(template()));
165
166 mockMvc.perform(get("/api/v1/task"))
167 .andExpect(status().isOk())
168 .andExpect(jsonPath("$[0].id").value(42))
169 .andExpect(jsonPath("$[0].createdBy").value("creator"))
170 .andExpect(jsonPath("$[0].lastModifiedBy").value("editor"));
171 mockMvc.perform(get("/api/v1/task/42"))
172 .andExpect(status().isOk())
173 .andExpect(jsonPath("$.label").value("Customer"))
174 .andExpect(jsonPath("$.createdBy").value("creator"))
175 .andExpect(jsonPath("$.lastModifiedBy").value("editor"));
176 }
177
178 @Test
179 public void findAll_allowsBackendConsoleOrigin() throws Exception {
180 when(findAllService.findAll()).thenReturn(List.of(template()));
181
182 mockMvc.perform(get("/api/v1/task")
183 .header(HttpHeaders.ORIGIN, "http://localhost:5173"))
184 .andExpect(status().isOk())
185 .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN,
186 "http://localhost:5173"));
187 }
188
189 @Test
190 public void updateAndDelete_usePersistentIdentifier() throws Exception {
191 when(updateService.update(eq(42L), any(UpdateTaskRequest.class)))
192 .thenReturn(Optional.of(template()));
193 when(deleteService.delete(42L)).thenReturn(true);
194
195 mockMvc.perform(put("/api/v1/task/42")
196 .contentType(MediaType.APPLICATION_JSON)
197 .content("{\"label\":\"Customer\",\"description\":\"Updated\"}"))
198 .andExpect(status().isOk());
199 mockMvc.perform(delete("/api/v1/task/42"))
200 .andExpect(status().isNoContent());
201 }
202
203 @Test
204 public void seedAndImportExport_useNewRoutes() throws Exception {
205 when(seedService.seed(any())).thenReturn(List.of(template()));
206 when(findAllService.findAll()).thenReturn(List.of(template()));
207 when(csvCodec.serialize(any())).thenReturn("id,code,label,description,createdDate,lastModifiedDate\n");
208 when(csvCodec.parse(any())).thenReturn(List.of(
209 new CreateTaskRequest("CUSTOMER", "Customer", null)));
210
211 mockMvc.perform(post("/api/v1/task/seed")
212 .contentType(MediaType.APPLICATION_JSON)
213 .content("[{\"code\":\"CUSTOMER\",\"label\":\"Customer\"}]"))
214 .andExpect(status().isCreated());
215 mockMvc.perform(get("/api/v1/task/export.csv"))
216 .andExpect(status().isOk())
217 .andExpect(content().contentTypeCompatibleWith("text/csv"));
218 mockMvc.perform(post("/api/v1/task/import.csv")
219 .contentType("text/csv")
220 .content("id,code,label,description,createdDate,lastModifiedDate\n"))
221 .andExpect(status().isCreated());
222 }
223
224 private static Task template() {
225 Instant timestamp = Instant.parse("2026-08-30T12:00:00Z");
226 return new Task(42L, "CUSTOMER", "Customer", "Example", timestamp, timestamp,
227 "creator", "editor");
228 }
229 }