profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
ApiIntegrationTest.java 31,571 bytes
1 package com.tasteprint;
2
3 import static org.assertj.core.api.Assertions.assertThat;
4 import static org.hamcrest.Matchers.greaterThan;
5 import static org.hamcrest.Matchers.hasSize;
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.multipart;
9 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
10 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
11 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
12 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
13 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
14 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
15 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
16 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
17
18 import java.nio.charset.StandardCharsets;
19 import java.time.LocalDate;
20 import java.util.HashSet;
21 import java.util.LinkedHashMap;
22 import java.util.Map;
23 import java.util.Set;
24 import java.util.UUID;
25
26 import com.fasterxml.jackson.databind.JsonNode;
27 import com.fasterxml.jackson.databind.ObjectMapper;
28 import org.junit.jupiter.api.Test;
29 import org.springframework.beans.factory.annotation.Autowired;
30 import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
31 import org.springframework.boot.test.context.SpringBootTest;
32 import org.springframework.http.HttpHeaders;
33 import org.springframework.http.MediaType;
34 import org.springframework.mock.web.MockMultipartFile;
35 import org.springframework.test.web.servlet.MockMvc;
36 import org.springframework.test.web.servlet.MvcResult;
37
38 @SpringBootTest
39 @AutoConfigureMockMvc
40 class ApiIntegrationTest {
41
42 private static final String PASSWORD = "safe-password-42";
43
44 @Autowired
45 private MockMvc mvc;
46
47 @Autowired
48 private ObjectMapper objectMapper;
49
50 @Test
51 void publicCatalogAndAuthenticationBoundaryWork() throws Exception {
52 mvc.perform(get("/api/v1/catalog/destinations"))
53 .andExpect(status().isOk())
54 .andExpect(jsonPath("$", hasSize(12)))
55 .andExpect(jsonPath("$[0].code").value("JP"))
56 .andExpect(jsonPath("$[0].dishCount").value(6));
57
58 mvc.perform(get("/api/v1/catalog/destinations/JP"))
59 .andExpect(status().isOk())
60 .andExpect(jsonPath("$.dishes", hasSize(6)));
61
62 mvc.perform(get("/api/v1/progress/dashboard"))
63 .andExpect(status().isUnauthorized())
64 .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
65 .andExpect(jsonPath("$.detail").value("Sign in to continue."));
66
67 mvc.perform(get("/api/v1/progress/dashboard")
68 .header(HttpHeaders.AUTHORIZATION, "Bearer invalid-token"))
69 .andExpect(status().isUnauthorized());
70
71 mvc.perform(post("/api/v1/auth/register")
72 .contentType(MediaType.APPLICATION_JSON)
73 .content(json(Map.of(
74 "displayName", "Short Password",
75 "email", uniqueEmail("short"),
76 "password", "short"
77 ))))
78 .andExpect(status().isBadRequest())
79 .andExpect(jsonPath("$.errors.password").exists());
80 }
81
82 @Test
83 void accountLifecycleAndPublicProfileWork() throws Exception {
84 String email = uniqueEmail("account");
85 JsonNode session = register("Casey Traveler", email);
86 String token = session.get("token").asText();
87 String shareSlug = session.at("/user/shareSlug").asText();
88
89 assertThat(token).hasSize(43);
90 assertThat(session.toString()).doesNotContain("password", "passwordHash");
91
92 mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(token)))
93 .andExpect(status().isOk())
94 .andExpect(jsonPath("$.email").value(email))
95 .andExpect(jsonPath("$.profilePublic").value(false));
96
97 mvc.perform(get("/api/v1/public/tasteprints/{slug}", shareSlug))
98 .andExpect(status().isNotFound());
99
100 mvc.perform(patch("/api/v1/auth/me")
101 .header(HttpHeaders.AUTHORIZATION, bearer(token))
102 .contentType(MediaType.APPLICATION_JSON)
103 .content(profile("Casey T.", true)))
104 .andExpect(status().isOk())
105 .andExpect(jsonPath("$.displayName").value("Casey T."))
106 .andExpect(jsonPath("$.homeCountryCode").value("EE"))
107 .andExpect(jsonPath("$.profilePublic").value(true));
108
109 mvc.perform(get("/api/v1/public/tasteprints/{slug}", shareSlug))
110 .andExpect(status().isOk())
111 .andExpect(jsonPath("$.user.displayName").value("Casey T."))
112 .andExpect(jsonPath("$.user.email").doesNotExist())
113 .andExpect(jsonPath("$.tasteprint.stats.totalTastings").value(0));
114
115 mvc.perform(patch("/api/v1/auth/me")
116 .header(HttpHeaders.AUTHORIZATION, bearer(token))
117 .contentType(MediaType.APPLICATION_JSON)
118 .content(json(Map.of(
119 "displayName", "Casey T.",
120 "homeCountryCode", "EE",
121 "avatarUrl", "http://insecure.example/avatar.png",
122 "profilePublic", true
123 ))))
124 .andExpect(status().isBadRequest())
125 .andExpect(jsonPath("$.errors.avatarUrl").exists());
126
127 mvc.perform(post("/api/v1/auth/register")
128 .contentType(MediaType.APPLICATION_JSON)
129 .content(json(Map.of("displayName", "Duplicate", "email", email, "password", PASSWORD))))
130 .andExpect(status().isConflict());
131
132 mvc.perform(post("/api/v1/auth/login")
133 .contentType(MediaType.APPLICATION_JSON)
134 .content(json(Map.of("email", email, "password", "wrong-password"))))
135 .andExpect(status().isUnauthorized())
136 .andExpect(jsonPath("$.detail").value("Invalid email or password."));
137
138 mvc.perform(post("/api/v1/auth/logout").header(HttpHeaders.AUTHORIZATION, bearer(token)))
139 .andExpect(status().isNoContent());
140
141 mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(token)))
142 .andExpect(status().isUnauthorized());
143 }
144
145 @Test
146 void tastingCrudValidationPagingAndOwnershipWork() throws Exception {
147 String ownerToken = register("Tasting Owner", uniqueEmail("taste-owner")).get("token").asText();
148 String otherToken = register("Other Taster", uniqueEmail("taste-other")).get("token").asText();
149 LocalDate today = LocalDate.now();
150
151 MvcResult created = mvc.perform(post("/api/v1/tastings")
152 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
153 .contentType(MediaType.APPLICATION_JSON)
154 .content(tasting("ramen", "Tokyo", "jp", today, 5)))
155 .andExpect(status().isCreated())
156 .andExpect(jsonPath("$.dish.slug").value("ramen"))
157 .andExpect(jsonPath("$.countryCode").value("JP"))
158 .andReturn();
159 String tastingId = body(created).get("id").asText();
160
161 mvc.perform(get("/api/v1/tastings?page=-4&size=500")
162 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
163 .andExpect(status().isOk())
164 .andExpect(jsonPath("$.page").value(0))
165 .andExpect(jsonPath("$.size").value(50))
166 .andExpect(jsonPath("$.totalItems").value(1));
167
168 mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(otherToken)))
169 .andExpect(status().isOk())
170 .andExpect(jsonPath("$.totalItems").value(0));
171
172 mvc.perform(put("/api/v1/tastings/{id}", tastingId)
173 .header(HttpHeaders.AUTHORIZATION, bearer(otherToken))
174 .contentType(MediaType.APPLICATION_JSON)
175 .content(tasting("sushi", "Kyoto", "JP", today, 4)))
176 .andExpect(status().isNotFound());
177
178 mvc.perform(put("/api/v1/tastings/{id}", tastingId)
179 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
180 .contentType(MediaType.APPLICATION_JSON)
181 .content(tasting("sushi", "Kyoto", "JP", today, 4)))
182 .andExpect(status().isOk())
183 .andExpect(jsonPath("$.dish.slug").value("sushi"))
184 .andExpect(jsonPath("$.rating").value(4));
185
186 mvc.perform(post("/api/v1/tastings")
187 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
188 .contentType(MediaType.APPLICATION_JSON)
189 .content(tasting("ramen", "Tokyo", "JP", today.plusDays(1), 5)))
190 .andExpect(status().isBadRequest())
191 .andExpect(jsonPath("$.errors.tastedOn").exists());
192
193 Map<String, Object> invalidCoordinates = tastingMap("ramen", "Tokyo", "JP", today, 5);
194 invalidCoordinates.put("latitude", 59.4);
195 mvc.perform(post("/api/v1/tastings")
196 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
197 .contentType(MediaType.APPLICATION_JSON)
198 .content(json(invalidCoordinates)))
199 .andExpect(status().isBadRequest());
200
201 mvc.perform(delete("/api/v1/tastings/{id}", tastingId)
202 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
203 .andExpect(status().isNoContent());
204
205 mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
206 .andExpect(status().isOk())
207 .andExpect(jsonPath("$.totalItems").value(0));
208 }
209
210 @Test
211 void tripMissionUsesOnlyMatchingTastingsAndSurvivesTripDeletion() throws Exception {
212 String ownerToken = register("Trip Owner", uniqueEmail("trip-owner")).get("token").asText();
213 String otherToken = register("Trip Stranger", uniqueEmail("trip-other")).get("token").asText();
214 LocalDate startsOn = LocalDate.now().minusDays(4);
215 LocalDate endsOn = LocalDate.now();
216
217 MvcResult created = mvc.perform(post("/api/v1/trips")
218 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
219 .contentType(MediaType.APPLICATION_JSON)
220 .content(trip("JP", "Tokyo and Osaka", startsOn, endsOn)))
221 .andExpect(status().isCreated())
222 .andExpect(jsonPath("$.status").value("ACTIVE"))
223 .andExpect(jsonPath("$.mission", hasSize(5)))
224 .andExpect(jsonPath("$.missionCompleted").value(0))
225 .andReturn();
226 JsonNode trip = body(created);
227 String tripId = trip.get("id").asText();
228 String missionDish = trip.at("/mission/0/dish/slug").asText();
229 Set<String> missionDishes = new HashSet<>();
230 trip.get("mission").forEach(item -> missionDishes.add(item.at("/dish/slug").asText()));
231 assertThat(missionDishes).hasSize(5);
232
233 mvc.perform(get("/api/v1/trips/{id}", tripId)
234 .header(HttpHeaders.AUTHORIZATION, bearer(otherToken)))
235 .andExpect(status().isNotFound());
236
237 mvc.perform(put("/api/v1/trips/{id}", tripId)
238 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
239 .contentType(MediaType.APPLICATION_JSON)
240 .content(trip("MX", "Changed", startsOn, endsOn)))
241 .andExpect(status().isBadRequest());
242
243 mvc.perform(post("/api/v1/tastings")
244 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
245 .contentType(MediaType.APPLICATION_JSON)
246 .content(tasting(missionDish, "Tokyo", "EE", startsOn, 5)))
247 .andExpect(status().isCreated());
248
249 mvc.perform(get("/api/v1/trips/{id}", tripId)
250 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
251 .andExpect(status().isOk())
252 .andExpect(jsonPath("$.missionCompleted").value(0));
253
254 mvc.perform(post("/api/v1/tastings")
255 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
256 .contentType(MediaType.APPLICATION_JSON)
257 .content(tasting(missionDish, "Tokyo", "JP", startsOn, 5)))
258 .andExpect(status().isCreated());
259
260 mvc.perform(get("/api/v1/trips/{id}", tripId)
261 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
262 .andExpect(status().isOk())
263 .andExpect(jsonPath("$.missionCompleted").value(1))
264 .andExpect(jsonPath("$.culinaryCoverage", greaterThan(0)));
265
266 mvc.perform(delete("/api/v1/trips/{id}", tripId)
267 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
268 .andExpect(status().isNoContent());
269
270 mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
271 .andExpect(status().isOk())
272 .andExpect(jsonPath("$.totalItems").value(2));
273 }
274
275 @Test
276 void collaborativeChallengeEnforcesMembershipAndCombinesProgress() throws Exception {
277 JsonNode owner = register("Challenge Owner", uniqueEmail("challenge-owner"));
278 JsonNode member = register("Challenge Member", uniqueEmail("challenge-member"));
279 String ownerToken = owner.get("token").asText();
280 String memberToken = member.get("token").asText();
281 LocalDate startsOn = LocalDate.now().minusDays(2);
282 LocalDate endsOn = LocalDate.now().plusDays(2);
283
284 MvcResult created = mvc.perform(post("/api/v1/challenges")
285 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
286 .contentType(MediaType.APPLICATION_JSON)
287 .content(challenge("Japan table", "JP", startsOn, endsOn)))
288 .andExpect(status().isCreated())
289 .andExpect(jsonPath("$.participants", hasSize(1)))
290 .andReturn();
291 JsonNode challenge = body(created);
292 String challengeId = challenge.get("id").asText();
293 String joinCode = challenge.get("joinCode").asText();
294 assertThat(joinCode).hasSize(6).matches("[A-Z2-9]+$");
295
296 mvc.perform(get("/api/v1/challenges/{id}", challengeId)
297 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)))
298 .andExpect(status().isForbidden());
299
300 mvc.perform(post("/api/v1/challenges/join")
301 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))
302 .contentType(MediaType.APPLICATION_JSON)
303 .content(json(Map.of("joinCode", joinCode.toLowerCase()))))
304 .andExpect(status().isOk())
305 .andExpect(jsonPath("$.participants", hasSize(2)));
306
307 mvc.perform(delete("/api/v1/challenges/{id}/members/me", challengeId)
308 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
309 .andExpect(status().isBadRequest());
310
311 mvc.perform(post("/api/v1/tastings")
312 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))
313 .contentType(MediaType.APPLICATION_JSON)
314 .content(tasting("ramen", "Tallinn", "EE", LocalDate.now(), 4)))
315 .andExpect(status().isCreated());
316
317 mvc.perform(get("/api/v1/challenges/{id}", challengeId)
318 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
319 .andExpect(status().isOk())
320 .andExpect(jsonPath("$.groupCoverage", greaterThan(0)))
321 .andExpect(jsonPath("$.participants[0].contributedDishes").value(1));
322
323 mvc.perform(delete("/api/v1/challenges/{id}", challengeId)
324 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)))
325 .andExpect(status().isForbidden());
326
327 mvc.perform(delete("/api/v1/challenges/{id}/members/me", challengeId)
328 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)))
329 .andExpect(status().isNoContent());
330
331 mvc.perform(get("/api/v1/challenges/{id}", challengeId)
332 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)))
333 .andExpect(status().isForbidden());
334
335 mvc.perform(delete("/api/v1/challenges/{id}", challengeId)
336 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
337 .andExpect(status().isNoContent());
338 }
339
340 @Test
341 void publicComparisonRespectsPrivacyAndSuggestsANewDish() throws Exception {
342 JsonNode first = register("First Map", uniqueEmail("compare-first"));
343 JsonNode second = register("Second Map", uniqueEmail("compare-second"));
344 String firstToken = first.get("token").asText();
345 String secondToken = second.get("token").asText();
346 String secondSlug = second.at("/user/shareSlug").asText();
347
348 mvc.perform(get("/api/v1/social/compare/{slug}", secondSlug)
349 .header(HttpHeaders.AUTHORIZATION, bearer(firstToken)))
350 .andExpect(status().isNotFound());
351
352 mvc.perform(patch("/api/v1/auth/me")
353 .header(HttpHeaders.AUTHORIZATION, bearer(secondToken))
354 .contentType(MediaType.APPLICATION_JSON)
355 .content(profile("Second Map", true)))
356 .andExpect(status().isOk());
357
358 mvc.perform(post("/api/v1/tastings")
359 .header(HttpHeaders.AUTHORIZATION, bearer(firstToken))
360 .contentType(MediaType.APPLICATION_JSON)
361 .content(tasting("ramen", "Tokyo", "JP", LocalDate.now(), 5)))
362 .andExpect(status().isCreated());
363 mvc.perform(post("/api/v1/tastings")
364 .header(HttpHeaders.AUTHORIZATION, bearer(secondToken))
365 .contentType(MediaType.APPLICATION_JSON)
366 .content(tasting("ramen", "Tokyo", "JP", LocalDate.now(), 4)))
367 .andExpect(status().isCreated());
368 Map<String, Object> publicTasting = tastingMap("sushi", "Tokyo", "JP", LocalDate.now(), 5);
369 publicTasting.put("restaurantName", "Private table");
370 publicTasting.put("note", "A public note without a precise location.");
371 publicTasting.put("latitude", 35.6762);
372 publicTasting.put("longitude", 139.6503);
373 mvc.perform(post("/api/v1/tastings")
374 .header(HttpHeaders.AUTHORIZATION, bearer(secondToken))
375 .contentType(MediaType.APPLICATION_JSON)
376 .content(json(publicTasting)))
377 .andExpect(status().isCreated());
378
379 mvc.perform(get("/api/v1/public/tasteprints/{slug}", secondSlug))
380 .andExpect(status().isOk())
381 .andExpect(jsonPath("$.tasteprint.recentTastings[0].city").value("Tokyo"))
382 .andExpect(jsonPath("$.tasteprint.recentTastings[0].note").exists())
383 .andExpect(jsonPath("$.tasteprint.recentTastings[0].restaurantName").doesNotExist())
384 .andExpect(jsonPath("$.tasteprint.recentTastings[0].latitude").doesNotExist())
385 .andExpect(jsonPath("$.tasteprint.recentTastings[0].longitude").doesNotExist())
386 .andExpect(jsonPath("$.tasteprint.recentTastings[0].createdAt").doesNotExist());
387
388 mvc.perform(get("/api/v1/social/compare/{slug}", secondSlug)
389 .header(HttpHeaders.AUTHORIZATION, bearer(firstToken)))
390 .andExpect(status().isOk())
391 .andExpect(jsonPath("$.overlapScore").value(50))
392 .andExpect(jsonPath("$.sharedDishes").value(1))
393 .andExpect(jsonPath("$.sharedCountries", hasSize(1)))
394 .andExpect(jsonPath("$.suggestedSharedBite.slug").value("sushi"));
395 }
396
397 @Test
398 void mediaUploadChecksAuthenticationTypeSizeAndSignature() throws Exception {
399 String token = register("Photo Owner", uniqueEmail("photo")).get("token").asText();
400 String otherToken = register("Photo Stranger", uniqueEmail("photo-stranger")).get("token").asText();
401 byte[] pngSignature = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
402 MockMultipartFile valid = new MockMultipartFile("file", "meal.png", "image/png", pngSignature);
403
404 mvc.perform(multipart("/api/v1/media").file(valid))
405 .andExpect(status().isUnauthorized());
406
407 MvcResult uploaded = mvc.perform(multipart("/api/v1/media").file(valid)
408 .header(HttpHeaders.AUTHORIZATION, bearer(token)))
409 .andExpect(status().isCreated())
410 .andExpect(jsonPath("$.url").value(org.hamcrest.Matchers.matchesPattern("/uploads/[a-f0-9-]+\\.png")))
411 .andExpect(jsonPath("$.contentType").value("image/png"))
412 .andExpect(jsonPath("$.size").value(8))
413 .andReturn();
414 String photoUrl = body(uploaded).get("url").asText();
415
416 mvc.perform(get(photoUrl))
417 .andExpect(status().isOk())
418 .andExpect(content().bytes(pngSignature));
419
420 Map<String, Object> stolenPhoto = tastingMap("ramen", "Tokyo", "JP", LocalDate.now(), 5);
421 stolenPhoto.put("photoUrl", photoUrl);
422 mvc.perform(post("/api/v1/tastings")
423 .header(HttpHeaders.AUTHORIZATION, bearer(otherToken))
424 .contentType(MediaType.APPLICATION_JSON)
425 .content(json(stolenPhoto)))
426 .andExpect(status().isForbidden());
427
428 MvcResult firstTasting = mvc.perform(post("/api/v1/tastings")
429 .header(HttpHeaders.AUTHORIZATION, bearer(token))
430 .contentType(MediaType.APPLICATION_JSON)
431 .content(json(stolenPhoto)))
432 .andExpect(status().isCreated())
433 .andReturn();
434 Map<String, Object> reusedPhoto = tastingMap("sushi", "Kyoto", "JP", LocalDate.now(), 4);
435 reusedPhoto.put("photoUrl", photoUrl);
436 MvcResult secondTasting = mvc.perform(post("/api/v1/tastings")
437 .header(HttpHeaders.AUTHORIZATION, bearer(token))
438 .contentType(MediaType.APPLICATION_JSON)
439 .content(json(reusedPhoto)))
440 .andExpect(status().isCreated())
441 .andReturn();
442
443 mvc.perform(delete("/api/v1/tastings/{id}", body(firstTasting).get("id").asText())
444 .header(HttpHeaders.AUTHORIZATION, bearer(token)))
445 .andExpect(status().isNoContent());
446 mvc.perform(get(photoUrl)).andExpect(status().isOk());
447
448 mvc.perform(delete("/api/v1/tastings/{id}", body(secondTasting).get("id").asText())
449 .header(HttpHeaders.AUTHORIZATION, bearer(token)))
450 .andExpect(status().isNoContent());
451 mvc.perform(get(photoUrl)).andExpect(status().isNotFound());
452
453 MockMultipartFile fakePng = new MockMultipartFile(
454 "file", "fake.png", "image/png", "not an image".getBytes(StandardCharsets.UTF_8)
455 );
456 mvc.perform(multipart("/api/v1/media").file(fakePng)
457 .header(HttpHeaders.AUTHORIZATION, bearer(token)))
458 .andExpect(status().isBadRequest())
459 .andExpect(jsonPath("$.detail").value("The uploaded file is not a valid image."));
460
461 MockMultipartFile tooLarge = new MockMultipartFile(
462 "file", "large.png", "image/png", new byte[6 * 1024 * 1024 + 1]
463 );
464 mvc.perform(multipart("/api/v1/media").file(tooLarge)
465 .header(HttpHeaders.AUTHORIZATION, bearer(token)))
466 .andExpect(status().isBadRequest())
467 .andExpect(jsonPath("$.detail").value("Photo must be smaller than 6 MB."));
468 }
469
470 @Test
471 void accountDeletionRequiresPasswordAndRemovesOwnedChallenges() throws Exception {
472 String ownerEmail = uniqueEmail("delete-owner");
473 String ownerToken = register("Delete Owner", ownerEmail).get("token").asText();
474 String memberToken = register("Remaining Member", uniqueEmail("delete-member")).get("token").asText();
475 LocalDate today = LocalDate.now();
476 byte[] pngSignature = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
477 MvcResult orphanUpload = mvc.perform(multipart("/api/v1/media")
478 .file(new MockMultipartFile("file", "orphan.png", "image/png", pngSignature))
479 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
480 .andExpect(status().isCreated())
481 .andReturn();
482 String orphanUrl = body(orphanUpload).get("url").asText();
483
484 MvcResult created = mvc.perform(post("/api/v1/challenges")
485 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
486 .contentType(MediaType.APPLICATION_JSON)
487 .content(challenge("Temporary table", "PT", today, today.plusDays(5))))
488 .andExpect(status().isCreated())
489 .andReturn();
490 String joinCode = body(created).get("joinCode").asText();
491
492 mvc.perform(post("/api/v1/challenges/join")
493 .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))
494 .contentType(MediaType.APPLICATION_JSON)
495 .content(json(Map.of("joinCode", joinCode))))
496 .andExpect(status().isOk());
497
498 mvc.perform(delete("/api/v1/auth/me")
499 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
500 .contentType(MediaType.APPLICATION_JSON)
501 .content(json(Map.of("password", "wrong-password"))))
502 .andExpect(status().isUnauthorized())
503 .andExpect(jsonPath("$.detail").value("Invalid password."));
504
505 mvc.perform(delete("/api/v1/auth/me")
506 .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))
507 .contentType(MediaType.APPLICATION_JSON)
508 .content(json(Map.of("password", PASSWORD))))
509 .andExpect(status().isNoContent());
510
511 mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)))
512 .andExpect(status().isUnauthorized());
513 mvc.perform(get(orphanUrl)).andExpect(status().isNotFound());
514
515 mvc.perform(get("/api/v1/challenges").header(HttpHeaders.AUTHORIZATION, bearer(memberToken)))
516 .andExpect(status().isOk())
517 .andExpect(jsonPath("$", hasSize(0)));
518
519 mvc.perform(post("/api/v1/auth/login")
520 .contentType(MediaType.APPLICATION_JSON)
521 .content(json(Map.of("email", ownerEmail, "password", PASSWORD))))
522 .andExpect(status().isUnauthorized());
523 }
524
525 @Test
526 void corsAllowsConfiguredFrontendAndRejectsUnknownOrigins() throws Exception {
527 mvc.perform(options("/api/v1/catalog/destinations")
528 .header(HttpHeaders.ORIGIN, "http://localhost:5173")
529 .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"))
530 .andExpect(status().isOk())
531 .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173"));
532
533 mvc.perform(options("/api/v1/catalog/destinations")
534 .header(HttpHeaders.ORIGIN, "https://unknown.example")
535 .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"))
536 .andExpect(status().isForbidden());
537 }
538
539 private JsonNode register(String displayName, String email) throws Exception {
540 MvcResult result = mvc.perform(post("/api/v1/auth/register")
541 .contentType(MediaType.APPLICATION_JSON)
542 .content(json(Map.of(
543 "displayName", displayName,
544 "email", email,
545 "password", PASSWORD
546 ))))
547 .andExpect(status().isCreated())
548 .andExpect(jsonPath("$.token").isString())
549 .andReturn();
550 return body(result);
551 }
552
553 private String uniqueEmail(String prefix) {
554 return prefix + "+" + UUID.randomUUID() + "@example.com";
555 }
556
557 private String bearer(String token) {
558 return "Bearer " + token;
559 }
560
561 private String tasting(String dishSlug, String city, String countryCode, LocalDate date, int rating)
562 throws Exception {
563 return json(tastingMap(dishSlug, city, countryCode, date, rating));
564 }
565
566 private Map<String, Object> tastingMap(String dishSlug, String city, String countryCode,
567 LocalDate date, int rating) {
568 Map<String, Object> value = new LinkedHashMap<>();
569 value.put("dishSlug", dishSlug);
570 value.put("city", city);
571 value.put("countryCode", countryCode);
572 value.put("tastedOn", date.toString());
573 value.put("rating", rating);
574 return value;
575 }
576
577 private String trip(String destinationCode, String city, LocalDate startsOn, LocalDate endsOn)
578 throws Exception {
579 return json(Map.of(
580 "destinationCode", destinationCode,
581 "city", city,
582 "startsOn", startsOn.toString(),
583 "endsOn", endsOn.toString()
584 ));
585 }
586
587 private String challenge(String title, String destinationCode, LocalDate startsOn, LocalDate endsOn)
588 throws Exception {
589 return json(Map.of(
590 "title", title,
591 "destinationCode", destinationCode,
592 "startsOn", startsOn.toString(),
593 "endsOn", endsOn.toString()
594 ));
595 }
596
597 private String profile(String displayName, boolean profilePublic) throws Exception {
598 Map<String, Object> profile = new LinkedHashMap<>();
599 profile.put("displayName", displayName);
600 profile.put("homeCity", "Tallinn");
601 profile.put("homeCountryCode", "ee");
602 profile.put("bio", "Trips remembered through food.");
603 profile.put("avatarUrl", null);
604 profile.put("profilePublic", profilePublic);
605 return json(profile);
606 }
607
608 private String json(Object value) throws Exception {
609 return objectMapper.writeValueAsString(value);
610 }
611
612 private JsonNode body(MvcResult result) throws Exception {
613 return objectMapper.readTree(result.getResponse().getContentAsByteArray());
614 }
615 }
616