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 | |